quanttide_devops/source/
changelog.rs1use std::path::Path;
2
3#[derive(Debug)]
9pub enum ChangelogError {
10 Io(std::io::Error),
12 File(String),
14 Git(String),
16 Parse(String),
18}
19
20impl std::fmt::Display for ChangelogError {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::Io(e) => write!(f, "读取 CHANGELOG 失败: {}", e),
24 Self::File(e) => write!(f, "文件写入失败: {}", e),
25 Self::Git(e) => write!(f, "git 命令失败: {}", e),
26 Self::Parse(e) => write!(f, "解析 CHANGELOG 失败: {}", e),
27 }
28 }
29}
30
31impl std::error::Error for ChangelogError {}
32
33impl From<std::io::Error> for ChangelogError {
34 fn from(e: std::io::Error) -> Self {
35 Self::Io(e)
36 }
37}
38
39#[derive(Debug)]
48pub struct Changelog {
49 version_order: Vec<String>,
51 notes: std::collections::HashMap<String, String>,
53}
54
55impl Changelog {
56 pub fn from_path(path: &Path) -> Result<Self, ChangelogError> {
58 let raw = std::fs::read_to_string(path)?;
59 Self::from_str(&raw)
60 }
61
62 #[allow(clippy::should_implement_trait)]
64 pub fn from_str(s: &str) -> Result<Self, ChangelogError> {
65 let parsed =
66 parse_changelog::parse(s).map_err(|e| ChangelogError::Parse(e.to_string()))?;
67 let mut version_order = Vec::with_capacity(parsed.len());
68 let mut notes = std::collections::HashMap::with_capacity(parsed.len());
69 for (version, release) in parsed.iter() {
70 version_order.push(version.to_string());
71 notes.insert(version.to_string(), release.notes.to_string());
72 }
73 Ok(Self { version_order, notes })
74 }
75
76 pub fn release_notes(&self, version: &str) -> Option<&str> {
78 self.notes.get(version).map(|s| s.as_str())
79 }
80
81 pub fn contains_version(&self, version: &str) -> bool {
83 self.notes.contains_key(version)
84 }
85
86 pub fn latest_version(&self) -> Option<&str> {
88 self.version_order.first().map(|s| s.as_str())
89 }
90
91 pub fn versions(&self) -> Vec<&str> {
93 self.version_order.iter().map(|s| s.as_str()).collect()
94 }
95}
96
97pub fn collect_git_log(repo_path: &Path, from_tag: Option<&str>) -> Result<String, ChangelogError> {
106 let repo = gix::open(repo_path).map_err(|e| ChangelogError::Git(e.to_string()))?;
107
108 let head_id = repo
109 .head_id()
110 .map_err(|e| ChangelogError::Git(e.to_string()))?;
111
112 let mut platform = repo.rev_walk([head_id]);
113
114 if let Some(tag) = from_tag {
115 let full_ref = format!("refs/tags/{}", tag);
116 let mut tag_ref = repo
117 .find_reference(&full_ref)
118 .map_err(|e| ChangelogError::Git(format!("找不到 tag '{}': {}", tag, e)))?;
119 let peeled = tag_ref
120 .peel_to_id_in_place()
121 .map_err(|e| ChangelogError::Git(e.to_string()))?;
122 platform = platform.with_pruned([peeled]);
123 }
124
125 let walk = platform
126 .all()
127 .map_err(|e| ChangelogError::Git(e.to_string()))?;
128
129 let mut lines = Vec::new();
130 for item in walk {
131 let info = item.map_err(|e| ChangelogError::Git(e.to_string()))?;
132 let commit = info
133 .object()
134 .map_err(|e| ChangelogError::Git(e.to_string()))?;
135 let msg = commit.message().map_err(|e| ChangelogError::Git(e.to_string()))?;
136 let short_hex = info.id.to_hex_with_len(7);
137 lines.push(format!("{} {}", short_hex, msg.summary()));
138 }
139
140 if lines.is_empty() {
141 return Err(ChangelogError::Git("没有新的提交记录".into()));
142 }
143
144 Ok(lines.join("\n"))
145}
146
147pub fn build_changelog_prompt(git_log: &str, version: &str) -> String {
155 format!(
156 "根据以下 git 提交记录,为版本 {} 生成 CHANGELOG 条目。\n\
157 \n\
158 要求:\n\
159 1. 按 Added / Changed / Fixed / Removed 分类\n\
160 2. 同类提交合并为概括性条目,不要逐条罗列\n\
161 3. 用中文描述\n\
162 4. 每类不超过 5 条\n\
163 5. 仅输出内容,不要版本头部和日期\n\
164 \n\
165 提交记录:\n{}",
166 version, git_log
167 )
168}
169
170pub fn append_entry(path: &Path, version: &str, content: &str) -> Result<bool, ChangelogError> {
180 let bare = crate::contract::normalize_version(version);
181
182 let today = jiff::Zoned::now().strftime("%Y-%m-%d").to_string();
183 let entry = format!("\n## [{}] - {}\n\n{}\n", bare, today, content);
184
185 if !path.exists() {
186 let mut body = "# CHANGELOG\n".to_string();
188 body.push_str(&entry);
189 std::fs::write(path, &body).map_err(|e| ChangelogError::File(e.to_string()))?;
190 return Ok(true);
191 }
192
193 let raw = std::fs::read_to_string(path)?;
194 let changelog = Changelog::from_str(&raw).map_err(|_| {
195 ChangelogError::File("已存在文件不是有效的 CHANGELOG 格式".into())
196 })?;
197
198 if changelog.contains_version(&bare) {
199 return Ok(false);
200 }
201
202 let insert_at = if let Some(first) = changelog.latest_version() {
204 raw.find(&format!("## [{}]", first))
205 .unwrap_or(raw.len())
206 } else {
207 raw.find('\n').map(|i| i + 1).unwrap_or(raw.len())
208 };
209
210 let mut body = String::with_capacity(raw.len() + entry.len());
211 body.push_str(&raw[..insert_at]);
212 body.push_str(&entry);
213 body.push_str(&raw[insert_at..]);
214
215 std::fs::write(path, &body).map_err(|e| ChangelogError::File(e.to_string()))?;
216 Ok(true)
217}
218
219#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn sample_changelog() -> &'static str {
228 "\
229# Changelog
230
231## [0.1.2] - 2026-07-02
232
233### Changed
234- Refactored model into modules.
235
236## [0.1.1] - 2026-07-02
237
238### Added
239- Version utility functions.
240
241## [0.1.0] - 2026-07-02
242
243### Added
244- Initial release.
245"
246 }
247
248 #[test]
249 fn test_release_notes_existing() {
250 let cl = Changelog::from_str(sample_changelog()).unwrap();
251 let notes = cl.release_notes("0.1.1").unwrap();
252 assert!(notes.contains("Version utility functions"));
253 }
254
255 #[test]
256 fn test_release_notes_not_found() {
257 let cl = Changelog::from_str(sample_changelog()).unwrap();
258 assert!(cl.release_notes("9.9.9").is_none());
259 }
260
261 #[test]
262 fn test_contains_version() {
263 let cl = Changelog::from_str(sample_changelog()).unwrap();
264 assert!(cl.contains_version("0.1.0"));
265 assert!(!cl.contains_version("0.2.0"));
266 }
267
268 #[test]
269 fn test_latest_version() {
270 let cl = Changelog::from_str(sample_changelog()).unwrap();
271 assert_eq!(cl.latest_version(), Some("0.1.2"));
272 }
273
274 #[test]
275 fn test_versions() {
276 let cl = Changelog::from_str(sample_changelog()).unwrap();
277 assert_eq!(cl.versions(), vec!["0.1.2", "0.1.1", "0.1.0"]);
278 }
279
280 #[test]
281 fn test_empty_changelog() {
282 let cl = Changelog::from_str("");
283 assert!(cl.is_err());
284 assert!(cl.unwrap_err().to_string().contains("no release note"));
285 }
286
287 #[test]
288 fn test_from_path() {
289 let d = tempfile::tempdir().unwrap();
290 let path = d.path().join("CHANGELOG.md");
291 std::fs::write(&path, sample_changelog()).unwrap();
292 let cl = Changelog::from_path(&path).unwrap();
293 assert_eq!(cl.latest_version(), Some("0.1.2"));
294 }
295
296 #[test]
297 fn test_from_path_not_found() {
298 let cl = Changelog::from_path(Path::new("/nonexistent/CHANGELOG.md"));
299 assert!(cl.is_err());
300 }
301
302 #[test]
303 fn test_changelog_strips_v_prefix() {
304 let s = "\
305## v0.1.0 - 2026-01-01
306
307### Added
308- Something.
309";
310 let cl = Changelog::from_str(s).unwrap();
312 assert!(cl.contains_version("0.1.0"));
313 assert!(!cl.contains_version("v0.1.0"));
314 }
315
316 #[test]
317 fn test_changelog_error_display_git() {
318 let err = ChangelogError::Git("rev-parse failed".into());
319 assert!(err.to_string().contains("git 命令失败"));
320 }
321
322 #[test]
323 fn test_changelog_error_display_file() {
324 let err = ChangelogError::File("permission denied".into());
325 assert!(err.to_string().contains("文件写入失败"));
326 }
327
328 #[test]
329 fn test_build_prompt_contains_version() {
330 let prompt = build_changelog_prompt("fix bug", "0.2.0");
331 assert!(prompt.contains("0.2.0"));
332 }
333
334 #[test]
335 fn test_build_prompt_contains_git_log() {
336 let prompt = build_changelog_prompt("feat: add login\nfix: crash", "0.2.0");
337 assert!(prompt.contains("feat: add login"));
338 assert!(prompt.contains("fix: crash"));
339 }
340
341 #[test]
342 fn test_append_entry_creates_new_file() {
343 let d = tempfile::tempdir().unwrap();
344 let path = d.path().join("CHANGELOG.md");
345 let ok = append_entry(&path, "0.1.0", "### Added\n- Initial release.").unwrap();
346 assert!(ok);
347 let raw = std::fs::read_to_string(&path).unwrap();
348 assert!(raw.starts_with("# CHANGELOG\n"));
349 assert!(raw.contains("## [0.1.0]"));
350 assert!(raw.contains("Initial release"));
351 }
352
353 #[test]
354 fn test_append_entry_existing_version_skips() {
355 let d = tempfile::tempdir().unwrap();
356 let path = d.path().join("CHANGELOG.md");
357 append_entry(&path, "0.1.0", "first").unwrap();
358 let ok = append_entry(&path, "0.1.0", "second").unwrap();
359 assert!(!ok);
360 let raw = std::fs::read_to_string(&path).unwrap();
361 assert!(raw.contains("first"));
362 assert!(!raw.contains("second"));
363 }
364
365 #[test]
366 fn test_append_entry_prepends() {
367 let d = tempfile::tempdir().unwrap();
368 let path = d.path().join("CHANGELOG.md");
369 append_entry(&path, "0.1.0", "first release").unwrap();
370 append_entry(&path, "0.2.0", "second release").unwrap();
371 let raw = std::fs::read_to_string(&path).unwrap();
372 let pos1 = raw.find("0.2.0").unwrap();
374 let pos2 = raw.find("0.1.0").unwrap();
375 assert!(pos1 < pos2, "新版本应该插入到已有版本之前");
376 }
377
378 #[test]
379 fn test_append_entry_strips_scope_version() {
380 let d = tempfile::tempdir().unwrap();
381 let path = d.path().join("CHANGELOG.md");
382 append_entry(&path, "cli/0.1.0", "CLI release").unwrap();
383 let raw = std::fs::read_to_string(&path).unwrap();
384 assert!(!raw.contains("cli/0.1.0"));
385 assert!(raw.contains("## [0.1.0]"));
386 }
387
388 #[test]
389 fn test_build_prompt_no_undefined_categories() {
390 let prompt = build_changelog_prompt("something", "0.1.0");
391 assert!(prompt.contains("Added"));
393 assert!(prompt.contains("Changed"));
394 assert!(prompt.contains("Fixed"));
395 assert!(prompt.contains("Removed"));
396 assert!(!prompt.contains("Deleted"));
398 assert!(!prompt.contains("Deprecated"));
399 assert!(!prompt.contains("Security"));
400 }
401
402 #[test]
403 fn test_scoped_pure_version() {
404 let s = "\
406## [0.1.0] - 2026-01-01
407
408### Added
409- CLI release.
410";
411 let cl = Changelog::from_str(s).unwrap();
412 assert!(cl.contains_version("0.1.0"));
413 assert!(!cl.contains_version("cli/0.1.0"));
415 }
416}