sampo_core/
changeset.rs

1use crate::errors::{Result, SampoError};
2use crate::types::Bump;
3use changesets::Change;
4use std::borrow::Cow;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Information about a changeset file
9#[derive(Debug, Clone)]
10pub struct ChangesetInfo {
11    pub path: PathBuf,
12    /// (package, bump) pairs parsed from frontmatter
13    pub entries: Vec<(String, Bump)>,
14    pub message: String,
15}
16
17/// Parse a changeset from its markdown content.
18/// Uses Knope's `changesets` crate to parse the frontmatter.
19///
20/// # Example
21/// ```rust,ignore
22/// let text = "---\nmy-package: minor\n---\n\nfeat: new feature\n";
23/// let info = parse_changeset(text, &Path::new("test.md")).unwrap();
24/// assert_eq!(info.entries, vec![("my-package".into(), 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<(String, 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 normalized = normalize_package_name(package_name);
45        entries.push((normalized.into_owned(), bump));
46    }
47    if entries.is_empty() {
48        return Ok(None);
49    }
50
51    let message = change.summary.trim().to_string();
52    if message.is_empty() {
53        return Ok(None);
54    }
55
56    Ok(Some(ChangesetInfo {
57        path: path.to_path_buf(),
58        entries,
59        message,
60    }))
61}
62
63/// Load all changesets from a directory
64pub fn load_changesets(dir: &Path) -> Result<Vec<ChangesetInfo>> {
65    if !dir.exists() {
66        return Ok(Vec::new());
67    }
68    let mut out = Vec::new();
69    for entry in fs::read_dir(dir)? {
70        let entry = entry?;
71        let path = entry.path();
72        if !path.is_file() {
73            continue;
74        }
75        if path.extension().and_then(|e| e.to_str()) != Some("md") {
76            continue;
77        }
78        let text =
79            fs::read_to_string(&path).map_err(|e| crate::errors::io_error_with_path(e, &path))?;
80        if let Some(changeset) = parse_changeset(&text, &path)? {
81            out.push(changeset);
82        }
83    }
84    Ok(out)
85}
86
87/// Render a changeset as markdown with YAML mapping frontmatter
88pub fn render_changeset_markdown(entries: &[(String, Bump)], message: &str) -> String {
89    use std::fmt::Write as _;
90    let mut out = String::new();
91    out.push_str("---\n");
92    for (package, bump) in entries {
93        let package = normalize_package_name(package);
94        let _ = writeln!(out, "{}: {}", package, bump);
95    }
96    out.push_str("---\n\n");
97    out.push_str(message);
98    out.push('\n');
99    out
100}
101
102fn normalize_package_name(raw: &str) -> Cow<'_, str> {
103    let trimmed = raw.trim();
104    if trimmed.len() >= 2 {
105        let bytes = trimmed.as_bytes();
106        let first = bytes[0];
107        let last = bytes[trimmed.len() - 1];
108        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
109            return Cow::Owned(trimmed[1..trimmed.len() - 1].to_string());
110        }
111    }
112    Cow::Borrowed(trimmed)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn parse_valid_changeset() {
121        let text = "---\na: minor\nb: minor\n---\n\nfeat: message\n";
122        let path = Path::new("/tmp/x.md");
123        let changeset = parse_changeset(text, path).unwrap().unwrap();
124        let mut entries = changeset.entries.clone();
125        entries.sort_by(|left, right| left.0.cmp(&right.0));
126        assert_eq!(
127            entries,
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| left.0.cmp(&right.0));
140        assert_eq!(
141            entries,
142            vec![
143                ("sampo-cli".into(), Bump::Patch),
144                ("sampo-core".into(), Bump::Minor)
145            ]
146        );
147    }
148
149    #[test]
150    fn render_changeset_markdown_test() {
151        let markdown = render_changeset_markdown(
152            &[("a".into(), Bump::Minor), ("b".into(), Bump::Minor)],
153            "feat: x",
154        );
155        assert!(markdown.starts_with("---\n"));
156        assert!(markdown.contains("a: minor\n"));
157        assert!(markdown.contains("b: minor\n"));
158        assert!(markdown.contains("---\n\nfeat: x\n"));
159    }
160
161    // Test from sampo/changeset.rs - ensure compatibility
162    #[test]
163    fn render_changeset_markdown_compatibility() {
164        let markdown = render_changeset_markdown(
165            &[("a".into(), Bump::Minor), ("b".into(), Bump::Minor)],
166            "feat: x",
167        );
168        assert!(markdown.starts_with("---\n"));
169        assert!(markdown.contains("a: minor\n"));
170        assert!(markdown.contains("b: minor\n"));
171        assert!(markdown.ends_with("feat: x\n"));
172    }
173
174    #[test]
175    fn render_changeset_markdown_strips_quoted_names() {
176        let markdown =
177            render_changeset_markdown(&[("\"sampo-core\"".into(), Bump::Minor)], "feat: sanitized");
178        assert!(markdown.contains("sampo-core: minor\n"));
179        assert!(!markdown.contains("\"sampo-core\""));
180    }
181
182    #[test]
183    fn parse_major_changeset() {
184        let text = "---\nmypackage: major\n---\n\nBREAKING: API change\n";
185        let path = Path::new("/tmp/major.md");
186        let changeset = parse_changeset(text, path).unwrap().unwrap();
187        assert_eq!(changeset.entries, vec![("mypackage".into(), Bump::Major)]);
188        assert_eq!(changeset.message, "BREAKING: API change");
189    }
190
191    #[test]
192    fn parse_empty_returns_error() {
193        let text = "";
194        let path = Path::new("/tmp/empty.md");
195        assert!(parse_changeset(text, path).is_err());
196    }
197
198    #[test]
199    fn load_changesets_empty_dir() {
200        let temp = tempfile::tempdir().unwrap();
201        let changesets = load_changesets(temp.path()).unwrap();
202        assert!(changesets.is_empty());
203    }
204
205    // Additional tests for comprehensive coverage
206    #[test]
207    fn load_changesets_filters_non_md_files() {
208        let temp = tempfile::tempdir().unwrap();
209        let changeset_dir = temp.path().join("changesets");
210        fs::create_dir_all(&changeset_dir).unwrap();
211
212        // Create a non-markdown file
213        fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
214
215        // Create a valid changeset
216        let valid_content = "---\ntest: patch\n---\n\nTest changeset\n";
217        fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
218
219        let changesets = load_changesets(&changeset_dir).unwrap();
220        assert_eq!(changesets.len(), 1);
221        assert_eq!(changesets[0].entries, vec![("test".into(), Bump::Patch)]);
222    }
223
224    #[test]
225    fn parse_changeset_with_invalid_frontmatter() {
226        let text = "packages:\n  - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
227        let path = Path::new("/tmp/invalid.md");
228        assert!(parse_changeset(text, path).is_err());
229    }
230
231    #[test]
232    fn parse_changeset_missing_packages() {
233        let text = "---\n---\n\nNo packages defined\n";
234        let path = Path::new("/tmp/no-packages.md");
235        assert!(parse_changeset(text, path).is_err());
236    }
237
238    #[test]
239    fn parse_changeset_missing_release() {
240        // Non-semver change type should be rejected by our wrapper
241        let text = "---\n\"test\": none\n---\n\nNo release type\n";
242        let path = Path::new("/tmp/no-release.md");
243        assert!(parse_changeset(text, path).is_err());
244    }
245
246    #[test]
247    fn parse_changeset_empty_message() {
248        let text = "---\ntest: patch\n---\n\n";
249        let path = Path::new("/tmp/empty-message.md");
250        assert!(parse_changeset(text, path).unwrap().is_none());
251    }
252
253    #[test]
254    fn try_from_change_type_to_bump() {
255        use changesets::ChangeType;
256
257        // Test successful conversions
258        assert_eq!(Bump::try_from(ChangeType::Patch), Ok(Bump::Patch));
259        assert_eq!(Bump::try_from(ChangeType::Minor), Ok(Bump::Minor));
260        assert_eq!(Bump::try_from(ChangeType::Major), Ok(Bump::Major));
261
262        // Test rejection of custom types
263        assert!(Bump::try_from(ChangeType::Custom("custom".to_string())).is_err());
264    }
265}