sampo_core/
changeset.rs

1use crate::types::Bump;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6/// Information about a changeset file
7#[derive(Debug, Clone)]
8pub struct ChangesetInfo {
9    pub path: PathBuf,
10    pub packages: Vec<String>,
11    pub bump: Bump,
12    pub message: String,
13}
14
15/// Parse a changeset from its markdown content
16pub fn parse_changeset(text: &str, path: &Path) -> Option<ChangesetInfo> {
17    // Expect frontmatter delimited by --- lines, with keys: packages (list), release (string)
18    let mut lines = text.lines();
19    if lines.next()?.trim() != "---" {
20        return None;
21    }
22    let mut packages: Vec<String> = Vec::new();
23    let mut bump: Option<Bump> = None;
24    let mut in_packages = false;
25    for line in &mut lines {
26        let l = line.trim();
27        if l == "---" {
28            break;
29        }
30        if l.starts_with("packages:") {
31            in_packages = true;
32            continue;
33        }
34        if in_packages {
35            // list items like "- name"
36            if let Some(rest) = l.strip_prefix('-') {
37                let name = rest.trim().to_string();
38                if !name.is_empty() {
39                    packages.push(name);
40                }
41                continue;
42            } else if !l.is_empty() {
43                // a non-list line ends the packages block
44                in_packages = false;
45            }
46        }
47        if let Some(v) = l.strip_prefix("release:")
48            && let Some(b) = Bump::parse(v.trim())
49        {
50            bump = Some(b);
51        }
52    }
53
54    // The remainder after the second --- is the message
55    let remainder: String = lines.collect::<Vec<_>>().join("\n");
56    let message = remainder.trim().to_string();
57    if packages.is_empty() || bump.is_none() || message.is_empty() {
58        return None;
59    }
60    Some(ChangesetInfo {
61        path: path.to_path_buf(),
62        packages,
63        bump: bump.unwrap(),
64        message,
65    })
66}
67
68/// Load all changesets from a directory
69pub fn load_changesets(dir: &Path) -> io::Result<Vec<ChangesetInfo>> {
70    if !dir.exists() {
71        return Ok(Vec::new());
72    }
73    let mut out = Vec::new();
74    for entry in fs::read_dir(dir)? {
75        let entry = entry?;
76        let path = entry.path();
77        if !path.is_file() {
78            continue;
79        }
80        if path.extension().and_then(|e| e.to_str()) != Some("md") {
81            continue;
82        }
83        let text = fs::read_to_string(&path)?;
84        if let Some(cs) = parse_changeset(&text, &path) {
85            out.push(cs);
86        }
87    }
88    Ok(out)
89}
90
91/// Render a changeset as markdown with frontmatter
92pub fn render_changeset_markdown(packages: &[String], bump: Bump, message: &str) -> String {
93    use std::fmt::Write as _;
94    let mut out = String::new();
95    out.push_str("---\n");
96    out.push_str("packages:\n");
97    for p in packages {
98        let _ = writeln!(out, "  - {}", p);
99    }
100    let _ = writeln!(out, "release: {}", bump);
101    out.push_str("---\n\n");
102    out.push_str(message);
103    out.push('\n');
104    out
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn parse_valid_changeset() {
113        let text = "---\npackages:\n  - a\n  - b\nrelease: minor\n---\n\nfeat: message\n";
114        let p = Path::new("/tmp/x.md");
115        let cs = parse_changeset(text, p).unwrap();
116        assert_eq!(cs.packages, vec!["a", "b"]);
117        assert_eq!(cs.bump, Bump::Minor);
118        assert_eq!(cs.message, "feat: message");
119    }
120
121    #[test]
122    fn render_changeset_markdown_test() {
123        let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
124        assert!(s.starts_with("---\n"));
125        assert!(s.contains("packages:\n  - a\n  - b\n"));
126        assert!(s.contains("release: minor\n"));
127        assert!(s.contains("---\n\nfeat: x\n"));
128    }
129
130    // Test from sampo/changeset.rs - ensure compatibility
131    #[test]
132    fn render_changeset_markdown_compatibility() {
133        let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
134        assert!(s.starts_with("---\n"));
135        assert!(s.contains("packages:\n  - a\n  - b\n"));
136        assert!(s.contains("release: minor\n"));
137        assert!(s.ends_with("feat: x\n"));
138    }
139
140    #[test]
141    fn parse_major_changeset() {
142        let text = "---\npackages:\n  - mypackage\nrelease: major\n---\n\nBREAKING: API change\n";
143        let p = Path::new("/tmp/major.md");
144        let cs = parse_changeset(text, p).unwrap();
145        assert_eq!(cs.packages, vec!["mypackage"]);
146        assert_eq!(cs.bump, Bump::Major);
147        assert_eq!(cs.message, "BREAKING: API change");
148    }
149
150    #[test]
151    fn parse_empty_returns_none() {
152        let text = "";
153        let p = Path::new("/tmp/empty.md");
154        assert!(parse_changeset(text, p).is_none());
155    }
156
157    #[test]
158    fn load_changesets_empty_dir() {
159        let temp = tempfile::tempdir().unwrap();
160        let changesets = load_changesets(temp.path()).unwrap();
161        assert!(changesets.is_empty());
162    }
163
164    // Additional tests for comprehensive coverage
165    #[test]
166    fn load_changesets_filters_non_md_files() {
167        let temp = tempfile::tempdir().unwrap();
168        let changeset_dir = temp.path().join("changesets");
169        fs::create_dir_all(&changeset_dir).unwrap();
170
171        // Create a non-markdown file
172        fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
173
174        // Create a valid changeset
175        let valid_content = "---\npackages:\n  - test\nrelease: patch\n---\n\nTest changeset\n";
176        fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
177
178        let changesets = load_changesets(&changeset_dir).unwrap();
179        assert_eq!(changesets.len(), 1);
180        assert_eq!(changesets[0].packages, vec!["test"]);
181    }
182
183    #[test]
184    fn parse_changeset_with_invalid_frontmatter() {
185        let text = "packages:\n  - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
186        let p = Path::new("/tmp/invalid.md");
187        assert!(parse_changeset(text, p).is_none());
188    }
189
190    #[test]
191    fn parse_changeset_missing_packages() {
192        let text = "---\nrelease: patch\n---\n\nNo packages defined\n";
193        let p = Path::new("/tmp/no-packages.md");
194        assert!(parse_changeset(text, p).is_none());
195    }
196
197    #[test]
198    fn parse_changeset_missing_release() {
199        let text = "---\npackages:\n  - test\n---\n\nNo release type\n";
200        let p = Path::new("/tmp/no-release.md");
201        assert!(parse_changeset(text, p).is_none());
202    }
203
204    #[test]
205    fn parse_changeset_empty_message() {
206        let text = "---\npackages:\n  - test\nrelease: patch\n---\n\n";
207        let p = Path::new("/tmp/empty-message.md");
208        assert!(parse_changeset(text, p).is_none());
209    }
210}