changesette 6.3.0

A version and changelog manager using the changesets file format
use std::{
    collections::BTreeMap,
    fs, io,
    path::{Path, PathBuf},
    sync::LazyLock,
};

use anyhow::{Context, Result, bail};
use regex::Regex;
use saphyr::{LoadableYamlNode, Mapping, Scalar, Yaml, YamlEmitter};

use crate::bump::Bump;

const IGNORED_FILE_NAMES: [&str; 3] = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];

static FRONTMATTER: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?s)\s*---(.*?)\r?\n\s*---(\s*(?:\n|$).*)").unwrap());

#[derive(Clone, Debug)]
pub(crate) struct LoadedChange {
    pub(crate) file_name: String,
    pub(crate) in_pre: bool,
    pub(crate) releases: Vec<(String, Option<Bump>)>,
    pub(crate) summary: String,
}

impl LoadedChange {
    pub(crate) fn id(&self) -> String {
        let stem = self
            .file_name
            .strip_suffix(".md")
            .unwrap_or(&self.file_name);
        if self.in_pre {
            format!("pre/{stem}")
        } else {
            stem.to_owned()
        }
    }

    pub(crate) fn rel_path(&self) -> PathBuf {
        if self.in_pre {
            Path::new("pre").join(&self.file_name)
        } else {
            PathBuf::from(self.file_name.clone())
        }
    }
}

pub(crate) fn load(changeset_dir: &Path) -> Result<Vec<LoadedChange>> {
    let file_names = scan(changeset_dir)?.unwrap_or_default();
    let pre_dir = changeset_dir.join("pre");
    let pre_file_names = scan(&pre_dir)?.unwrap_or_default();

    file_names
        .iter()
        .map(|file_name| load_one(changeset_dir, file_name, false))
        .chain(
            pre_file_names
                .iter()
                .map(|file_name| load_one(&pre_dir, file_name, true)),
        )
        .collect()
}

fn scan(dir: &Path) -> Result<Option<Vec<String>>> {
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
        Err(err) => return Err(err).context(dir.display().to_string()),
    };

    let mut file_names = Vec::new();
    for entry in entries {
        let entry = entry.with_context(|| dir.display().to_string())?;
        let Ok(file_name) = entry.file_name().into_string() else {
            continue;
        };
        // Selecting entries by name alone means a symlink is followed and a
        // directory with an adopted name is a read error.
        #[expect(clippy::case_sensitive_file_extension_comparisons)]
        if file_name.starts_with('.')
            || !file_name.ends_with(".md")
            || file_name.eq_ignore_ascii_case("README.md")
            || IGNORED_FILE_NAMES.contains(&file_name.as_str())
        {
            continue;
        }
        file_names.push(file_name);
    }
    file_names.sort();
    Ok(Some(file_names))
}

pub(crate) fn max_bumps(changes: &[LoadedChange]) -> BTreeMap<&str, Option<Bump>> {
    let mut bumps = BTreeMap::new();
    for change in changes {
        for (name, bump) in &change.releases {
            let entry = bumps.entry(name.as_str()).or_insert(None);
            *entry = (*entry).max(*bump);
        }
    }
    bumps
}

pub(crate) fn render(releases: &[(String, Option<Bump>)], summary: &str) -> Result<String> {
    let summary = summary.trim();
    let mut content = if releases.is_empty() {
        String::from("---\n---\n")
    } else {
        let mut mapping = Mapping::new();
        for (name, bump) in releases {
            mapping.insert(
                Yaml::Value(Scalar::String(name.as_str().into())),
                Yaml::Value(Scalar::String(bump.map_or("none", Bump::as_str).into())),
            );
        }
        let mut frontmatter = String::new();
        YamlEmitter::new(&mut frontmatter).dump(&Yaml::Mapping(mapping))?;
        format!("{frontmatter}\n---\n")
    };
    if !summary.is_empty() {
        content.push('\n');
        content.push_str(summary);
        content.push('\n');
    }
    Ok(content)
}

