sampo_core/
changeset.rs

1use crate::errors::{Result, SampoError};
2use crate::types::{Bump, PackageSpecifier};
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<(PackageSpecifier, 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[0].0.to_canonical_string(), "my-package");
24/// assert_eq!(info.entries[0].1, Bump::Minor);
25/// ```
26pub fn parse_changeset(text: &str, path: &Path) -> Result<Option<ChangesetInfo>> {
27    let file_name = path
28        .file_name()
29        .ok_or_else(|| SampoError::Changeset("Invalid file path".to_string()))?
30        .to_string_lossy()
31        .to_string();
32
33    let change = Change::from_file_name_and_content(&file_name, text)
34        .map_err(|err| SampoError::Changeset(format!("Failed to parse changeset: {}", err)))?;
35
36    // Convert Change.versioning -> Vec<(String, Bump)>, rejecting non-semver change types.
37    let mut entries: Vec<(PackageSpecifier, Bump)> = Vec::new();
38    for (package_name, change_type) in change.versioning.iter() {
39        let bump = change_type.clone().try_into()
40            .map_err(|_| SampoError::Changeset(format!(
41                "Unsupported change type '{:?}' for package '{}'. Only 'patch', 'minor', and 'major' are supported.",
42                change_type, package_name
43            )))?;
44        let spec = PackageSpecifier::parse(package_name).map_err(|reason| {
45            SampoError::Changeset(format!(
46                "Invalid package reference '{}': {reason}",
47                package_name
48            ))
49        })?;
50        entries.push((spec, bump));
51    }
52    if entries.is_empty() {
53        return Ok(None);
54    }
55
56    let message = change.summary.trim().to_string();
57    if message.is_empty() {
58        return Ok(None);
59    }
60
61    Ok(Some(ChangesetInfo {
62        path: path.to_path_buf(),
63        entries,
64        message,
65    }))
66}
67
68/// Load all changesets from a directory
69pub fn load_changesets(dir: &Path) -> 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 =
84            fs::read_to_string(&path).map_err(|e| crate::errors::io_error_with_path(e, &path))?;
85        if let Some(changeset) = parse_changeset(&text, &path)? {
86            out.push(changeset);
87        }
88    }
89    Ok(out)
90}
91
92/// Render a changeset as markdown with YAML mapping frontmatter
93pub fn render_changeset_markdown(entries: &[(PackageSpecifier, Bump)], message: &str) -> String {
94    use std::fmt::Write as _;
95    let mut out = String::new();
96    out.push_str("---\n");
97    for (package, bump) in entries {
98        let canonical = package.to_canonical_string();
99        let _ = writeln!(out, "{}: {}", canonical, bump);
100    }
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 = "---\na: minor\nb: minor\n---\n\nfeat: message\n";
114        let path = Path::new("/tmp/x.md");
115        let changeset = parse_changeset(text, path).unwrap().unwrap();
116        let mut entries = changeset.entries.clone();
117        entries.sort_by(|left, right| {
118            left.0
119                .to_canonical_string()
120                .cmp(&right.0.to_canonical_string())
121        });
122        let collected: Vec<(String, Bump)> = entries
123            .into_iter()
124            .map(|(spec, bump)| (spec.to_canonical_string(), bump))
125            .collect();
126        assert_eq!(
127            collected,
128            vec![("a".into(), Bump::Minor), ("b".into(), Bump::Minor)]
129        );
130        assert_eq!(changeset.message, "feat: message");
131    }
132
133    #[test]
134    fn parse_changeset_strips_wrapping_quotes() {
135        let text = "---\n\"sampo-core\": minor\n'sampo-cli': patch\n---\n\nfeat: message\n";
136        let path = Path::new("/tmp/quotes.md");
137        let changeset = parse_changeset(text, path).unwrap().unwrap();
138        let mut entries = changeset.entries;
139        entries.sort_by(|left, right| {
140            left.0
141                .to_canonical_string()
142                .cmp(&right.0.to_canonical_string())
143        });
144        let collected: Vec<(String, Bump)> = entries
145            .into_iter()
146            .map(|(spec, bump)| (spec.to_canonical_string(), bump))
147            .collect();
148        assert_eq!(
149            collected,
150            vec![
151                ("sampo-cli".into(), Bump::Patch),
152                ("sampo-core".into(), Bump::Minor)
153            ]
154        );
155    }
156
157    #[test]
158    fn parse_changeset_accepts_canonical_identifiers_with_slash() {
159        let text = "---\ncargo/example: minor\n---\n\nfeat: canonical id\n";
160        let path = Path::new("/tmp/canonical-slash.md");
161        let changeset = parse_changeset(text, path).unwrap().unwrap();
162        let collected: Vec<(String, Bump)> = changeset
163            .entries
164            .iter()
165            .map(|(spec, bump)| (spec.to_canonical_string(), *bump))
166            .collect();
167        assert_eq!(collected, vec![("cargo/example".into(), Bump::Minor)]);
168    }
169
170    #[test]
171    fn render_changeset_markdown_test() {
172        let markdown = render_changeset_markdown(
173            &[
174                (PackageSpecifier::parse("a").unwrap(), Bump::Minor),
175                (PackageSpecifier::parse("b").unwrap(), Bump::Minor),
176            ],
177            "feat: x",
178        );
179        assert!(markdown.starts_with("---\n"));
180        assert!(markdown.contains("a: minor\n"));
181        assert!(markdown.contains("b: minor\n"));
182        assert!(markdown.contains("---\n\nfeat: x\n"));
183    }
184
185    #[test]
186    fn render_changeset_markdown_with_canonical_identifier() {
187        let markdown = render_changeset_markdown(
188            &[(
189                PackageSpecifier::parse("cargo/example").unwrap(),
190                Bump::Minor,
191            )],
192            "feat: canonical",
193        );
194        assert!(markdown.contains("cargo/example: minor\n"));
195    }
196
197    // Test from sampo/changeset.rs - ensure compatibility
198    #[test]
199    fn render_changeset_markdown_compatibility() {
200        let markdown = render_changeset_markdown(
201            &[
202                (PackageSpecifier::parse("a").unwrap(), Bump::Minor),
203                (PackageSpecifier::parse("b").unwrap(), Bump::Minor),
204            ],
205            "feat: x",
206        );
207        assert!(markdown.starts_with("---\n"));
208        assert!(markdown.contains("a: minor\n"));
209        assert!(markdown.contains("b: minor\n"));
210        assert!(markdown.ends_with("feat: x\n"));
211    }
212
213    #[test]
214    fn render_changeset_markdown_strips_quoted_names() {
215        let markdown = render_changeset_markdown(
216            &[(
217                PackageSpecifier::parse("\"sampo-core\"").unwrap(),
218                Bump::Minor,
219            )],
220            "feat: sanitized",
221        );
222        assert!(markdown.contains("sampo-core: minor\n"));
223        assert!(!markdown.contains("\"sampo-core\""));
224    }
225
226    #[test]
227    fn parse_major_changeset() {
228        let text = "---\nmypackage: major\n---\n\nBREAKING: API change\n";
229        let path = Path::new("/tmp/major.md");
230        let changeset = parse_changeset(text, path).unwrap().unwrap();
231        let collected: Vec<(String, Bump)> = changeset
232            .entries
233            .iter()
234            .map(|(spec, bump)| (spec.to_canonical_string(), *bump))
235            .collect();
236        assert_eq!(collected, vec![("mypackage".into(), Bump::Major)]);
237        assert_eq!(changeset.message, "BREAKING: API change");
238    }
239
240    #[test]
241    fn parse_empty_returns_error() {
242        let text = "";
243        let path = Path::new("/tmp/empty.md");
244        assert!(parse_changeset(text, path).is_err());
245    }
246
247    #[test]
248    fn load_changesets_empty_dir() {
249        let temp = tempfile::tempdir().unwrap();
250        let changesets = load_changesets(temp.path()).unwrap();
251        assert!(changesets.is_empty());
252    }
253
254    // Additional tests for comprehensive coverage
255    #[test]
256    fn load_changesets_filters_non_md_files() {
257        let temp = tempfile::tempdir().unwrap();
258        let changeset_dir = temp.path().join("changesets");
259        fs::create_dir_all(&changeset_dir).unwrap();
260
261        // Create a non-markdown file
262        fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
263
264        // Create a valid changeset
265        let valid_content = "---\ntest: patch\n---\n\nTest changeset\n";
266        fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
267
268        let changesets = load_changesets(&changeset_dir).unwrap();
269        assert_eq!(changesets.len(), 1);
270        let entry = &changesets[0].entries[0];
271        assert_eq!(entry.0.to_canonical_string(), "test");
272        assert_eq!(entry.1, Bump::Patch);
273    }
274
275    #[test]
276    fn parse_changeset_with_invalid_frontmatter() {
277        let text = "packages:\n  - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
278        let path = Path::new("/tmp/invalid.md");
279        assert!(parse_changeset(text, path).is_err());
280    }
281
282    #[test]
283    fn parse_changeset_missing_packages() {
284        let text = "---\n---\n\nNo packages defined\n";
285        let path = Path::new("/tmp/no-packages.md");
286        assert!(parse_changeset(text, path).is_err());
287    }
288
289    #[test]
290    fn parse_changeset_missing_release() {
291        // Non-semver change type should be rejected by our wrapper
292        let text = "---\n\"test\": none\n---\n\nNo release type\n";
293        let path = Path::new("/tmp/no-release.md");
294        assert!(parse_changeset(text, path).is_err());
295    }
296
297    #[test]
298    fn parse_changeset_empty_message() {
299        let text = "---\ntest: patch\n---\n\n";
300        let path = Path::new("/tmp/empty-message.md");
301        assert!(parse_changeset(text, path).unwrap().is_none());
302    }
303
304    #[test]
305    fn try_from_change_type_to_bump() {
306        use changesets::ChangeType;
307
308        // Test successful conversions
309        assert_eq!(Bump::try_from(ChangeType::Patch), Ok(Bump::Patch));
310        assert_eq!(Bump::try_from(ChangeType::Minor), Ok(Bump::Minor));
311        assert_eq!(Bump::try_from(ChangeType::Major), Ok(Bump::Major));
312
313        // Test rejection of custom types
314        assert!(Bump::try_from(ChangeType::Custom("custom".to_string())).is_err());
315    }
316}