use std::path::Path;
#[derive(Debug)]
pub enum ChangelogError {
Io(std::io::Error),
Parse(String),
}
impl std::fmt::Display for ChangelogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "读取 CHANGELOG 失败: {}", e),
Self::Parse(e) => write!(f, "解析 CHANGELOG 失败: {}", e),
}
}
}
impl std::error::Error for ChangelogError {}
impl From<std::io::Error> for ChangelogError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug)]
pub struct Changelog {
#[allow(dead_code)]
raw: String,
inner: parse_changelog::Changelog<'static>,
}
impl Changelog {
pub fn from_path(path: &Path) -> Result<Self, ChangelogError> {
let raw = std::fs::read_to_string(path)?;
Self::from_str(&raw)
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self, ChangelogError> {
let raw = s.to_string();
let inner =
parse_changelog::parse(&raw).map_err(|e| ChangelogError::Parse(e.to_string()))?;
let inner: parse_changelog::Changelog<'static> = unsafe { std::mem::transmute(inner) };
Ok(Self { raw, inner })
}
pub fn release_notes<'a>(&'a self, version: &str) -> Option<&'a str> {
self.inner.get(version).map(|r| r.notes)
}
pub fn contains_version(&self, version: &str) -> bool {
self.inner.contains_key(version)
}
pub fn latest_version(&self) -> Option<&str> {
self.inner.keys().next().copied()
}
pub fn versions(&self) -> Vec<&str> {
self.inner.keys().copied().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_changelog() -> &'static str {
"\
# Changelog
## [0.1.2] - 2026-07-02
### Changed
- Refactored model into modules.
## [0.1.1] - 2026-07-02
### Added
- Version utility functions.
## [0.1.0] - 2026-07-02
### Added
- Initial release.
"
}
#[test]
fn test_release_notes_existing() {
let cl = Changelog::from_str(sample_changelog()).unwrap();
let notes = cl.release_notes("0.1.1").unwrap();
assert!(notes.contains("Version utility functions"));
}
#[test]
fn test_release_notes_not_found() {
let cl = Changelog::from_str(sample_changelog()).unwrap();
assert!(cl.release_notes("9.9.9").is_none());
}
#[test]
fn test_contains_version() {
let cl = Changelog::from_str(sample_changelog()).unwrap();
assert!(cl.contains_version("0.1.0"));
assert!(!cl.contains_version("0.2.0"));
}
#[test]
fn test_latest_version() {
let cl = Changelog::from_str(sample_changelog()).unwrap();
assert_eq!(cl.latest_version(), Some("0.1.2"));
}
#[test]
fn test_versions() {
let cl = Changelog::from_str(sample_changelog()).unwrap();
assert_eq!(cl.versions(), vec!["0.1.2", "0.1.1", "0.1.0"]);
}
#[test]
fn test_empty_changelog() {
let cl = Changelog::from_str("");
assert!(cl.is_err());
assert!(cl.unwrap_err().to_string().contains("no release note"));
}
#[test]
fn test_from_path() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("CHANGELOG.md");
std::fs::write(&path, sample_changelog()).unwrap();
let cl = Changelog::from_path(&path).unwrap();
assert_eq!(cl.latest_version(), Some("0.1.2"));
}
#[test]
fn test_from_path_not_found() {
let cl = Changelog::from_path(Path::new("/nonexistent/CHANGELOG.md"));
assert!(cl.is_err());
}
#[test]
fn test_changelog_strips_v_prefix() {
let s = "\
## v0.1.0 - 2026-01-01
### Added
- Something.
";
let cl = Changelog::from_str(s).unwrap();
assert!(cl.contains_version("0.1.0"));
assert!(!cl.contains_version("v0.1.0"));
}
#[test]
fn test_scoped_pure_version() {
let s = "\
## [0.1.0] - 2026-01-01
### Added
- CLI release.
";
let cl = Changelog::from_str(s).unwrap();
assert!(cl.contains_version("0.1.0"));
assert!(!cl.contains_version("cli/0.1.0"));
}
}