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#[derive(Debug, Clone)]
10pub struct ChangesetInfo {
11 pub path: PathBuf,
12 pub entries: Vec<(String, Bump)>,
14 pub message: String,
15}
16
17pub 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<(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
63pub 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
87pub 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]
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 #[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 fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
214
215 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 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 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 assert!(Bump::try_from(ChangeType::Custom("custom".to_string())).is_err());
264 }
265}