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    Parse(String),
14}
15
16impl std::fmt::Display for ChangelogError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            Self::Io(e) => write!(f, "读取 CHANGELOG 失败: {}", e),
20            Self::Parse(e) => write!(f, "解析 CHANGELOG 失败: {}", e),
21        }
22    }
23}
24
25impl std::error::Error for ChangelogError {}
26
27impl From<std::io::Error> for ChangelogError {
28    fn from(e: std::io::Error) -> Self {
29        Self::Io(e)
30    }
31}
32
33// ═══════════════════════════════════════════════════════════════════════
34// Changelog 结构
35// ═══════════════════════════════════════════════════════════════════════
36
37/// CHANGELOG 解析结果。封装 `parse-changelog` 提供便捷方法。
38///
39/// 内部持有原始文本 + 解析后的有序 Map(版本号 → Release)。
40#[derive(Debug)]
41pub struct Changelog {
42    #[allow(dead_code)]
43    /// 原始文本,保障解析结果的引用有效性。
44    raw: String,
45    /// 解析后的版本 → Release 有序 Map。
46    ///
47    /// # Safety
48    ///
49    /// `inner` 中的 `&str` 引用指向 `self.raw` 的堆内存。
50    /// `raw` 和 `inner` 始终一起移动和释放,因此引用始终有效。
51    inner: parse_changelog::Changelog<'static>,
52}
53
54impl Changelog {
55    /// 从文件路径解析 CHANGELOG。
56    pub fn from_path(path: &Path) -> Result<Self, ChangelogError> {
57        let raw = std::fs::read_to_string(path)?;
58        Self::from_str(&raw)
59    }
60
61    /// 从字符串解析 CHANGELOG。
62    pub fn from_str(s: &str) -> Result<Self, ChangelogError> {
63        let raw = s.to_string();
64        // Safety: inner 的 &str 引用指向 raw。
65        // raw 和 inner 始终一起移动和释放,引用在 Changelog 存活期间始终有效。
66        let inner =
67            parse_changelog::parse(&raw).map_err(|e| ChangelogError::Parse(e.to_string()))?;
68        // SAFETY: inner 的 &str 引用指向 raw。raw 和 inner 始终一起移动和释放。
69        let inner: parse_changelog::Changelog<'static> = unsafe { std::mem::transmute(inner) };
70        Ok(Self { raw, inner })
71    }
72
73    /// 获取指定版本的 release notes(用于 GitHub Release body)。
74    pub fn release_notes<'a>(&'a self, version: &str) -> Option<&'a str> {
75        self.inner.get(version).map(|r| r.notes)
76    }
77
78    /// 检查指定版本是否存在于 CHANGELOG 中。
79    pub fn contains_version(&self, version: &str) -> bool {
80        self.inner.contains_key(version)
81    }
82
83    /// 获取最新发布的版本号(即文件中第一个版本)。
84    pub fn latest_version(&self) -> Option<&str> {
85        self.inner.keys().next().copied()
86    }
87
88    /// 获取所有版本号列表(保持文件中先后顺序)。
89    pub fn versions(&self) -> Vec<&str> {
90        self.inner.keys().copied().collect()
91    }
92}
93
94// ═══════════════════════════════════════════════════════════════════════
95// 测试
96// ═══════════════════════════════════════════════════════════════════════
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    fn sample_changelog() -> &'static str {
103        "\
104# Changelog
105
106## [0.1.2] - 2026-07-02
107
108### Changed
109- Refactored model into modules.
110
111## [0.1.1] - 2026-07-02
112
113### Added
114- Version utility functions.
115
116## [0.1.0] - 2026-07-02
117
118### Added
119- Initial release.
120"
121    }
122
123    #[test]
124    fn test_release_notes_existing() {
125        let cl = Changelog::from_str(sample_changelog()).unwrap();
126        let notes = cl.release_notes("0.1.1").unwrap();
127        assert!(notes.contains("Version utility functions"));
128    }
129
130    #[test]
131    fn test_release_notes_not_found() {
132        let cl = Changelog::from_str(sample_changelog()).unwrap();
133        assert!(cl.release_notes("9.9.9").is_none());
134    }
135
136    #[test]
137    fn test_contains_version() {
138        let cl = Changelog::from_str(sample_changelog()).unwrap();
139        assert!(cl.contains_version("0.1.0"));
140        assert!(!cl.contains_version("0.2.0"));
141    }
142
143    #[test]
144    fn test_latest_version() {
145        let cl = Changelog::from_str(sample_changelog()).unwrap();
146        assert_eq!(cl.latest_version(), Some("0.1.2"));
147    }
148
149    #[test]
150    fn test_versions() {
151        let cl = Changelog::from_str(sample_changelog()).unwrap();
152        assert_eq!(cl.versions(), vec!["0.1.2", "0.1.1", "0.1.0"]);
153    }
154
155    #[test]
156    fn test_empty_changelog() {
157        let cl = Changelog::from_str("");
158        assert!(cl.is_err());
159        assert!(cl.unwrap_err().to_string().contains("no release note"));
160    }
161
162    #[test]
163    fn test_from_path() {
164        let d = tempfile::tempdir().unwrap();
165        let path = d.path().join("CHANGELOG.md");
166        std::fs::write(&path, sample_changelog()).unwrap();
167        let cl = Changelog::from_path(&path).unwrap();
168        assert_eq!(cl.latest_version(), Some("0.1.2"));
169    }
170
171    #[test]
172    fn test_from_path_not_found() {
173        let cl = Changelog::from_path(Path::new("/nonexistent/CHANGELOG.md"));
174        assert!(cl.is_err());
175    }
176
177    #[test]
178    fn test_changelog_strips_v_prefix() {
179        let s = "\
180## v0.1.0 - 2026-01-01
181
182### Added
183- Something.
184";
185        // v 前缀被解析器剥离,版本 key 统一不带 v
186        let cl = Changelog::from_str(s).unwrap();
187        assert!(cl.contains_version("0.1.0"));
188        assert!(!cl.contains_version("v0.1.0"));
189    }
190
191    #[test]
192    fn test_scoped_pure_version() {
193        // CHANGELOG 中写纯版本号,查询也传纯版本号
194        let s = "\
195## [0.1.0] - 2026-01-01
196
197### Added
198- CLI release.
199";
200        let cl = Changelog::from_str(s).unwrap();
201        assert!(cl.contains_version("0.1.0"));
202        // scope 前缀版本不应匹配 CHANGELOG 中的纯版本
203        assert!(!cl.contains_version("cli/0.1.0"));
204    }
205}