Abbaye

at ccc40f5

use std::path::PathBuf;

use miette::{IntoDiagnostic, Result, miette};
use serde::Deserialize;

/// Configuration for [`ChangelogExtractor`].
#[derive(Debug, Default, Clone, Deserialize)]
pub struct ChangelogConfig {
    /// Directory that contains the `CHANGELOG.md`.
    /// Defaults to the current working directory.
    pub directory: Option<PathBuf>,
}

/// Reads version information from a `CHANGELOG.md` file.
///
/// Use [`ChangelogExtractor::section`] to retrieve
/// the full release notes for a specific version.
pub struct ChangelogExtractor;

impl ChangelogExtractor {
    /// Returns the latest (topmost) version found in the changelog.
    pub async fn latest_version(&self, config: ChangelogConfig) -> Result<String> {
        let changelog = Changelog::load(config).await?;
        changelog
            .latest_version()
            .map(str::to_owned)
            .ok_or_else(|| miette!("no version headings found in CHANGELOG.md"))
    }

    /// Returns the body of the changelog section for `version`.
    ///
    /// `version` should match the string as it appears in the heading, without
    /// brackets (e.g. `"1.2.0"` matches both `## [1.2.0]` and `## 1.2.0`).
    pub async fn section(&self, config: ChangelogConfig, version: &str) -> Result<String> {
        let changelog = Changelog::load(config).await?;
        changelog
            .section(version)
            .map(str::to_owned)
            .ok_or_else(|| miette!("version {version} not found in CHANGELOG.md"))
    }
}

// ── Parsing ───────────────────────────────────────────────────────────────────

struct ChangelogSection {
    version: String,
    body: String,
}

struct Changelog {
    sections: Vec<ChangelogSection>,
}

impl Changelog {
    async fn load(config: ChangelogConfig) -> Result<Self> {
        let path = config
            .directory
            .unwrap_or_else(|| PathBuf::from("."))
            .join("CHANGELOG.md");

        let content = tokio::fs::read_to_string(&path).await.into_diagnostic()?;

        Ok(Self::parse(&content))
    }

    fn parse(content: &str) -> Self {
        let mut sections: Vec<ChangelogSection> = Vec::new();
        let mut current_version: Option<String> = None;
        let mut current_body: Vec<&str> = Vec::new();

        for line in content.lines() {
            if let Some(version) = parse_version_heading(line) {
                // Flush the previous section before starting a new one.
                if let Some(ver) = current_version.take() {
                    sections.push(ChangelogSection {
                        version: ver,
                        body: current_body.join("\n").trim().to_owned(),
                    });
                    current_body.clear();
                }
                current_version = Some(version);
            } else if current_version.is_some() {
                current_body.push(line);
            }
        }

        // Flush the final section.
        if let Some(ver) = current_version {
            sections.push(ChangelogSection {
                version: ver,
                body: current_body.join("\n").trim().to_owned(),
            });
        }

        Self { sections }
    }

    /// Returns the version string from the first (latest) section.
    fn latest_version(&self) -> Option<&str> {
        self.sections.first().map(|s| s.version.as_str())
    }

    /// Returns the body of the section whose version matches `version`.
    fn section(&self, version: &str) -> Option<&str> {
        self.sections
            .iter()
            .find(|s| s.version == version)
            .map(|s| s.body.as_str())
    }
}

/// Parses an `##`-level Markdown heading and returns the version string, or
/// `None` if the heading does not look like a version entry.
///
/// Recognised formats:
/// - `## [1.2.0] - 2024-01-15`  (Keep a Changelog bracketed)
/// - `## [1.2.0]`               (bracketed, no date)
/// - `## 1.2.0 - 2024-01-15`   (unbracketed with date)
/// - `## 1.2.0`                 (unbracketed, no date)
fn parse_version_heading(line: &str) -> Option<String> {
    let rest = line.strip_prefix("## ")?;
    let version = if rest.starts_with('[') {
        let end = rest.find(']')?;
        &rest[1..end]
    } else {
        // Everything before an optional ` - <date>` suffix.
        rest.split(" - ").next()?.trim()
    };
    if version.is_empty() {
        None
    } else {
        Some(version.to_owned())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const SAMPLE: &str = "\
# Changelog

## [1.2.0] - 2024-03-01

### Added
- New feature

## [1.1.0] - 2024-01-15

### Fixed
- Bug fix

## 1.0.0

Initial release.
";

    #[test]
    fn latest_version_is_first_heading() {
        let cl = Changelog::parse(SAMPLE);
        assert_eq!(cl.latest_version(), Some("1.2.0"));
    }

    #[test]
    fn section_body_is_trimmed() {
        let cl = Changelog::parse(SAMPLE);
        let body = cl.section("1.2.0").unwrap();
        assert!(body.starts_with("### Added"));
        assert!(body.ends_with("- New feature"));
    }

    #[test]
    fn unbracketed_heading_is_parsed() {
        let cl = Changelog::parse(SAMPLE);
        assert!(cl.section("1.0.0").is_some());
    }

    #[test]
    fn unknown_version_returns_none() {
        let cl = Changelog::parse(SAMPLE);
        assert!(cl.section("9.9.9").is_none());
    }
}