1
2
3 package dlog
4
5 import (
6 "bufio"
7 "bytes"
8 "errors"
9 "fmt"
10 "io/fs"
11 "os"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "time"
16
17 "sigs.k8s.io/yaml"
18 )
19
20 const (
21 nameRegexStr = `\d{3,}(-\w+)*\.md`
22 delim = "---"
23 h1 = "#"
24 )
25
26
27 const (
28 StatusProposed = "proposed"
29 StatusRejected = "rejected"
30 StatusAccepted = "accepted"
31 StatusDeprecated = "deprecated"
32 StatusSuperseded = "superseded"
33 )
34
35 var nameRegex = regexp.MustCompile(nameRegexStr)
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50 type Decision struct {
51
52
53
54 Status string `json:"status"`
55
56
57 Date Time `json:"date"`
58
59
60
61 Deciders []string `json:"deciders"`
62
63
64 Tags []string `json:"tags,omitempty"`
65
66
67
68 Consulted []string `json:"consulted,omitempty"`
69
70
71
72 Informed []string `json:"informed,omitempty"`
73
74
75
76 file string
77 number string
78 title string
79 }
80
81
82
83 func (d *Decision) Number() string { return d.number }
84
85
86 func (d *Decision) File() string { return d.file }
87
88
89
90 func (d *Decision) Title() string { return d.title }
91
92
93 type Time struct {
94 time.Time
95 }
96
97
98
99 func (t *Time) UnmarshalJSON(b []byte) error {
100 s := strings.Trim(string(b), "\"")
101 if s == "null" {
102 t.Time = time.Time{}
103 return nil
104 }
105 var err error
106 t.Time, err = time.Parse(time.DateOnly, s)
107 return err
108 }
109
110
111
112 func (t *Time) MarshalJSON() ([]byte, error) {
113 return []byte(`"` + t.Format(time.DateOnly) + `"`), nil
114 }
115
116 func (t Time) String() string {
117 return t.Format(time.DateOnly)
118 }
119
120
121
122 func (d *Decision) IsValid() error {
123 if d.Status == "" {
124 return fmt.Errorf("status is required")
125 }
126
127 if d.Status != StatusProposed && d.Status != StatusRejected &&
128 d.Status != StatusAccepted && d.Status != StatusDeprecated &&
129 !strings.HasPrefix(d.Status, StatusSuperseded) {
130 return fmt.Errorf("invalid value for status: %s, expected one of: "+
131 "'accepted', 'proposed', 'rejected', 'deprecated', 'superseded by [...]'",
132 d.Status)
133 }
134
135 if d.Date.IsZero() {
136 return fmt.Errorf("date is required")
137 }
138
139 if len(d.Deciders) == 0 {
140 return fmt.Errorf("deciders is required")
141 }
142
143 return nil
144 }
145
146
147
148 func IsValidFileName(path string) error {
149 if nameRegex.MatchString(path) {
150 return nil
151 }
152 return fmt.Errorf("%s is an invalid name for a decision log: decision log "+
153 "file names should match the expression '%s'", path, nameRegexStr)
154 }
155
156
157
158 func FromMarkdown(path string) (*Decision, error) {
159 if err := IsValidFileName(path); err != nil {
160 return nil, err
161 }
162
163 return fromMarkdown(path)
164 }
165
166 func fromMarkdown(path string) (*Decision, error) {
167 file, err := os.Open(path)
168 if err != nil {
169 return nil, fmt.Errorf("failed to read %s: %w", path, err)
170 }
171 defer file.Close()
172
173 filename := filepath.Base(path)
174
175 number := strings.Split(filename, "-")[0]
176
177 scanner := bufio.NewScanner(file)
178
179 scanner.Scan()
180 if scanner.Text() != delim {
181 return nil, fmt.Errorf("%s doesn't contain decision log frontmatter", path)
182 }
183
184 data := make([]byte, 0)
185 for scanner.Scan() {
186 text := scanner.Text()
187
188 if text == delim {
189 break
190 }
191
192 data = append(data, []byte(text+"\n")...)
193 }
194 if scanner.Err() != nil {
195 return nil, fmt.Errorf("%s: error reading decision log frontmatter: %w",
196 path, err)
197 }
198
199 d := &Decision{file: filename, number: number}
200 if err := yaml.Unmarshal(data, d); err != nil {
201 return nil, fmt.Errorf("failed to parse decision data from %s: %w", path, err)
202 }
203
204
205 for scanner.Scan() {
206 text := scanner.Text()
207 if title, found := strings.CutPrefix(text, h1); found {
208 title = strings.TrimSpace(title)
209
210 if !strings.HasPrefix(title, fmt.Sprintf("%s: ", d.number)) {
211 return nil, fmt.Errorf("%s: unexpected title %s, titles should be in "+
212 "the format '$NUMBER: $TITLE'", path, title)
213 }
214 d.title = strings.TrimSpace(strings.Split(title, ":")[1])
215 break
216 }
217 }
218
219
220 if err := d.IsValid(); err != nil {
221 return nil, fmt.Errorf("decision data from %s is invalid: %w", path, err)
222 }
223
224 return d, nil
225 }
226
227
228
229 func FromDir(dir string) ([]*Decision, error) {
230 dd := make([]*Decision, 0)
231
232 if err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, _ error) error {
233 switch {
234 case d.IsDir(), IsValidFileName(path) != nil:
235 return nil
236 }
237 dec, err := fromMarkdown(filepath.Join(dir, path))
238 if err != nil {
239 return err
240 }
241 dd = append(dd, dec)
242 return nil
243 }); err != nil {
244 return nil, err
245 }
246
247 return dd, nil
248 }
249
250
251
252 func ToMarkdown(d *Decision, dir string) error {
253 var (
254 f *os.File
255 p = filepath.Join(dir, d.file)
256 )
257
258 _, err := os.Stat(p)
259 switch {
260 case errors.Is(err, os.ErrNotExist):
261 f, err = os.Create(p)
262 if err != nil {
263 return fmt.Errorf("failed to create %s: %w", p, err)
264 }
265 case err != nil:
266 return fmt.Errorf("failed to stat %s: %w", p, err)
267 default:
268 f, err = os.Open(p)
269 if err != nil {
270 return fmt.Errorf("failed to open %s: %w", p, err)
271 }
272 }
273 defer f.Close()
274
275
276
277 data, err := yaml.Marshal(d)
278 if err != nil {
279 return fmt.Errorf("failed to marshal decision log: %w", err)
280 }
281 buf := new(bytes.Buffer)
282 buf.WriteString("---\n")
283 buf.Write(data)
284 buf.WriteString("---\n")
285
286
287
288 scanner := bufio.NewScanner(f)
289
290
291 if !scanner.Scan() && scanner.Err() == nil {
292 if _, err := buf.WriteTo(f); err != nil {
293 return fmt.Errorf("failed to write %s: %w", p, err)
294 }
295 return nil
296 }
297
298
299
300 if scanner.Text() != delim {
301 if _, err := buf.ReadFrom(f); err != nil {
302 return fmt.Errorf("failed to read %s: %w", p, err)
303 }
304 return updateFile(f.Name(), buf)
305 }
306
307
308 slurp := false
309 for scanner.Scan() {
310 text := scanner.Text()
311
312 if text == delim {
313 slurp = true
314 continue
315 }
316 if slurp {
317 buf.WriteString(text)
318 buf.WriteString("\n")
319 }
320 }
321 return updateFile(f.Name(), buf)
322 }
323
324
325 func updateFile(f string, contents *bytes.Buffer) error {
326 file, err := os.Create(f)
327 if err != nil {
328 return fmt.Errorf("failed to reset %s: %w", f, err)
329 }
330
331 if _, err := contents.WriteTo(file); err != nil {
332 return fmt.Errorf("failed to write %s: %w", f, err)
333 }
334 return nil
335 }
336
View as plain text