Skip to main content

quanttide_devops/source/
changelog.rs

1//! CHANGELOG 解析与追加写入。
2//!
3//! 封装 `parse-changelog`,提供版本列举、release notes 提取、条目追加。
4
5use std::path::Path;
6
7// ═══════════════════════════════════════════════════════════════════════
8// 错误类型
9// ═══════════════════════════════════════════════════════════════════════
10
11/// CHANGELOG 操作错误。
12#[derive(thiserror::Error, Debug)]
13pub enum ChangelogError {
14    /// 文件读取失败。
15    #[error("读取 CHANGELOG 失败: {0}")]
16    Io(#[from] std::io::Error),
17    /// 文件写入失败。
18    #[error("文件写入失败: {0}")]
19    File(String),
20    /// git 命令失败。
21    #[error("git 命令失败: {0}")]
22    Git(String),
23    /// 解析失败。
24    #[error("解析 CHANGELOG 失败: {0}")]
25    Parse(String),
26}
27
28// ═══════════════════════════════════════════════════════════════════════
29// Changelog 结构
30// ═══════════════════════════════════════════════════════════════════════
31
32/// CHANGELOG 解析结果。封装 `parse-changelog` 提供便捷方法。
33///
34/// 内部持有所有版本号及 release notes 的完全所有权数据,
35/// 不依赖内部引用,安全无 `unsafe`。
36#[derive(Debug)]
37pub struct Changelog {
38    /// 版本号列表(保持文件中先后顺序)。
39    version_order: Vec<String>,
40    /// 版本号 → Release notes 映射。
41    notes: std::collections::HashMap<String, String>,
42}
43
44impl Changelog {
45    /// 从文件路径解析 CHANGELOG。
46    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    /// 从字符串解析 CHANGELOG。
52    #[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    /// 获取指定版本的 release notes(用于 GitHub Release body)。
66    pub fn release_notes(&self, version: &str) -> Option<&str> {
67        self.notes.get(version).map(|s| s.as_str())
68    }
69
70    /// 检查指定版本是否存在于 CHANGELOG 中。
71    pub fn contains_version(&self, version: &str) -> bool {
72        self.notes.contains_key(version)
73    }
74
75    /// 获取最新发布的版本号(即文件中第一个版本)。
76    pub fn latest_version(&self) -> Option<&str> {
77        self.version_order.first().map(|s| s.as_str())
78    }
79
80    /// 获取所有版本号列表(保持文件中先后顺序)。
81    pub fn versions(&self) -> Vec<&str> {
82        self.version_order.iter().map(|s| s.as_str()).collect()
83    }
84}
85
86// ═══════════════════════════════════════════════════════════════════════
87// Git 日志收集
88// ═══════════════════════════════════════════════════════════════════════
89
90/// 收集 git 提交记录。
91///
92/// `from_tag = Some(tag)` 时范围是 `tag..HEAD`,`None` 时返回全部提交。
93/// 使用 gix 实现,避免依赖 CLI git。
94pub 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
136// ═══════════════════════════════════════════════════════════════════════
137// LLM Prompt 构建
138// ═══════════════════════════════════════════════════════════════════════
139
140/// 根据 git 提交记录构建 CHANGELOG 生成 prompt。
141///
142/// 输出固定的分类规则(Added / Changed / Fixed / Removed),要求 LLM 用中文生成。
143pub 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
159// ═══════════════════════════════════════════════════════════════════════
160// CHANGELOG 条目追加
161// ═══════════════════════════════════════════════════════════════════════
162
163/// 向 CHANGELOG 文件追加新版本条目。
164///
165/// - 文件不存在时创建并写入头部 `# CHANGELOG\n`
166/// - 版本已存在时跳过(返回 `Ok(false)`)
167/// - 新条目插入到已有条目的最前面,放在已有第一个版本之前
168pub 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        // 文件不存在,创建并写入头部和条目
176        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    // 找到第一个版本条目的位置,在它前面插入
192    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// ═══════════════════════════════════════════════════════════════════════
209// 测试
210// ═══════════════════════════════════════════════════════════════════════
211
212#[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        // v 前缀被解析器剥离,版本 key 统一不带 v
300        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        // 0.2.0 应该出现在 0.1.0 前面
362        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        // 只应包含 Added / Changed / Fixed / Removed
381        assert!(prompt.contains("Added"));
382        assert!(prompt.contains("Changed"));
383        assert!(prompt.contains("Fixed"));
384        assert!(prompt.contains("Removed"));
385        // 不包含未定义的关键词
386        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        // CHANGELOG 中写纯版本号,查询也传纯版本号
394        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        // scope 前缀版本不应匹配 CHANGELOG 中的纯版本
403        assert!(!cl.contains_version("cli/0.1.0"));
404    }
405}