1use crate::types::Bump;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone)]
8pub struct ChangesetInfo {
9 pub path: PathBuf,
10 pub packages: Vec<String>,
11 pub bump: Bump,
12 pub message: String,
13}
14
15pub fn parse_changeset(text: &str, path: &Path) -> Option<ChangesetInfo> {
17 let mut lines = text.lines();
19 if lines.next()?.trim() != "---" {
20 return None;
21 }
22 let mut packages: Vec<String> = Vec::new();
23 let mut bump: Option<Bump> = None;
24 let mut in_packages = false;
25 for line in &mut lines {
26 let l = line.trim();
27 if l == "---" {
28 break;
29 }
30 if l.starts_with("packages:") {
31 in_packages = true;
32 continue;
33 }
34 if in_packages {
35 if let Some(rest) = l.strip_prefix('-') {
37 let name = rest.trim().to_string();
38 if !name.is_empty() {
39 packages.push(name);
40 }
41 continue;
42 } else if !l.is_empty() {
43 in_packages = false;
45 }
46 }
47 if let Some(v) = l.strip_prefix("release:")
48 && let Some(b) = Bump::parse(v.trim())
49 {
50 bump = Some(b);
51 }
52 }
53
54 let remainder: String = lines.collect::<Vec<_>>().join("\n");
56 let message = remainder.trim().to_string();
57 if packages.is_empty() || bump.is_none() || message.is_empty() {
58 return None;
59 }
60 Some(ChangesetInfo {
61 path: path.to_path_buf(),
62 packages,
63 bump: bump.unwrap(),
64 message,
65 })
66}
67
68pub fn load_changesets(dir: &Path) -> io::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 = fs::read_to_string(&path)?;
84 if let Some(cs) = parse_changeset(&text, &path) {
85 out.push(cs);
86 }
87 }
88 Ok(out)
89}
90
91pub fn detect_changesets_dir(workspace: &Path) -> PathBuf {
93 let base = workspace.join(".sampo");
94 let cfg_path = base.join("config.toml");
95 if cfg_path.exists()
96 && let Ok(text) = std::fs::read_to_string(&cfg_path)
97 && let Ok(value) = text.parse::<toml::Value>()
98 && let Some(dir) = value
99 .get("changesets")
100 .and_then(|v| v.as_table())
101 .and_then(|t| t.get("dir"))
102 .and_then(|v| v.as_str())
103 {
104 return base.join(dir);
105 }
106 base.join("changesets")
107}
108
109pub fn render_changeset_markdown(packages: &[String], bump: Bump, message: &str) -> String {
111 use std::fmt::Write as _;
112 let mut out = String::new();
113 out.push_str("---\n");
114 out.push_str("packages:\n");
115 for p in packages {
116 let _ = writeln!(out, " - {}", p);
117 }
118 let _ = writeln!(out, "release: {}", bump);
119 out.push_str("---\n\n");
120 out.push_str(message);
121 out.push('\n');
122 out
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn parse_valid_changeset() {
131 let text = "---\npackages:\n - a\n - b\nrelease: minor\n---\n\nfeat: message\n";
132 let p = Path::new("/tmp/x.md");
133 let cs = parse_changeset(text, p).unwrap();
134 assert_eq!(cs.packages, vec!["a", "b"]);
135 assert_eq!(cs.bump, Bump::Minor);
136 assert_eq!(cs.message, "feat: message");
137 }
138
139 #[test]
140 fn render_changeset_markdown_test() {
141 let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
142 assert!(s.starts_with("---\n"));
143 assert!(s.contains("packages:\n - a\n - b\n"));
144 assert!(s.contains("release: minor\n"));
145 assert!(s.contains("---\n\nfeat: x\n"));
146 }
147
148 #[test]
150 fn render_changeset_markdown_compatibility() {
151 let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
152 assert!(s.starts_with("---\n"));
153 assert!(s.contains("packages:\n - a\n - b\n"));
154 assert!(s.contains("release: minor\n"));
155 assert!(s.ends_with("feat: x\n"));
156 }
157
158 #[test]
159 fn parse_major_changeset() {
160 let text = "---\npackages:\n - mypackage\nrelease: major\n---\n\nBREAKING: API change\n";
161 let p = Path::new("/tmp/major.md");
162 let cs = parse_changeset(text, p).unwrap();
163 assert_eq!(cs.packages, vec!["mypackage"]);
164 assert_eq!(cs.bump, Bump::Major);
165 assert_eq!(cs.message, "BREAKING: API change");
166 }
167
168 #[test]
169 fn parse_empty_returns_none() {
170 let text = "";
171 let p = Path::new("/tmp/empty.md");
172 assert!(parse_changeset(text, p).is_none());
173 }
174
175 #[test]
176 fn load_changesets_empty_dir() {
177 let temp = tempfile::tempdir().unwrap();
178 let changesets = load_changesets(temp.path()).unwrap();
179 assert!(changesets.is_empty());
180 }
181
182 #[test]
183 fn detect_changesets_dir_defaults() {
184 let temp = tempfile::tempdir().unwrap();
185 let dir = detect_changesets_dir(temp.path());
186 assert_eq!(dir, temp.path().join(".sampo/changesets"));
187 }
188
189 #[test]
190 fn detect_changesets_dir_custom() {
191 let temp = tempfile::tempdir().unwrap();
192 let sampo_dir = temp.path().join(".sampo");
193 fs::create_dir_all(&sampo_dir).unwrap();
194 fs::write(
195 sampo_dir.join("config.toml"),
196 "[changesets]\ndir = \"custom-changesets\"\n",
197 )
198 .unwrap();
199
200 let dir = detect_changesets_dir(temp.path());
201 assert_eq!(dir, temp.path().join(".sampo/custom-changesets"));
202 }
203
204 #[test]
206 fn load_changesets_filters_non_md_files() {
207 let temp = tempfile::tempdir().unwrap();
208 let changeset_dir = temp.path().join("changesets");
209 fs::create_dir_all(&changeset_dir).unwrap();
210
211 fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
213
214 let valid_content = "---\npackages:\n - test\nrelease: patch\n---\n\nTest changeset\n";
216 fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
217
218 let changesets = load_changesets(&changeset_dir).unwrap();
219 assert_eq!(changesets.len(), 1);
220 assert_eq!(changesets[0].packages, vec!["test"]);
221 }
222
223 #[test]
224 fn parse_changeset_with_invalid_frontmatter() {
225 let text = "packages:\n - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
226 let p = Path::new("/tmp/invalid.md");
227 assert!(parse_changeset(text, p).is_none());
228 }
229
230 #[test]
231 fn parse_changeset_missing_packages() {
232 let text = "---\nrelease: patch\n---\n\nNo packages defined\n";
233 let p = Path::new("/tmp/no-packages.md");
234 assert!(parse_changeset(text, p).is_none());
235 }
236
237 #[test]
238 fn parse_changeset_missing_release() {
239 let text = "---\npackages:\n - test\n---\n\nNo release type\n";
240 let p = Path::new("/tmp/no-release.md");
241 assert!(parse_changeset(text, p).is_none());
242 }
243
244 #[test]
245 fn parse_changeset_empty_message() {
246 let text = "---\npackages:\n - test\nrelease: patch\n---\n\n";
247 let p = Path::new("/tmp/empty-message.md");
248 assert!(parse_changeset(text, p).is_none());
249 }
250}