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    #[allow(clippy::should_implement_trait)]
63    pub fn from_str(s: &str) -> Result<Self, ChangelogError> {
64        let raw = s.to_string();
65        // Safety: inner 的 &str 引用指向 raw。
66        // raw 和 inner 始终一起移动和释放,引用在 Changelog 存活期间始终有效。
67        let inner =
68            parse_changelog::parse(&raw).map_err(|e| ChangelogError::Parse(e.to_string()))?;
69        // SAFETY: inner 的 &str 引用指向 raw。raw 和 inner 始终一起移动和释放。
70        let inner: parse_changelog::Changelog<'static> = unsafe { std::mem::transmute(inner) };
71        Ok(Self { raw, inner })
72    }
73
74    /// 获取指定版本的 release notes(用于 GitHub Release body)。
75    pub fn release_notes<'a>(&'a self, version: &str) -> Option<&'a str> {
76        self.inner.get(version).map(|r| r.notes)
77    }
78
79    /// 检查指定版本是否存在于 CHANGELOG 中。
80    pub fn contains_version(&self, version: &str) -> bool {
81        self.inner.contains_key(version)
82    }
83
84    /// 获取最新发布的版本号(即文件中第一个版本)。
85    pub fn latest_version(&self) -> Option<&str> {
86        self.inner.keys().next().copied()
87    }
88
89    /// 获取所有版本号列表(保持文件中先后顺序)。
90    pub fn versions(&self) -> Vec<&str> {
91        self.inner.keys().copied().collect()
92    }
93}
94
95// ═══════════════════════════════════════════════════════════════════════
96// 测试
97// ═══════════════════════════════════════════════════════════════════════
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn sample_changelog() -> &'static str {
104        "\
105# Changelog
106
107## [0.1.2] - 2026-07-02
108
109### Changed
110- Refactored model into modules.
111
112## [0.1.1] - 2026-07-02
113
114### Added
115- Version utility functions.
116
117## [0.1.0] - 2026-07-02
118
119### Added
120- Initial release.
121"
122    }
123
124    #[test]
125    fn test_release_notes_existing() {
126        let cl = Changelog::from_str(sample_changelog()).unwrap();
127        let notes = cl.release_notes("0.1.1").unwrap();
128        assert!(notes.contains("Version utility functions"));
129    }
130
131    #[test]
132    fn test_release_notes_not_found() {
133        let cl = Changelog::from_str(sample_changelog()).unwrap();
134        assert!(cl.release_notes("9.9.9").is_none());
135    }
136
137    #[test]
138    fn test_contains_version() {
139        let cl = Changelog::from_str(sample_changelog()).unwrap();
140        assert!(cl.contains_version("0.1.0"));
141        assert!(!cl.contains_version("0.2.0"));
142    }
143
144    #[test]
145    fn test_latest_version() {
146        let cl = Changelog::from_str(sample_changelog()).unwrap();
147        assert_eq!(cl.latest_version(), Some("0.1.2"));
148    }
149
150    #[test]
151    fn test_versions() {
152        let cl = Changelog::from_str(sample_changelog()).unwrap();
153        assert_eq!(cl.versions(), vec!["0.1.2", "0.1.1", "0.1.0"]);
154    }
155
156    #[test]
157    fn test_empty_changelog() {
158        let cl = Changelog::from_str("");
159        assert!(cl.is_err());
160        assert!(cl.unwrap_err().to_string().contains("no release note"));
161    }
162
163    #[test]
164    fn test_from_path() {
165        let d = tempfile::tempdir().unwrap();
166        let path = d.path().join("CHANGELOG.md");
167        std::fs::write(&path, sample_changelog()).unwrap();
168        let cl = Changelog::from_path(&path).unwrap();
169        assert_eq!(cl.latest_version(), Some("0.1.2"));
170    }
171
172    #[test]
173    fn test_from_path_not_found() {
174        let cl = Changelog::from_path(Path::new("/nonexistent/CHANGELOG.md"));
175        assert!(cl.is_err());
176    }
177
178    #[test]
179    fn test_changelog_strips_v_prefix() {
180        let s = "\
181## v0.1.0 - 2026-01-01
182
183### Added
184- Something.
185";
186        // v 前缀被解析器剥离,版本 key 统一不带 v
187        let cl = Changelog::from_str(s).unwrap();
188        assert!(cl.contains_version("0.1.0"));
189        assert!(!cl.contains_version("v0.1.0"));
190    }
191
192    #[test]
193    fn test_scoped_pure_version() {
194        // CHANGELOG 中写纯版本号,查询也传纯版本号
195        let s = "\
196## [0.1.0] - 2026-01-01
197
198### Added
199- CLI release.
200";
201        let cl = Changelog::from_str(s).unwrap();
202        assert!(cl.contains_version("0.1.0"));
203        // scope 前缀版本不应匹配 CHANGELOG 中的纯版本
204        assert!(!cl.contains_version("cli/0.1.0"));
205    }
206}