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