1use crate::errors::{Result, SampoError};
2use crate::types::{Bump, PackageSpecifier};
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<(PackageSpecifier, Bump)>,
13 pub message: String,
14}
15
16pub 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 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
68pub 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
92pub 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]
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 #[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 fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
263
264 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 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 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 assert!(Bump::try_from(ChangeType::Custom("custom".to_string())).is_err());
315 }
316}