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