changesette 6.1.0

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

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

use crate::{bump::Bump, output::display_path};

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

// A port of the upstream `@changesets/parse` regex; capture 1 is the
// frontmatter YAML and capture 2 is the summary.
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,
    /// Whether the file was loaded from `pre/` in the changeset directory.
    pub(crate) in_pre: bool,
    /// The packages named in the frontmatter, in order, each with its
    /// requested bump (`None` for the `none` type); empty for an empty
    /// changeset.
    pub(crate) releases: Vec<(String, Option<Bump>)>,
    /// The summary text below the frontmatter, trimmed; may be empty.
    pub(crate) summary: String,
}

impl LoadedChange {
    /// The file name without its `.md` suffix, prefixed with `pre/` for a
    /// `pre/` changeset.
    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()
        }
    }

    /// The path of the file relative to the changeset directory,
    /// `/`-separated.
    pub(crate) fn rel_path(&self) -> String {
        if self.in_pre {
            format!("pre/{}", self.file_name)
        } else {
            self.file_name.clone()
        }
    }
}

/// Loads every changeset in `changeset_dir`, the ones directly in it first
/// and then the ones in its `pre/` subdirectory as in the upstream reader,
/// each group in file-name order, treating a missing directory as empty and
/// leaving package-name validation to the callers.
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()
}

// Returns the names of the changeset files directly in `dir`, sorted, or
// `Ok(None)` if `dir` does not exist.
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(display_path(dir)),
    };

    let mut file_names = Vec::new();
    for entry in entries {
        let entry = entry.with_context(|| display_path(dir))?;
        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.
        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))
}

/// Groups `changes` by package name: each named package maps to the widest
/// bump requested for it, or `None` when it is only ever named with the
/// `none` type.
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
}

/// Renders the canonical changeset file content for `releases` and
/// `summary`, emitting a `None` bump as the `none` type and trimming the
/// summary.
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(|| display_path(&file_path))?;
    let Some(captures) = FRONTMATTER.captures(&content) else {
        bail!(
            "{}: missing frontmatter (expected `---`-delimited YAML)",
            display_path(&file_path)
        )
    };
    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}",
            display_path(&file_path)
        ),
    };

    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",
                        display_path(&file_path)
                    )
                };
                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",
                        display_path(&file_path)
                    ),
                    None => bail!(
                        "{}: invalid bump type; expected major, minor, patch, or none",
                        display_path(&file_path)
                    ),
                };
                releases.push((name.to_owned(), bump));
            }
        }
        Some(_) => bail!(
            "{}: frontmatter must be a mapping of package names to bump types",
            display_path(&file_path)
        ),
    }

    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())
    }

    #[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);
        insta::assert_debug_snapshot!(
            changes
                .iter()
                .map(|change| (change.id(), change.rel_path()))
                .collect::<Vec<_>>()
        );
    }

    #[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"));
    }
}