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/// 内部持有原始文本 + 解析后的有序 Map(版本号 → Release)。
46#[derive(Debug)]
47pub struct Changelog {
48    #[allow(dead_code)]
49    /// 原始文本,保障解析结果的引用有效性。
50    raw: String,
51    /// 解析后的版本 → Release 有序 Map。
52    ///
53    /// # Safety
54    ///
55    /// `inner` 中的 `&str` 引用指向 `self.raw` 的堆内存。
56    /// `raw` 和 `inner` 始终一起移动和释放,因此引用始终有效。
57    inner: parse_changelog::Changelog<'static>,
58}
59
60impl Changelog {
61    /// 从文件路径解析 CHANGELOG。
62    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    /// 从字符串解析 CHANGELOG。
68    #[allow(clippy::should_implement_trait)]
69    pub fn from_str(s: &str) -> Result<Self, ChangelogError> {
70        let raw = s.to_string();
71        // Safety: inner 的 &str 引用指向 raw。
72        // raw 和 inner 始终一起移动和释放,引用在 Changelog 存活期间始终有效。
73        let inner =
74            parse_changelog::parse(&raw).map_err(|e| ChangelogError::Parse(e.to_string()))?;
75        // SAFETY: inner 的 &str 引用指向 raw。raw 和 inner 始终一起移动和释放。
76        let inner: parse_changelog::Changelog<'static> = unsafe { std::mem::transmute(inner) };
77        Ok(Self { raw, inner })
78    }
79
80    /// 获取指定版本的 release notes(用于 GitHub Release body)。
81    pub fn release_notes<'a>(&'a self, version: &str) -> Option<&'a str> {
82        self.inner.get(version).map(|r| r.notes)
83    }
84
85    /// 检查指定版本是否存在于 CHANGELOG 中。
86    pub fn contains_version(&self, version: &str) -> bool {
87        self.inner.contains_key(version)
88    }
89
90    /// 获取最新发布的版本号(即文件中第一个版本)。
91    pub fn latest_version(&self) -> Option<&str> {
92        self.inner.keys().next().copied()
93    }
94
95    /// 获取所有版本号列表(保持文件中先后顺序)。
96    pub fn versions(&self) -> Vec<&str> {
97        self.inner.keys().copied().collect()
98    }
99}
100
101// ═══════════════════════════════════════════════════════════════════════
102// Git 日志收集
103// ═══════════════════════════════════════════════════════════════════════
104
105/// 收集 git 提交记录。
106///
107/// `from_tag = Some(tag)` 时范围是 `tag..HEAD`,`None` 时返回全部提交。
108/// 使用 gix 实现,避免依赖 CLI git。
109pub 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
151// ═══════════════════════════════════════════════════════════════════════
152// LLM Prompt 构建
153// ═══════════════════════════════════════════════════════════════════════
154
155/// 根据 git 提交记录构建 CHANGELOG 生成 prompt。
156///
157/// 输出固定的分类规则(Added / Changed / Fixed / Removed),要求 LLM 用中文生成。
158pub 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
174// ═══════════════════════════════════════════════════════════════════════
175// CHANGELOG 条目追加
176// ═══════════════════════════════════════════════════════════════════════
177
178/// 向 CHANGELOG 文件追加新版本条目。
179///
180/// - 文件不存在时创建并写入头部 `# CHANGELOG\n`
181/// - 版本已存在时跳过(返回 `Ok(false)`)
182/// - 新条目插入到已有条目的最前面,放在已有第一个版本之前
183pub 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        // 文件不存在,创建并写入头部和条目
191        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    // 找到第一个版本条目的位置,在它前面插入
207    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// ═══════════════════════════════════════════════════════════════════════
224// 测试
225// ═══════════════════════════════════════════════════════════════════════
226
227#[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        // v 前缀被解析器剥离,版本 key 统一不带 v
315        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        // 0.2.0 应该出现在 0.1.0 前面
377        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        // 只应包含 Added / Changed / Fixed / Removed
396        assert!(prompt.contains("Added"));
397        assert!(prompt.contains("Changed"));
398        assert!(prompt.contains("Fixed"));
399        assert!(prompt.contains("Removed"));
400        // 不包含未定义的关键词
401        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        // CHANGELOG 中写纯版本号,查询也传纯版本号
409        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        // scope 前缀版本不应匹配 CHANGELOG 中的纯版本
418        assert!(!cl.contains_version("cli/0.1.0"));
419    }
420}