1use crate::errors::{Result, SampoError};
2use crate::types::Bump;
3use changesets::Change;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone)]
9pub struct ChangesetInfo {
10 pub path: PathBuf,
11 pub entries: Vec<(String, Bump)>,
13 pub message: String,
14}
15
16pub 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 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
61pub 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
85pub 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]
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 #[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 fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
168
169 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 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 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 assert!(Bump::try_from(ChangeType::Custom("custom".to_string())).is_err());
218 }
219}