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 render_changeset_markdown(packages: &[String], bump: Bump, message: &str) -> String {
93 use std::fmt::Write as _;
94 let mut out = String::new();
95 out.push_str("---\n");
96 out.push_str("packages:\n");
97 for p in packages {
98 let _ = writeln!(out, " - {}", p);
99 }
100 let _ = writeln!(out, "release: {}", bump);
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 = "---\npackages:\n - a\n - b\nrelease: minor\n---\n\nfeat: message\n";
114 let p = Path::new("/tmp/x.md");
115 let cs = parse_changeset(text, p).unwrap();
116 assert_eq!(cs.packages, vec!["a", "b"]);
117 assert_eq!(cs.bump, Bump::Minor);
118 assert_eq!(cs.message, "feat: message");
119 }
120
121 #[test]
122 fn render_changeset_markdown_test() {
123 let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
124 assert!(s.starts_with("---\n"));
125 assert!(s.contains("packages:\n - a\n - b\n"));
126 assert!(s.contains("release: minor\n"));
127 assert!(s.contains("---\n\nfeat: x\n"));
128 }
129
130 #[test]
132 fn render_changeset_markdown_compatibility() {
133 let s = render_changeset_markdown(&["a".into(), "b".into()], Bump::Minor, "feat: x");
134 assert!(s.starts_with("---\n"));
135 assert!(s.contains("packages:\n - a\n - b\n"));
136 assert!(s.contains("release: minor\n"));
137 assert!(s.ends_with("feat: x\n"));
138 }
139
140 #[test]
141 fn parse_major_changeset() {
142 let text = "---\npackages:\n - mypackage\nrelease: major\n---\n\nBREAKING: API change\n";
143 let p = Path::new("/tmp/major.md");
144 let cs = parse_changeset(text, p).unwrap();
145 assert_eq!(cs.packages, vec!["mypackage"]);
146 assert_eq!(cs.bump, Bump::Major);
147 assert_eq!(cs.message, "BREAKING: API change");
148 }
149
150 #[test]
151 fn parse_empty_returns_none() {
152 let text = "";
153 let p = Path::new("/tmp/empty.md");
154 assert!(parse_changeset(text, p).is_none());
155 }
156
157 #[test]
158 fn load_changesets_empty_dir() {
159 let temp = tempfile::tempdir().unwrap();
160 let changesets = load_changesets(temp.path()).unwrap();
161 assert!(changesets.is_empty());
162 }
163
164 #[test]
166 fn load_changesets_filters_non_md_files() {
167 let temp = tempfile::tempdir().unwrap();
168 let changeset_dir = temp.path().join("changesets");
169 fs::create_dir_all(&changeset_dir).unwrap();
170
171 fs::write(changeset_dir.join("not-a-changeset.txt"), "invalid content").unwrap();
173
174 let valid_content = "---\npackages:\n - test\nrelease: patch\n---\n\nTest changeset\n";
176 fs::write(changeset_dir.join("valid.md"), valid_content).unwrap();
177
178 let changesets = load_changesets(&changeset_dir).unwrap();
179 assert_eq!(changesets.len(), 1);
180 assert_eq!(changesets[0].packages, vec!["test"]);
181 }
182
183 #[test]
184 fn parse_changeset_with_invalid_frontmatter() {
185 let text = "packages:\n - test\nrelease: patch\n---\n\nNo frontmatter delimiter\n";
186 let p = Path::new("/tmp/invalid.md");
187 assert!(parse_changeset(text, p).is_none());
188 }
189
190 #[test]
191 fn parse_changeset_missing_packages() {
192 let text = "---\nrelease: patch\n---\n\nNo packages defined\n";
193 let p = Path::new("/tmp/no-packages.md");
194 assert!(parse_changeset(text, p).is_none());
195 }
196
197 #[test]
198 fn parse_changeset_missing_release() {
199 let text = "---\npackages:\n - test\n---\n\nNo release type\n";
200 let p = Path::new("/tmp/no-release.md");
201 assert!(parse_changeset(text, p).is_none());
202 }
203
204 #[test]
205 fn parse_changeset_empty_message() {
206 let text = "---\npackages:\n - test\nrelease: patch\n---\n\n";
207 let p = Path::new("/tmp/empty-message.md");
208 assert!(parse_changeset(text, p).is_none());
209 }
210}