// Package dlog implements functionality for working with structured decision // logs in the spirit of MADR (https://adr.github.io/madr). package dlog import ( "bufio" "bytes" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "strings" "time" "sigs.k8s.io/yaml" ) const ( nameRegexStr = `\d{3,}(-\w+)*\.md` delim = "---" h1 = "#" ) // Decision log status phases. const ( StatusProposed = "proposed" StatusRejected = "rejected" StatusAccepted = "accepted" StatusDeprecated = "deprecated" StatusSuperseded = "superseded" ) var nameRegex = regexp.MustCompile(nameRegexStr) // Decision represents the structured data associated with a decision log, // typically stored in the frontmatter of the markdown file containing the // decision log, e.g.: // // # 0021-my-teams-decision.md // --- // status: proposed // date: '10-28-30' // [...] // --- // // # $DECISION_LOG_TITLE // [...] type Decision struct { // Status communicates the phase of the decision's lifecycle. It must be one // of: "proposed", "rejected", "accepted", "deprecated", // "superseded by [link to decision]" Status string `json:"status"` // Date is the last time this decision was updated, in the format YYYY-MM-DD Date Time `json:"date"` // Deciders is a required list of individuals or teams that were involved in // the final outcome for the decision. Deciders []string `json:"deciders"` // Tags are optional metadata used for categorizing and filtering decisions. Tags []string `json:"tags,omitempty"` // Consulted is an optional list of individuals or teams that were consulted // by the deciders. Consulted []string `json:"consulted,omitempty"` // Informed is an optional list of individuals or teams that were kept // up-to-date on the decision. Informed []string `json:"informed,omitempty"` // Information populated on load, exposed via methods to avoid being // serialized. file string number string title string } // Number is the unique identifier for the decision log, populated on load, // e.g.: 0003-my-decision.md would have the number '0003' func (d *Decision) Number() string { return d.number } // File is the name of the file containing the decision log, populated on load. func (d *Decision) File() string { return d.file } // Title returns the decision log doc title, the first '#' header, e.g.: // '# 0003: My Decision' would have the title 'My Decision'. func (d *Decision) Title() string { return d.title } // Time wraps [time.Time] so that we can unmarshal YYYY-MM-DD format dates. type Time struct { time.Time } // UnmarshalJSON implements [json.Unmarshaller] so that we can control specifics // of the time format. func (t *Time) UnmarshalJSON(b []byte) error { s := strings.Trim(string(b), "\"") if s == "null" { t.Time = time.Time{} return nil } var err error t.Time, err = time.Parse(time.DateOnly, s) return err } // MarshalJSON implements [json.Marshaller] so that we can control formatting // when we write Decision data to file. func (t *Time) MarshalJSON() ([]byte, error) { return []byte(`"` + t.Format(time.DateOnly) + `"`), nil } func (t Time) String() string { return t.Format(time.DateOnly) } // IsValid returns an error if the decision is missing required fields or has // invalid values for any fields. func (d *Decision) IsValid() error { if d.Status == "" { return fmt.Errorf("status is required") } if d.Status != StatusProposed && d.Status != StatusRejected && d.Status != StatusAccepted && d.Status != StatusDeprecated && !strings.HasPrefix(d.Status, StatusSuperseded) { return fmt.Errorf("invalid value for status: %s, expected one of: "+ "'accepted', 'proposed', 'rejected', 'deprecated', 'superseded by [...]'", d.Status) } if d.Date.IsZero() { return fmt.Errorf("date is required") } if len(d.Deciders) == 0 { return fmt.Errorf("deciders is required") } return nil } // IsValidFileName returns an error if the decision log path is not in the // expected format. func IsValidFileName(path string) error { if nameRegex.MatchString(path) { return nil } return fmt.Errorf("%s is an invalid name for a decision log: decision log "+ "file names should match the expression '%s'", path, nameRegexStr) } // FromMarkdown parses decision data from frontmatter embedded in a markdown // file. func FromMarkdown(path string) (*Decision, error) { if err := IsValidFileName(path); err != nil { return nil, err } return fromMarkdown(path) } func fromMarkdown(path string) (*Decision, error) { file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to read %s: %w", path, err) } defer file.Close() filename := filepath.Base(path) // NOTE: We know this is safe because we have validated the path ahead of time number := strings.Split(filename, "-")[0] scanner := bufio.NewScanner(file) // Frontmatter should come first, so check the first line before iterating scanner.Scan() if scanner.Text() != delim { return nil, fmt.Errorf("%s doesn't contain decision log frontmatter", path) } // Read frontmatter from file data := make([]byte, 0) for scanner.Scan() { text := scanner.Text() // We have reached the ending delimiter if text == delim { break } // We are slurpin' til we hit the end data = append(data, []byte(text+"\n")...) } if scanner.Err() != nil { return nil, fmt.Errorf("%s: error reading decision log frontmatter: %w", path, err) } d := &Decision{file: filename, number: number} if err := yaml.Unmarshal(data, d); err != nil { return nil, fmt.Errorf("failed to parse decision data from %s: %w", path, err) } // Read title from first H1 in the document for scanner.Scan() { text := scanner.Text() if title, found := strings.CutPrefix(text, h1); found { title = strings.TrimSpace(title) // Dlogs are expected to be titled `# $NUMBER: $TITLE` if !strings.HasPrefix(title, fmt.Sprintf("%s: ", d.number)) { return nil, fmt.Errorf("%s: unexpected title %s, titles should be in "+ "the format '$NUMBER: $TITLE'", path, title) } d.title = strings.TrimSpace(strings.Split(title, ":")[1]) break } } // Validate that loaded decision is well-formed if err := d.IsValid(); err != nil { return nil, fmt.Errorf("decision data from %s is invalid: %w", path, err) } return d, nil } // FromDir loads all decision logs under path recursively. Any files that don't // have a valid name or contain decision log frontmatter are ignored. func FromDir(dir string) ([]*Decision, error) { dd := make([]*Decision, 0) if err := fs.WalkDir(os.DirFS(dir), ".", func(path string, d fs.DirEntry, _ error) error { switch { case d.IsDir(), IsValidFileName(path) != nil: return nil } dec, err := fromMarkdown(filepath.Join(dir, path)) if err != nil { return err } dd = append(dd, dec) return nil }); err != nil { return nil, err } return dd, nil } // ToMarkdown writes new frontmatter to a markdown file for d, creating the // file if it doesn't exist. func ToMarkdown(d *Decision, dir string) error { var ( f *os.File p = filepath.Join(dir, d.file) ) _, err := os.Stat(p) switch { case errors.Is(err, os.ErrNotExist): f, err = os.Create(p) if err != nil { return fmt.Errorf("failed to create %s: %w", p, err) } case err != nil: return fmt.Errorf("failed to stat %s: %w", p, err) default: f, err = os.Open(p) if err != nil { return fmt.Errorf("failed to open %s: %w", p, err) } } defer f.Close() // Generate new frontmatter, write it to top of buffer that will become the // updated file. data, err := yaml.Marshal(d) if err != nil { return fmt.Errorf("failed to marshal decision log: %w", err) } buf := new(bytes.Buffer) buf.WriteString("---\n") buf.Write(data) buf.WriteString("---\n") // Scan current file, dropping any frontmatter and then writing the rest of // the file to new buffer scanner := bufio.NewScanner(f) // If first call to Scan returns false but there is no error, we just created // this file, write buffer and exit early if !scanner.Scan() && scanner.Err() == nil { if _, err := buf.WriteTo(f); err != nil { return fmt.Errorf("failed to write %s: %w", p, err) } return nil } // File didn't have any existing frontmatter, we can jump into writing // all contents to our buffer and updating the file if scanner.Text() != delim { if _, err := buf.ReadFrom(f); err != nil { return fmt.Errorf("failed to read %s: %w", p, err) } return updateFile(f.Name(), buf) } // Replace existing frontmatter slurp := false for scanner.Scan() { text := scanner.Text() // When we hit second delimiter, its soup time if text == delim { slurp = true continue } if slurp { buf.WriteString(text) buf.WriteString("\n") } } return updateFile(f.Name(), buf) } // updateFile resets an existing file and writes contents to it func updateFile(f string, contents *bytes.Buffer) error { file, err := os.Create(f) if err != nil { return fmt.Errorf("failed to reset %s: %w", f, err) } // Overwrite it if _, err := contents.WriteTo(file); err != nil { return fmt.Errorf("failed to write %s: %w", f, err) } return nil }