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/// Detect the changesets directory, respecting custom configuration
92pub fn detect_changesets_dir(workspace: &Path) -> PathBuf {
93    let base = workspace.join(".sampo");
94    let cfg_path = base.join("config.toml");
95    if cfg_path.exists()
96        && let Ok(text) = std::fs::read_to_string(&cfg_path)
97        && let Ok(value) = text.parse::<toml::Value>()
98        && let Some(dir) = value
99            .get("changesets")
100            .and_then(|v| v.as_table())
101            .and_then(|t| t.get("dir"))
102            .and_then(|v| v.as_str())
103    {
104        return base.join(dir);
105    }
106    base.join("changesets")
107}
108
109/// Render a changeset as markdown with frontmatter
110pub fn render_changeset_markdown(packages: &[String], bump: Bump, message: &str) -> String {
111    use std::fmt::Write as _;
112    let mut out = String::new();
113    out.push_str("---\n");
114    out.push_str("packages:\n");
115    for p in packages {
116        let _ = writeln!(out, "  - {}", p);
117    }
118    let _ = writeln!(out, "release: {}", bump);
119    out.push_str("---\n\n");
120    out.push_str(message);
121    out.push('\n');
122    out
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn parse_valid_changeset() {
131        let text = "---\npackages:\n  - a\n  - b\nrelease: minor\n---\n\nfeat: message\n";
132        let p = Path::new("/tmp/x.md");
133        let cs = parse_changeset(text, p).unwrap();
134        assert_eq!(cs.packages, vec!["a", "b"]);
135        assert_eq!(cs.bump, Bump::Minor);
136        assert_eq!(cs.message, "feat: message");
137    }
138
139    #[test]
140    fn render_changeset_markdown_test() {
141        let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
142        assert!(s.starts_with("---\n"));
143        assert!(s.contains("packages:\n  - a\n  - b\n"));
144        assert!(s.contains("release: minor\n"));
145        assert!(s.contains("---\n\nfeat: x\n"));
146    }
147
148    // Test from sampo/changeset.rs - ensure compatibility
149    #[test]
150    fn render_changeset_markdown_compatibility() {
151        let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
152        assert!(s.starts_with("---\n"));
153        assert!(s.contains("packages:\n  - a\n  - b\n"));
154        assert!(s.contains("release: minor\n"));
155        assert!(s.ends_with("feat: x\n"));
156    }
157
158    #[test]
159    fn parse_major_changeset() {
160        let text = "---\npackages:\n  - mypackage\nrelease: major\n---\n\nBREAKING: API change\n";
161        let p = Path::new("/tmp/major.md");
162        let cs = parse_changeset(text, p).unwrap();
163        assert_eq!(cs.packages, vec!["mypackage"]);
164        assert_eq!(cs.bump, Bump::Major);
165        assert_eq!(cs.message, "BREAKING: API change");
166    }
167
168    #[test]
169    fn parse_empty_returns_none() {
170        let text = "";
171        let p = Path::new("/tmp/empty.md");
172        assert!(parse_changeset(text, p).is_none());
173    }
174
175    #[test]
176    fn load_changesets_empty_dir() {
177        let temp = tempfile::tempdir().unwrap();
178        let changesets = load_changesets(temp.path()).unwrap();
179        assert!(changesets.is_empty());
180    }
181
182    #[test]
183    fn detect_changesets_dir_defaults() {
184        let temp = tempfile::tempdir().unwrap();
185        let dir = detect_changesets_dir(temp.path());
186        assert_eq!(dir, temp.path().join(".sampo/changesets"));
187    }
188
189    #[test]
190    fn detect_changesets_dir_custom() {
191        let temp = tempfile::tempdir().unwrap();
192        let sampo_dir = temp.path().join(".sampo");
193        fs::create_dir_all(&sampo_dir).unwrap();
194        fs::write(
195            sampo_dir.join("config.toml"),
196            "[changesets]\ndir = \"custom-changesets\"\n",
197        )
198        .unwrap();
199
200        let dir = detect_changesets_dir(temp.path());
201        assert_eq!(dir, temp.path().join(".sampo/custom-changesets"));
202    }
203
204    // Additional tests for comprehensive coverage
205    #[test]
206    fn load_changesets_filters_non_md_files() {
207        let temp = tempfile::tempdir().unwrap();
208        let changeset_dir = temp.path().join("changesets");
209        fs::create_dir_all(&changeset_dir).unwrap();
210
211        // Create a non-markdown file
212        fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
213
214        // Create a valid changeset
215        let valid_content = "---\npackages:\n  - test\nrelease: patch\n---\n\nTest changeset\n";
216        fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
217
218        let changesets = load_changesets(&changeset_dir).unwrap();
219        assert_eq!(changesets.len(), 1);
220        assert_eq!(changesets[0].packages, vec!["test"]);
221    }
222
223    #[test]
224    fn parse_changeset_with_invalid_frontmatter() {
225        let text = "packages:\n  - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
226        let p = Path::new("/tmp/invalid.md");
227        assert!(parse_changeset(text, p).is_none());
228    }
229
230    #[test]
231    fn parse_changeset_missing_packages() {
232        let text = "---\nrelease: patch\n---\n\nNo packages defined\n";
233        let p = Path::new("/tmp/no-packages.md");
234        assert!(parse_changeset(text, p).is_none());
235    }
236
237    #[test]
238    fn parse_changeset_missing_release() {
239        let text = "---\npackages:\n  - test\n---\n\nNo release type\n";
240        let p = Path::new("/tmp/no-release.md");
241        assert!(parse_changeset(text, p).is_none());
242    }
243
244    #[test]
245    fn parse_changeset_empty_message() {
246        let text = "---\npackages:\n  - test\nrelease: patch\n---\n\n";
247        let p = Path::new("/tmp/empty-message.md");
248        assert!(parse_changeset(text, p).is_none());
249    }
250}