sampo_core/
changeset.rs

1use crate::errors::{Result, SampoError};
2use crate::types::Bump;
3use changesets::Change;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7/// Information about a changeset file
8#[derive(Debug, Clone)]
9pub struct ChangesetInfo {
10    pub path: PathBuf,
11    /// (package, bump) pairs parsed from frontmatter
12    pub entries: Vec<(String, Bump)>,
13    pub message: String,
14}
15
16/// Parse a changeset from its markdown content.
17/// Uses Knope's `changesets` crate to parse the frontmatter.
18///
19/// # Example
20/// ```rust,ignore
21/// let text = "---\nmy-package: minor\n---\n\nfeat: new feature\n";
22/// let info = parse_changeset(text, &Path::new("test.md")).unwrap();
23/// assert_eq!(info.entries, vec![("my-package".into(), Bump::Minor)]);
24/// ```
25pub fn parse_changeset(text: &str, path: &Path) -> Result<Option<ChangesetInfo>> {
26    let file_name = path
27        .file_name()
28        .ok_or_else(|| SampoError::Changeset("Invalid file path".to_string()))?
29        .to_string_lossy()
30        .to_string();
31
32    let change = Change::from_file_name_and_content(&file_name, text)
33        .map_err(|err| SampoError::Changeset(format!("Failed to parse changeset: {}", err)))?;
34
35    // Convert Change.versioning -> Vec<(String, Bump)>, rejecting non-semver change types.
36    let mut entries: Vec<(String, Bump)> = Vec::new();
37    for (package_name, change_type) in change.versioning.iter() {
38        let bump = change_type.clone().try_into()
39            .map_err(|_| SampoError::Changeset(format!(
40                "Unsupported change type '{:?}' for package '{}'. Only 'patch', 'minor', and 'major' are supported.",
41                change_type, package_name
42            )))?;
43        entries.push((package_name.clone(), bump));
44    }
45    if entries.is_empty() {
46        return Ok(None);
47    }
48
49    let message = change.summary.trim().to_string();
50    if message.is_empty() {
51        return Ok(None);
52    }
53
54    Ok(Some(ChangesetInfo {
55        path: path.to_path_buf(),
56        entries,
57        message,
58    }))
59}
60
61/// Load all changesets from a directory
62pub fn load_changesets(dir: &Path) -> Result<Vec<ChangesetInfo>> {
63    if !dir.exists() {
64        return Ok(Vec::new());
65    }
66    let mut out = Vec::new();
67    for entry in fs::read_dir(dir)? {
68        let entry = entry?;
69        let path = entry.path();
70        if !path.is_file() {
71            continue;
72        }
73        if path.extension().and_then(|e| e.to_str()) != Some("md") {
74            continue;
75        }
76        let text =
77            fs::read_to_string(&path).map_err(|e| crate::errors::io_error_with_path(e, &path))?;
78        if let Some(changeset) = parse_changeset(&text, &path)? {
79            out.push(changeset);
80        }
81    }
82    Ok(out)
83}
84
85/// Render a changeset as markdown with YAML mapping frontmatter
86pub fn render_changeset_markdown(packages: &[String], bump: Bump, message: &str) -> String {
87    use std::fmt::Write as _;
88    let mut out = String::new();
89    out.push_str("---\n");
90    for package in packages {
91        let _ = writeln!(out, "{}: {}", package, bump);
92    }
93    out.push_str("---\n\n");
94    out.push_str(message);
95    out.push('\n');
96    out
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn parse_valid_changeset() {
105        let text = "---\na: minor\nb: minor\n---\n\nfeat: message\n";
106        let path = Path::new("/tmp/x.md");
107        let changeset = parse_changeset(text, path).unwrap().unwrap();
108        let mut entries = changeset.entries.clone();
109        entries.sort_by(|left, right| left.0.cmp(&right.0));
110        assert_eq!(
111            entries,
112            vec![("a".into(), Bump::Minor), ("b".into(), Bump::Minor)]
113        );
114        assert_eq!(changeset.message, "feat: message");
115    }
116
117    #[test]
118    fn render_changeset_markdown_test() {
119        let markdown = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
120        assert!(markdown.starts_with("---\n"));
121        assert!(markdown.contains("a: minor\n"));
122        assert!(markdown.contains("b: minor\n"));
123        assert!(markdown.contains("---\n\nfeat: x\n"));
124    }
125
126    // Test from sampo/changeset.rs - ensure compatibility
127    #[test]
128    fn render_changeset_markdown_compatibility() {
129        let markdown = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
130        assert!(markdown.starts_with("---\n"));
131        assert!(markdown.contains("a: minor\n"));
132        assert!(markdown.contains("b: minor\n"));
133        assert!(markdown.ends_with("feat: x\n"));
134    }
135
136    #[test]
137    fn parse_major_changeset() {
138        let text = "---\nmypackage: major\n---\n\nBREAKING: API change\n";
139        let path = Path::new("/tmp/major.md");
140        let changeset = parse_changeset(text, path).unwrap().unwrap();
141        assert_eq!(changeset.entries, vec![("mypackage".into(), Bump::Major)]);
142        assert_eq!(changeset.message, "BREAKING: API change");
143    }
144
145    #[test]
146    fn parse_empty_returns_error() {
147        let text = "";
148        let path = Path::new("/tmp/empty.md");
149        assert!(parse_changeset(text, path).is_err());
150    }
151
152    #[test]
153    fn load_changesets_empty_dir() {
154        let temp = tempfile::tempdir().unwrap();
155        let changesets = load_changesets(temp.path()).unwrap();
156        assert!(changesets.is_empty());
157    }
158
159    // Additional tests for comprehensive coverage
160    #[test]
161    fn load_changesets_filters_non_md_files() {
162        let temp = tempfile::tempdir().unwrap();
163        let changeset_dir = temp.path().join("changesets");
164        fs::create_dir_all(&changeset_dir).unwrap();
165
166        // Create a non-markdown file
167        fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
168
169        // Create a valid changeset
170        let valid_content = "---\ntest: patch\n---\n\nTest changeset\n";
171        fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
172
173        let changesets = load_changesets(&changeset_dir).unwrap();
174        assert_eq!(changesets.len(), 1);
175        assert_eq!(changesets[0].entries, vec![("test".into(), Bump::Patch)]);
176    }
177
178    #[test]
179    fn parse_changeset_with_invalid_frontmatter() {
180        let text = "packages:\n  - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
181        let path = Path::new("/tmp/invalid.md");
182        assert!(parse_changeset(text, path).is_err());
183    }
184
185    #[test]
186    fn parse_changeset_missing_packages() {
187        let text = "---\n---\n\nNo packages defined\n";
188        let path = Path::new("/tmp/no-packages.md");
189        assert!(parse_changeset(text, path).is_err());
190    }
191
192    #[test]
193    fn parse_changeset_missing_release() {
194        // Non-semver change type should be rejected by our wrapper
195        let text = "---\n\"test\": none\n---\n\nNo release type\n";
196        let path = Path::new("/tmp/no-release.md");
197        assert!(parse_changeset(text, path).is_err());
198    }
199
200    #[test]
201    fn parse_changeset_empty_message() {
202        let text = "---\ntest: patch\n---\n\n";
203        let path = Path::new("/tmp/empty-message.md");
204        assert!(parse_changeset(text, path).unwrap().is_none());
205    }
206
207    #[test]
208    fn try_from_change_type_to_bump() {
209        use changesets::ChangeType;
210
211        // Test successful conversions
212        assert_eq!(Bump::try_from(ChangeType::Patch), Ok(Bump::Patch));
213        assert_eq!(Bump::try_from(ChangeType::Minor), Ok(Bump::Minor));
214        assert_eq!(Bump::try_from(ChangeType::Major), Ok(Bump::Major));
215
216        // Test rejection of custom types
217        assert!(Bump::try_from(ChangeType::Custom("custom".to_string())).is_err());
218    }
219}