use std::{collections::BTreeMap, fs, io, path::Path, sync::LazyLock};
use anyhow::{Context, Result, bail};
use regex::Regex;
use saphyr::{LoadableYamlNode, Yaml};
use crate::{bump::Bump, output::display_path};
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) -> String {
if self.in_pre {
format!("pre/{}", self.file_name)
} else {
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(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;
};
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
}
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"));
}
}