snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 Annotations about the code that implements koment.

internal/store/store.go

1 package store
2
3 import (
4 "crypto/rand"
5 "encoding/hex"
6 "errors"
7 "fmt"
8 "io"
9 "io/fs"
10 "os"
11 "path"
12 "path/filepath"
13 "sort"
14 "strings"
15
16 yaml "go.yaml.in/yaml/v3"
17 )
18
19 const (
20 DirName = ".koment"
21
22 annotationsDir = "annotations"
23 recordSuffix = ".yaml"
24 yamlIndent = 2
25 )
26
27 const schemaDirective = "# yaml-language-server: $schema=" + SchemaURL + "\n"
28
29 type Store struct{ root string }
30
31 func Open(root string) *Store { return &Store{root: root} }
32
33 func (s *Store) Root() string { return s.root }
34
35 func closeRepositoryRoot(root *os.Root, returnedError *error) {
36 if err := root.Close(); err != nil {
37 *returnedError = errors.Join(*returnedError, fmt.Errorf("closing repository root: %w", err))
38 }
39 }
40
41 // FindRoot walks up from start for the directory that owns the annotations,
42 // preferring an existing .koment over the enclosing git work tree.
43 func FindRoot(start string) (string, error) {
44 directory, err := filepath.Abs(start)
45 if err != nil {
46 return "", fmt.Errorf("resolving %s: %w", start, err)
47 }
48
49 gitRoot := ""
50 for {
51 if isDir(filepath.Join(directory, DirName)) {
52 return directory, nil
53 }
54 if gitRoot == "" && exists(filepath.Join(directory, ".git")) {
55 gitRoot = directory
56 }
57 parent := filepath.Dir(directory)
58 if parent == directory {
59 break
60 }
61 directory = parent
62 }
63
64 if gitRoot != "" {
65 return gitRoot, nil
66 }
67 return "", fmt.Errorf("no %s or .git directory at or above %s", DirName, start)
68 }
69
70 // FromWorkingDirectory reads a path the way a person typing it at a shell
71 // prompt means it: relative to where they are standing.
72 func (s *Store) FromWorkingDirectory(path string) (string, error) {
73 absolute, err := filepath.Abs(path)
74 if err != nil {
75 return "", fmt.Errorf("resolving %s: %w", path, err)
76 }
77 return s.fromAbsolute(absolute, path)
78 }
79
80 // FromRoot reads a path the way an API caller means it: already relative to the
81 // repository root, wherever the koment process happens to be running.
82 func (s *Store) FromRoot(path string) (string, error) {
83 if filepath.IsAbs(path) {
84 return s.fromAbsolute(path, path)
85 }
86 return validSourcePath(filepath.ToSlash(filepath.Clean(path)))
87 }
88
89 func (s *Store) fromAbsolute(absolute, original string) (string, error) {
90 relative, err := filepath.Rel(s.root, absolute)
91 if err != nil {
92 return "", fmt.Errorf("%s is not inside %s: %w", original, s.root, err)
93 }
94 return validSourcePath(filepath.ToSlash(relative))
95 }
96
97 func validSourcePath(value string) (string, error) {
98 if strings.Contains(value, `\`) {
99 return "", fmt.Errorf("source path %s must use forward slashes", value)
100 }
101 clean := path.Clean(value)
102 switch {
103 case clean == "" || clean == ".":
104 return "", fmt.Errorf("empty source path")
105 case clean != value:
106 return "", fmt.Errorf("source path %s is not canonical; use %s", value, clean)
107 case path.IsAbs(clean) || hasDrivePrefix(clean):
108 return "", fmt.Errorf("source path %s must be relative to the repository root", value)
109 case clean == ".." || strings.HasPrefix(clean, "../"):
110 return "", fmt.Errorf("source path %s escapes the repository root", value)
111 }
112 return clean, nil
113 }
114
115 func hasDrivePrefix(value string) bool {
116 if len(value) < 2 || value[1] != ':' {
117 return false
118 }
119 letter := value[0]
120 return letter >= 'A' && letter <= 'Z' || letter >= 'a' && letter <= 'z'
121 }
122
123 func (s *Store) ReadSource(file string) (_ []byte, returnedError error) {
124 clean, err := validSourcePath(file)
125 if err != nil {
126 return nil, err
127 }
128 root, err := os.OpenRoot(s.root)
129 if err != nil {
130 return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
131 }
132 defer closeRepositoryRoot(root, &returnedError)
133 content, err := root.ReadFile(filepath.FromSlash(clean))
134 if err != nil {
135 return nil, fmt.Errorf("reading source %s: %w", clean, err)
136 }
137 return content, nil
138 }
139
140 // WriteSource atomically replaces a repository file without crossing its root.
141 func (s *Store) WriteSource(file string, content []byte) (returnedError error) {
142 clean, err := validSourcePath(file)
143 if err != nil {
144 return err
145 }
146 root, err := os.OpenRoot(s.root)
147 if err != nil {
148 return fmt.Errorf("opening repository root %s: %w", s.root, err)
149 }
150 defer closeRepositoryRoot(root, &returnedError)
151 name := filepath.FromSlash(clean)
152 information, err := root.Stat(name)
153 if err != nil {
154 return fmt.Errorf("reading permissions for %s: %w", clean, err)
155 }
156 if err := writeAtomicallyWithMode(root, name, content, information.Mode().Perm()); err != nil {
157 return fmt.Errorf("writing source %s: %w", clean, err)
158 }
159 return nil
160 }
161
162 func (s *Store) RecordPath(id string) (string, error) {
163 name, err := recordName(id)
164 if err != nil {
165 return "", err
166 }
167 return filepath.Join(s.root, name), nil
168 }
169
170 func recordName(id string) (string, error) {
171 if !ValidID(id) {
172 return "", fmt.Errorf("annotation id %q is not a canonical ULID", id)
173 }
174 return filepath.Join(DirName, annotationsDir, id+recordSuffix), nil
175 }
176
177 func (s *Store) Load(id string) (_ *Annotation, returnedError error) {
178 name, err := recordName(id)
179 if err != nil {
180 return nil, err
181 }
182 root, err := os.OpenRoot(s.root)
183 if err != nil {
184 return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
185 }
186 defer closeRepositoryRoot(root, &returnedError)
187 content, err := root.ReadFile(name)
188 if err != nil {
189 return nil, err
190 }
191 return DecodeAnnotation(id, content)
192 }
193
194 // DecodeAnnotation validates one record read from a non-filesystem source.
195 func DecodeAnnotation(id string, content []byte) (*Annotation, error) {
196 name, err := recordName(id)
197 if err != nil {
198 return nil, err
199 }
200 var annotation Annotation
201 decoder := yaml.NewDecoder(strings.NewReader(string(content)))
202 decoder.KnownFields(true)
203 if err := decoder.Decode(&annotation); err != nil {
204 return nil, fmt.Errorf("parsing %s: %w", name, err)
205 }
206 var trailing any
207 if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
208 if err == nil {
209 return nil, fmt.Errorf("parsing %s: multiple YAML documents are not allowed", name)
210 }
211 return nil, fmt.Errorf("parsing %s after the annotation: %w", name, err)
212 }
213 if err := annotation.Validate(); err != nil {
214 return nil, fmt.Errorf("in %s: %w", name, err)
215 }
216 if annotation.ID != id {
217 return nil, fmt.Errorf("in %s: record claims id %s but filename claims %s", name, annotation.ID, id)
218 }
219 return &annotation, nil
220 }
221
222 func (s *Store) Save(annotation *Annotation) (returnedError error) {
223 name, err := recordName(annotation.ID)
224 if err != nil {
225 return err
226 }
227 encoded, err := EncodeAnnotation(annotation)
228 if err != nil {
229 return err
230 }
231 root, err := os.OpenRoot(s.root)
232 if err != nil {
233 return fmt.Errorf("opening repository root %s: %w", s.root, err)
234 }
235 defer closeRepositoryRoot(root, &returnedError)
236 if err := root.MkdirAll(filepath.Join(DirName, annotationsDir), 0o755); err != nil {
237 return fmt.Errorf("creating %s: %w", filepath.Join(DirName, annotationsDir), err)
238 }
239
240 return writeAtomically(root, name, encoded)
241 }
242
243 func EncodeAnnotation(annotation *Annotation) ([]byte, error) {
244 if err := annotation.Validate(); err != nil {
245 return nil, err
246 }
247 var encoded strings.Builder
248 encoded.WriteString(schemaDirective)
249 encoder := yaml.NewEncoder(&encoded)
250 encoder.SetIndent(yamlIndent)
251 if err := encoder.Encode(annotation); err != nil {
252 return nil, fmt.Errorf("encoding annotation %s: %w", annotation.ID, err)
253 }
254 if err := encoder.Close(); err != nil {
255 return nil, fmt.Errorf("encoding annotation %s: %w", annotation.ID, err)
256 }
257 return []byte(encoded.String()), nil
258 }
259
260 func writeAtomically(root *os.Root, name string, content []byte) error {
261 return writeAtomicallyWithMode(root, name, content, 0o644)
262 }
263
264 func writeAtomicallyWithMode(root *os.Root, name string, content []byte, mode fs.FileMode) error {
265 var entropy [8]byte
266 if _, err := rand.Read(entropy[:]); err != nil {
267 return fmt.Errorf("creating temporary name for %s: %w", name, err)
268 }
269 temporaryName := name + "." + hex.EncodeToString(entropy[:])
270 temporary, err := root.OpenFile(temporaryName, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
271 if err != nil {
272 return fmt.Errorf("creating temporary file beside %s: %w", name, err)
273 }
274 defer func() { _ = root.Remove(temporaryName) }()
275
276 if _, err := temporary.Write(content); err != nil {
277 _ = temporary.Close()
278 return fmt.Errorf("writing %s: %w", temporaryName, err)
279 }
280 if err := temporary.Close(); err != nil {
281 return fmt.Errorf("closing %s: %w", temporaryName, err)
282 }
283 if err := root.Rename(temporaryName, name); err != nil {
284 return fmt.Errorf("replacing %s: %w", name, err)
285 }
286 return nil
287 }
288
289 func (s *Store) FindByID(id string) (*Annotation, error) {
290 annotation, err := s.Load(id)
291 if errorsIsNotExist(err) {
292 return nil, fmt.Errorf("no annotation with id %s", id)
293 }
294 return annotation, err
295 }
296
297 func errorsIsNotExist(err error) bool {
298 return err != nil && os.IsNotExist(err)
299 }
300
301 func (s *Store) Remove(id string) (returnedError error) {
302 name, err := recordName(id)
303 if err != nil {
304 return err
305 }
306 root, err := os.OpenRoot(s.root)
307 if err != nil {
308 return fmt.Errorf("opening repository root %s: %w", s.root, err)
309 }
310 defer closeRepositoryRoot(root, &returnedError)
311 if err := root.Remove(name); err != nil {
312 return fmt.Errorf("removing %s: %w", name, err)
313 }
314 return nil
315 }
316
317 func (s *Store) All() (_ []Annotation, returnedError error) {
318 root, err := os.OpenRoot(s.root)
319 if err != nil {
320 return nil, fmt.Errorf("opening repository root %s: %w", s.root, err)
321 }
322 defer closeRepositoryRoot(root, &returnedError)
323 directory := path.Join(DirName, annotationsDir)
324 entries, err := fs.ReadDir(root.FS(), directory)
325 if errorsIsNotExist(err) {
326 return nil, nil
327 }
328 if err != nil {
329 return nil, fmt.Errorf("reading %s: %w", directory, err)
330 }
331
332 annotations := make([]Annotation, 0, len(entries))
333 for _, entry := range entries {
334 if entry.IsDir() {
335 return nil, fmt.Errorf("unexpected directory %s in flat annotation store", path.Join(directory, entry.Name()))
336 }
337 if !strings.HasSuffix(entry.Name(), recordSuffix) {
338 continue
339 }
340 id := strings.TrimSuffix(entry.Name(), recordSuffix)
341 annotation, err := s.Load(id)
342 if err != nil {
343 return nil, err
344 }
345 annotations = append(annotations, *annotation)
346 }
347 return annotations, nil
348 }
349
350 func (s *Store) ForFile(file string) ([]Annotation, error) {
351 clean, err := validSourcePath(file)
352 if err != nil {
353 return nil, err
354 }
355 all, err := s.All()
356 if err != nil {
357 return nil, err
358 }
359 annotations := make([]Annotation, 0)
360 for _, annotation := range all {
361 if annotation.File == clean {
362 annotations = append(annotations, annotation)
363 }
364 }
365 return annotations, nil
366 }
367
368 func (s *Store) AnnotatedFiles() ([]string, error) {
369 annotations, err := s.All()
370 if err != nil {
371 return nil, err
372 }
373 unique := make(map[string]struct{}, len(annotations))
374 for _, annotation := range annotations {
375 unique[annotation.File] = struct{}{}
376 }
377 files := make([]string, 0, len(unique))
378 for file := range unique {
379 files = append(files, file)
380 }
381 sort.Strings(files)
382 return files, nil
383 }
384
385 func isDir(path string) bool {
386 info, err := os.Stat(path)
387 return err == nil && info.IsDir()
388 }
389
390 func exists(path string) bool {
391 _, err := os.Stat(path)
392 return err == nil
393 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close