fn load_one(dir: &Path, file_name: &str, in_pre: bool) -> Result<LoadedChange> {
    let file_path = dir.join(file_name);

    let content =
        fs::read_to_string(&file_path).with_context(|| file_path.display().to_string())?;
    let Some(captures) = FRONTMATTER.captures(&content) else {
        bail!(
            "{}: missing frontmatter (expected `---`-delimited YAML)",
            file_path.display()
        )
    };
    let frontmatter = &captures[1];
    let summary = captures[2].trim();

    let docs = match Yaml::load_from_str(frontmatter) {
        Ok(docs) => docs,
        Err(err) => bail!(
            "{}: invalid YAML in frontmatter: {err}",
            file_path.display()
        ),
    };

    let mut releases = Vec::new();
    match docs.into_iter().next() {
        None => {}
        Some(doc) if doc.is_null() => {}
        Some(Yaml::Mapping(mapping)) => {
            for (key, value) in &mapping {
                let Some(name) = key.as_str() else {
                    bail!(
                        "{}: invalid package name in frontmatter",
                        file_path.display()
                    )
                };
                let bump = match value.as_str() {
                    Some("major") => Some(Bump::Major),
                    Some("minor") => Some(Bump::Minor),
                    Some("patch") => Some(Bump::Patch),
                    Some("none") => None,
                    Some(other) => bail!(
                        "{}: unknown bump type {other:?}; expected major, minor, patch, or none",
                        file_path.display()
                    ),
                    None => bail!(
                        "{}: invalid bump type; expected major, minor, patch, or none",
                        file_path.display()
                    ),
                };
                releases.push((name.to_owned(), bump));
            }
        }
        Some(_) => bail!(
            "{}: frontmatter must be a mapping of package names to bump types",
            file_path.display()
        ),
    }

    Ok(LoadedChange {
        file_name: file_name.to_owned(),
        in_pre,
        releases,
        summary: summary.to_owned(),
    })
}

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

    fn fixture(case: &str) -> std::path::PathBuf {
        Path::new("tests/fixtures/changeset").join(case)
    }

    fn load_ok(case: &str) -> Vec<LoadedChange> {
        load(&fixture(case)).unwrap()
    }

    fn load_err(case: &str) -> String {
        format!("{:#}", load(&fixture(case)).unwrap_err()).replace('\\', "/")
    }

    #[test]
    fn treats_a_missing_directory_as_empty() {
        assert_eq!(load_ok("does-not-exist").len(), 0);
    }

    #[test]
    fn sorts_by_file_name_and_skips_ignored_files() {
        insta::assert_debug_snapshot!(load_ok("ordering"));
    }

    #[test]
    fn loads_pre_changesets_last_with_prefixed_ids() {
        let changes = load_ok("with-pre");
        insta::assert_debug_snapshot!(changes);
        assert_eq!(
            changes.iter().map(LoadedChange::id).collect::<Vec<_>>(),
            [
                "brave-lions-dance",
                "pre/atomic-pugs-smile",
                "pre/zany-moons-sing"
            ]
        );
        assert_eq!(changes[0].rel_path(), Path::new("brave-lions-dance.md"));
        assert_eq!(
            changes[1].rel_path(),
            Path::new("pre").join("atomic-pugs-smile.md")
        );
    }

    #[test]
    fn parses_a_file_written_by_the_upstream_cli() {
        insta::assert_debug_snapshot!(load_ok("upstream-generated"));
    }

    #[test]
    fn parses_a_multi_package_file_written_by_the_upstream_cli() {
        insta::assert_debug_snapshot!(load_ok("upstream-multi-package"));
    }

    #[test]
    fn parses_a_none_file_written_by_the_upstream_cli() {
        insta::assert_debug_snapshot!(load_ok("upstream-none"));
    }

    #[test]
    fn parses_an_empty_file_written_by_the_upstream_cli() {
        insta::assert_debug_snapshot!(load_ok("upstream-empty"));
    }

    #[test]
    fn parses_a_two_package_changeset() {
        insta::assert_debug_snapshot!(load_ok("two-packages"));
    }

    #[test]
    fn parses_an_empty_frontmatter_with_a_summary() {
        insta::assert_debug_snapshot!(load_ok("empty-with-summary"));
    }

    #[test]
    fn follows_symlinked_changesets() {
        insta::assert_debug_snapshot!(load_ok("symlink"));
    }

    #[test]
    fn rejects_a_directory_with_an_adopted_name() {
        let err = load_err("md-directory");
        assert!(err.contains("md-directory/nested.md: "), "{err}");
    }

    #[test]
    fn keeps_multi_line_summaries() {
        insta::assert_debug_snapshot!(load_ok("multi-line-body"));
    }

    #[test]
    fn parses_crlf_files() {
        insta::assert_debug_snapshot!(load_ok("crlf"));
    }

    #[test]
    fn parses_a_quoted_bump_type() {
        insta::assert_debug_snapshot!(load_ok("quoted-value"));
    }

    #[test]
    fn parses_frontmatter_with_comments_and_blank_lines() {
        insta::assert_debug_snapshot!(load_ok("comments-and-blank-lines"));
    }

    #[test]
    fn max_bumps_is_empty_without_changesets() {
        assert!(max_bumps(&[]).is_empty());
    }

    #[test]
    fn max_bumps_picks_the_highest_per_package() {
        let changes = [
            LoadedChange {
                file_name: "a.md".into(),
                in_pre: false,
                releases: vec![
                    ("one".into(), Some(Bump::Patch)),
                    ("two".into(), Some(Bump::Major)),
                ],
                summary: "a".into(),
            },
            LoadedChange {
                file_name: "b.md".into(),
                in_pre: false,
                releases: vec![("one".into(), Some(Bump::Minor)), ("three".into(), None)],
                summary: "b".into(),
            },
        ];
        assert_eq!(
            max_bumps(&changes).into_iter().collect::<Vec<_>>(),
            [
                ("one", Some(Bump::Minor)),
                ("three", None),
                ("two", Some(Bump::Major)),
            ]
        );
    }

    #[test]
    fn max_bumps_keeps_none_below_any_bump() {
        let changes = [
            LoadedChange {
                file_name: "a.md".into(),
                in_pre: false,
                releases: vec![("one".into(), None)],
                summary: "a".into(),
            },
            LoadedChange {
                file_name: "b.md".into(),
                in_pre: false,
                releases: vec![("one".into(), Some(Bump::Patch))],
                summary: "b".into(),
            },
        ];
        assert_eq!(
            max_bumps(&changes).into_iter().collect::<Vec<_>>(),
            [("one", Some(Bump::Patch))]
        );
    }

    #[test]
    fn parses_a_frontmatter_only_file_with_an_empty_summary() {
        insta::assert_debug_snapshot!(load_ok("frontmatter-only"));
    }

    #[test]
    fn rejects_a_custom_bump_type() {
        insta::assert_snapshot!(load_err("custom-type"));
    }

    #[test]
    fn rejects_a_file_without_frontmatter() {
        insta::assert_snapshot!(load_err("no-frontmatter"));
    }

    #[test]
    fn rejects_invalid_yaml_in_frontmatter() {
        insta::assert_snapshot!(load_err("invalid-yaml"));
    }

    #[test]
    fn rejects_a_non_mapping_frontmatter() {
        insta::assert_snapshot!(load_err("non-mapping"));
    }

    #[test]
    fn rejects_a_missing_bump_value() {
        insta::assert_snapshot!(load_err("null-bump"));
    }
}