internal/store/record.go
1
// Package store reads and writes the annotation records that live in .koment.
2
package store
4
import (
5
"fmt"
6
"strconv"
7
"strings"
8
"time"
10
yaml "go.yaml.in/yaml/v3"
11
)
13
const RecordVersion = 1
15
const SchemaURL = "https://raw.githubusercontent.com/janpuc/koment/main/schema/annotation.schema.json"
17
type Kind string
19
const (
20
KindWhy Kind = "why"
21
KindGotcha Kind = "gotcha"
22
KindInvariant Kind = "invariant"
23
KindAntiPattern Kind = "anti-pattern"
24
)
26
var Kinds = []Kind{KindWhy, KindGotcha, KindInvariant, KindAntiPattern}
28
func ParseKind(text string) (Kind, error) {
29
for _, kind := range Kinds {
30
if Kind(text) == kind {
31
return kind, nil
32
}
33
}
34
return "", fmt.Errorf("unknown kind %q, want one of %s", text, joinKinds())
35
}
37
func joinKinds() string {
38
names := make([]string, len(Kinds))
39
for index, kind := range Kinds {
40
names[index] = string(kind)
41
}
42
return strings.Join(names, ", ")
43
}
45
type Scope string
47
const (
48
ScopeFile Scope = "file"
49
ScopeExcerpt Scope = "excerpt"
50
)
52
func ParseScope(text string) (Scope, error) {
53
switch Scope(text) {
54
case ScopeFile:
55
return ScopeFile, nil
56
case ScopeExcerpt:
57
return ScopeExcerpt, nil
58
}
59
return "", fmt.Errorf("unknown scope %q, want one of %s, %s", text, ScopeFile, ScopeExcerpt)
60
}
62
type Anchor struct {
63
Scope Scope `yaml:"scope"`
64
Excerpt string `yaml:"excerpt,omitempty"`
65
Before string `yaml:"before,omitempty"`
66
After string `yaml:"after,omitempty"`
67
LastSeenLine int `yaml:"last_seen_line,omitempty"`
68
}
70
func (a Anchor) MarshalYAML() (any, error) {
71
node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
72
appendScalar := func(key, value, tag string, style yaml.Style) {
73
node.Content = append(node.Content,
74
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
75
&yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: value, Style: style},
76
)
77
}
78
appendScalar("scope", string(a.Scope), "!!str", 0)
79
if a.Excerpt != "" {
80
appendScalar("excerpt", a.Excerpt, "!!str", safeStringStyle(a.Excerpt))
81
}
82
if a.Before != "" {
83
appendScalar("before", a.Before, "!!str", safeStringStyle(a.Before))
84
}
85
if a.After != "" {
86
appendScalar("after", a.After, "!!str", safeStringStyle(a.After))
87
}
88
if a.LastSeenLine != 0 {
89
appendScalar("last_seen_line", strconv.Itoa(a.LastSeenLine), "!!int", 0)
90
}
91
return node, nil
92
}
94
func safeStringStyle(value string) yaml.Style {
95
if strings.Contains(value, "\t") {
96
return yaml.DoubleQuotedStyle
97
}
98
if strings.Contains(value, "\n") {
99
return yaml.LiteralStyle
100
}
101
return 0
102
}
104
func (a Anchor) Validate(id string) error {
105
switch a.Scope {
106
case ScopeFile:
107
if a.Excerpt != "" || a.Before != "" || a.After != "" || a.LastSeenLine != 0 {
108
return fmt.Errorf("annotation %s: file anchor must not carry excerpt context or a line", id)
109
}
110
return nil
111
case ScopeExcerpt:
112
if a.Excerpt == "" {
113
return fmt.Errorf("annotation %s: excerpt anchor requires a non-empty excerpt", id)
114
}
115
if a.LastSeenLine < 1 {
116
return fmt.Errorf("annotation %s: last_seen_line %d is not a positive line number", id, a.LastSeenLine)
117
}
118
if err := validateContext("before", a.Before); err != nil {
119
return fmt.Errorf("annotation %s: %w", id, err)
120
}
121
if err := validateContext("after", a.After); err != nil {
122
return fmt.Errorf("annotation %s: %w", id, err)
123
}
124
return nil
125
default:
126
_, err := ParseScope(string(a.Scope))
127
return fmt.Errorf("annotation %s: %w", id, err)
128
}
129
}
131
func validateContext(name, context string) error {
132
if context == "" {
133
return nil
134
}
135
if strings.Count(strings.TrimSuffix(context, "\n"), "\n") >= 3 {
136
return fmt.Errorf("anchor.%s contains more than three lines", name)
137
}
138
return nil
139
}
141
type Policy struct {
142
Exception string `yaml:"exception"`
143
Acknowledged bool `yaml:"acknowledged"`
144
}
146
func (p Policy) Validate(annotation Annotation) error {
147
if p.Exception != "inline-comment" || !p.Acknowledged {
148
return fmt.Errorf("annotation %s: policy must explicitly acknowledge an inline-comment exception", annotation.ID)
149
}
150
if annotation.Kind != KindWhy || annotation.Anchor.Scope != ScopeExcerpt {
151
return fmt.Errorf("annotation %s: inline-comment policy requires a why annotation with an excerpt anchor", annotation.ID)
152
}
153
return nil
154
}
156
type Annotation struct {
157
Version int `yaml:"version"`
158
ID string `yaml:"id"`
159
File string `yaml:"file"`
160
Kind Kind `yaml:"kind"`
161
Body string `yaml:"body"`
162
Created Date `yaml:"created"`
163
Anchor Anchor `yaml:"anchor"`
164
Git *GitContext `yaml:"git,omitempty"`
165
Author Author `yaml:"author"`
166
Policy *Policy `yaml:"policy,omitempty"`
167
}
169
func (a Annotation) Validate() error {
170
if a.Version != RecordVersion {
171
return fmt.Errorf("annotation %s has version %d, want %d", a.ID, a.Version, RecordVersion)
172
}
173
if !ValidID(a.ID) {
174
return fmt.Errorf("annotation id %q is not a canonical ULID", a.ID)
175
}
176
if _, err := validSourcePath(a.File); err != nil {
177
return fmt.Errorf("annotation %s file: %w", a.ID, err)
178
}
179
if _, err := ParseKind(string(a.Kind)); err != nil {
180
return fmt.Errorf("annotation %s: %w", a.ID, err)
181
}
182
if strings.TrimSpace(a.Body) == "" {
183
return fmt.Errorf("annotation %s: empty body", a.ID)
184
}
185
if a.Created.IsZero() {
186
return fmt.Errorf("annotation %s: missing created date", a.ID)
187
}
188
if err := a.Anchor.Validate(a.ID); err != nil {
189
return err
190
}
191
if a.Git != nil {
192
if err := a.Git.Validate(); err != nil {
193
return fmt.Errorf("annotation %s: %w", a.ID, err)
194
}
195
}
196
if err := a.Author.Validate(); err != nil {
197
return fmt.Errorf("annotation %s: %w", a.ID, err)
198
}
199
if a.Policy != nil {
200
if err := a.Policy.Validate(a); err != nil {
201
return err
202
}
203
}
204
return nil
205
}
207
// Date is a calendar date with no time or zone, written as YYYY-MM-DD.
208
type Date struct{ time.Time }
210
const dateLayout = "2006-01-02"
212
func Today() Date { return Date{time.Now().UTC().Truncate(24 * time.Hour)} }
214
func (d Date) MarshalYAML() (any, error) { return d.Format(dateLayout), nil }
216
func (d *Date) UnmarshalYAML(unmarshal func(any) error) error {
217
var text string
218
if err := unmarshal(&text); err != nil {
219
return fmt.Errorf("created must be a %s date: %w", dateLayout, err)
220
}
221
parsed, err := time.Parse(dateLayout, text)
222
if err != nil {
223
return fmt.Errorf("created %q is not a %s date", text, dateLayout)
224
}
225
d.Time = parsed
226
return nil
227
}