package changelog import ( "bytes" "fmt" "os" "path/filepath" "strings" "time" ) const header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" // Entry holds the data for one changelog entry. type Entry struct { Version string Date time.Time Description string } // Append prepends a new version entry to the CHANGELOG.md at the given path. // If the file doesn't exist it will be created with a standard header. func Append(path string, entry Entry) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("creating dir for %s: %w", path, err) } existing, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return fmt.Errorf("reading %s: %w", path, err) } section := formatSection(entry) var buf bytes.Buffer if len(existing) == 0 { buf.WriteString(header) buf.WriteString(section) } else { content := string(existing) // Insert after the header block if present, otherwise prepend. insertIdx := findInsertPoint(content) buf.WriteString(content[:insertIdx]) buf.WriteString(section) buf.WriteString(content[insertIdx:]) } return os.WriteFile(path, buf.Bytes(), 0644) } func formatSection(e Entry) string { date := e.Date.Format("2006-01-02") var buf bytes.Buffer fmt.Fprintf(&buf, "## [%s] - %s\n\n", e.Version, date) if strings.TrimSpace(e.Description) != "" { buf.WriteString(strings.TrimSpace(e.Description)) buf.WriteString("\n") } buf.WriteString("\n") return buf.String() } // findInsertPoint returns the index in content where new version sections should be inserted. // It skips over the header (title + unreleased section if any), placing new entries at the top // of the version list. func findInsertPoint(content string) int { lines := strings.Split(content, "\n") for i, line := range lines { // Find first ## [version] line (not ## [Unreleased]) if strings.HasPrefix(line, "## [") && !strings.HasPrefix(line, "## [Unreleased]") { // Return position of this line return positionOfLine(content, i) } } // No existing version sections: append at end of file return len(content) } func positionOfLine(content string, lineNum int) int { pos := 0 for i, ch := range content { if lineNum == 0 { return i } if ch == '\n' { lineNum-- } _ = pos } return len(content) }