package changeset import ( "bytes" "fmt" "math/rand" "os" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" "gopkg.in/yaml.v3" ) // BumpType represents a semver bump type. type BumpType string const ( BumpMajor BumpType = "major" BumpMinor BumpType = "minor" BumpPatch BumpType = "patch" BumpPreMajor BumpType = "premajor" BumpPreMinor BumpType = "preminor" BumpPrePatch BumpType = "prepatch" BumpPreRelease BumpType = "prerelease" ) // ValidBumps is the set of valid bump type strings. var ValidBumps = []BumpType{ BumpMajor, BumpMinor, BumpPatch, BumpPreMajor, BumpPreMinor, BumpPrePatch, BumpPreRelease, } // Changeset represents a single .stamp/*.md file. type Changeset struct { // Slug is the filename without extension. Slug string // Bumps maps project name to bump type. Bumps map[string]BumpType // Description is the markdown body of the changeset. Description string } // frontmatterYAML is the shape parsed from YAML frontmatter. type frontmatterYAML struct { Bumps map[string]string `yaml:"bumps"` } // frontmatterTOML is the shape parsed from TOML frontmatter. type frontmatterTOML struct { Bumps map[string]string `toml:"bumps"` } // Parse reads a .stamp/*.md file and returns a Changeset. func Parse(path string) (*Changeset, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading %s: %w", path, err) } return ParseBytes(filepath.Base(strings.TrimSuffix(path, ".md")), data) } // ParseBytes parses changeset content from raw bytes. func ParseBytes(slug string, data []byte) (*Changeset, error) { content := strings.TrimSpace(string(data)) var ( rawBumps map[string]string body string err error ) switch { case strings.HasPrefix(content, "---"): rawBumps, body, err = parseYAMLFrontmatter(content) case strings.HasPrefix(content, "+++"): rawBumps, body, err = parseTOMLFrontmatter(content) default: return nil, fmt.Errorf("changeset %q: missing frontmatter (expected --- or +++)", slug) } if err != nil { return nil, fmt.Errorf("changeset %q: %w", slug, err) } bumps := make(map[string]BumpType, len(rawBumps)) for project, raw := range rawBumps { bt := BumpType(strings.ToLower(raw)) if !isValidBump(bt) { return nil, fmt.Errorf("changeset %q: invalid bump type %q for project %q", slug, raw, project) } bumps[project] = bt } return &Changeset{ Slug: slug, Bumps: bumps, Description: strings.TrimSpace(body), }, nil } func parseYAMLFrontmatter(content string) (map[string]string, string, error) { // Strip opening --- rest := strings.TrimPrefix(content, "---") idx := strings.Index(rest, "\n---") if idx < 0 { return nil, "", fmt.Errorf("unterminated YAML frontmatter") } fm := rest[:idx] body := strings.TrimSpace(rest[idx+4:]) // skip closing ---\n var parsed frontmatterYAML if err := yaml.Unmarshal([]byte(fm), &parsed); err != nil { return nil, "", fmt.Errorf("invalid YAML frontmatter: %w", err) } return parsed.Bumps, body, nil } func parseTOMLFrontmatter(content string) (map[string]string, string, error) { rest := strings.TrimPrefix(content, "+++") idx := strings.Index(rest, "\n+++") if idx < 0 { return nil, "", fmt.Errorf("unterminated TOML frontmatter") } fm := rest[:idx] body := strings.TrimSpace(rest[idx+4:]) var parsed frontmatterTOML if err := toml.Unmarshal([]byte(fm), &parsed); err != nil { return nil, "", fmt.Errorf("invalid TOML frontmatter: %w", err) } return parsed.Bumps, body, nil } // Write creates a new .stamp/.md file in the given directory. func Write(dir, slug string, bumps map[string]BumpType, description string) error { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("creating %s: %w", dir, err) } path := filepath.Join(dir, slug+".md") content, err := Format(bumps, description) if err != nil { return err } return os.WriteFile(path, content, 0644) } // Format renders a changeset to YAML-frontmatter markdown bytes. func Format(bumps map[string]BumpType, description string) ([]byte, error) { var buf bytes.Buffer buf.WriteString("---\nbumps:\n") for project, bump := range bumps { fmt.Fprintf(&buf, " %s: %s\n", project, bump) } buf.WriteString("---\n\n") buf.WriteString(description) buf.WriteString("\n") return buf.Bytes(), nil } // ReadAll reads all .stamp/*.md files from the given directory. func ReadAll(dir string) ([]*Changeset, error) { entries, err := filepath.Glob(filepath.Join(dir, "*.md")) if err != nil { return nil, err } var result []*Changeset for _, entry := range entries { cs, err := Parse(entry) if err != nil { return nil, err } result = append(result, cs) } return result, nil } // DeleteAll removes all .stamp/*.md files from the given directory. func DeleteAll(dir string) error { entries, err := filepath.Glob(filepath.Join(dir, "*.md")) if err != nil { return err } for _, entry := range entries { if err := os.Remove(entry); err != nil { return fmt.Errorf("removing %s: %w", entry, err) } } return nil } // GenerateSlug returns a random adjective-noun slug, e.g. "brave-river". func GenerateSlug() string { r := rand.New(rand.NewSource(time.Now().UnixNano())) adj := adjectives[r.Intn(len(adjectives))] noun := nouns[r.Intn(len(nouns))] return fmt.Sprintf("%s-%s", adj, noun) } func isValidBump(bt BumpType) bool { for _, v := range ValidBumps { if bt == v { return true } } return false } var adjectives = []string{ "brave", "calm", "clean", "dark", "eager", "fast", "fresh", "gentle", "happy", "kind", "light", "loud", "neat", "quiet", "rapid", "sharp", "silent", "smart", "soft", "sweet", "swift", "warm", "wide", "wild", "young", } var nouns = []string{ "bird", "cloud", "dawn", "dew", "dust", "fire", "flame", "flash", "frost", "gale", "hill", "lake", "leaf", "moon", "rain", "river", "rock", "seed", "shadow", "sky", "snow", "star", "storm", "stream", "tide", "tree", "wave", "wind", }