Skip to main content

quanttide_devops/source/
changelog.rs

1use std::path::Path;
2
3// ═══════════════════════════════════════════════════════════════════════
4// 错误类型
5// ═══════════════════════════════════════════════════════════════════════
6
7/// CHANGELOG 操作错误。
8#[derive(Debug)]
9pub enum ChangelogError {
10    /// 文件读取失败。
11    Io(std::io::Error),
12    /// 文件写入失败。
13    File(String),
14    /// git 命令失败。
15    Git(String),
16    /// 解析失败。
17    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// ═══════════════════════════════════════════════════════════════════════
40// Changelog 结构
41// ═══════════════════════════════════════════════════════════════════════
42
43/// CHANGELOG 解析结果。封装 `parse-changelog` 提供便捷方法。
44///
45/// 内部持有所有版本号及 release notes 的完全所有权数据,
46/// 不依赖内部引用,安全无 `unsafe`。
47#[derive(Debug)]
48pub struct Changelog {
49    /// 版本号列表(保持文件中先后顺序)。
50    version_order: Vec<String>,
51    /// 版本号 → Release notes 映射。
52    notes: std::collections::HashMap<String, String>,
53}
54
55impl Changelog {
56    /// 从文件路径解析 CHANGELOG。
57    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    /// 从字符串解析 CHANGELOG。
63    #[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    /// 获取指定版本的 release notes(用于 GitHub Release body)。
77    pub fn release_notes(&self, version: &str) -> Option<&str> {
78        self.notes.get(version).map(|s| s.as_str())
79    }
80
81    /// 检查指定版本是否存在于 CHANGELOG 中。
82    pub fn contains_version(&self, version: &str) -> bool {
83        self.notes.contains_key(version)
84    }
85
86    /// 获取最新发布的版本号(即文件中第一个版本)。
87    pub fn latest_version(&self) -> Option<&str> {
88        self.version_order.first().map(|s| s.as_str())
89    }
90
91    /// 获取所有版本号列表(保持文件中先后顺序)。
92    pub fn versions(&self) -> Vec<&str> {
93        self.version_order.iter().map(|s| s.as_str()).collect()
94    }
95}
96
97// ═══════════════════════════════════════════════════════════════════════
98// Git 日志收集
99// ═══════════════════════════════════════════════════════════════════════
100
101/// 收集 git 提交记录。
102///
103/// `from_tag = Some(tag)` 时范围是 `tag..HEAD`,`None` 时返回全部提交。
104/// 使用 gix 实现,避免依赖 CLI git。
105pub 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
147// ═══════════════════════════════════════════════════════════════════════
148// LLM Prompt 构建
149// ═══════════════════════════════════════════════════════════════════════
150
151/// 根据 git 提交记录构建 CHANGELOG 生成 prompt。
152///
153/// 输出固定的分类规则(Added / Changed / Fixed / Removed),要求 LLM 用中文生成。
154pub 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
170// ═══════════════════════════════════════════════════════════════════════
171// CHANGELOG 条目追加
172// ═══════════════════════════════════════════════════════════════════════
173
174/// 向 CHANGELOG 文件追加新版本条目。
175///
176/// - 文件不存在时创建并写入头部 `# CHANGELOG\n`
177/// - 版本已存在时跳过(返回 `Ok(false)`)
178/// - 新条目插入到已有条目的最前面,放在已有第一个版本之前
179pub 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        // 文件不存在,创建并写入头部和条目
187        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    // 找到第一个版本条目的位置,在它前面插入
203    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// ═══════════════════════════════════════════════════════════════════════
220// 测试
221// ═══════════════════════════════════════════════════════════════════════
222
223#[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        // v 前缀被解析器剥离,版本 key 统一不带 v
311        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        // 0.2.0 应该出现在 0.1.0 前面
373        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        // 只应包含 Added / Changed / Fixed / Removed
392        assert!(prompt.contains("Added"));
393        assert!(prompt.contains("Changed"));
394        assert!(prompt.contains("Fixed"));
395        assert!(prompt.contains("Removed"));
396        // 不包含未定义的关键词
397        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        // CHANGELOG 中写纯版本号,查询也传纯版本号
405        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        // scope 前缀版本不应匹配 CHANGELOG 中的纯版本
414        assert!(!cl.contains_version("cli/0.1.0"));
415    }
416}