quanttide_devops/source/
changelog.rs1use std::path::Path;
2
3#[derive(Debug)]
9pub enum ChangelogError {
10 Io(std::io::Error),
12 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#[derive(Debug)]
41pub struct Changelog {
42 #[allow(dead_code)]
43 raw: String,
45 inner: parse_changelog::Changelog<'static>,
52}
53
54impl Changelog {
55 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 #[allow(clippy::should_implement_trait)]
63 pub fn from_str(s: &str) -> Result<Self, ChangelogError> {
64 let raw = s.to_string();
65 let inner =
68 parse_changelog::parse(&raw).map_err(|e| ChangelogError::Parse(e.to_string()))?;
69 let inner: parse_changelog::Changelog<'static> = unsafe { std::mem::transmute(inner) };
71 Ok(Self { raw, inner })
72 }
73
74 pub fn release_notes<'a>(&'a self, version: &str) -> Option<&'a str> {
76 self.inner.get(version).map(|r| r.notes)
77 }
78
79 pub fn contains_version(&self, version: &str) -> bool {
81 self.inner.contains_key(version)
82 }
83
84 pub fn latest_version(&self) -> Option<&str> {
86 self.inner.keys().next().copied()
87 }
88
89 pub fn versions(&self) -> Vec<&str> {
91 self.inner.keys().copied().collect()
92 }
93}
94
95#[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 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 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 assert!(!cl.contains_version("cli/0.1.0"));
205 }
206}