use std::{fs, io, path::Path, sync::LazyLock};
use anyhow::{Context, Result, bail, ensure};
use regex::Regex;
use saphyr::{LoadableYamlNode, Yaml};
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(Debug)]
pub(crate) struct LoadedChange {
pub(crate) file_name: String,
pub(crate) bump: Bump,
pub(crate) summary: String,
}
pub(crate) fn load(changeset_dir: &Path, package_name: &str) -> Result<Vec<LoadedChange>> {
let entries = match fs::read_dir(changeset_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == io::ErrorKind::NotFound => bail!(
"{}: changeset directory not found; run `changesette init` to create it",
changeset_dir.display()
),
Err(err) => return Err(err).context(changeset_dir.display().to_string()),
};
let mut file_names = Vec::new();
for entry in entries {
let entry = entry.with_context(|| changeset_dir.display().to_string())?;
if !entry
.file_type()
.with_context(|| entry.path().display().to_string())?
.is_file()
{
continue;
}
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();
file_names
.iter()
.map(|file_name| load_one(changeset_dir, file_name, package_name))
.collect()
}
pub(crate) fn max_bump(changes: &[LoadedChange]) -> Option<Bump> {
changes.iter().map(|change| change.bump).max()
}
fn load_one(changeset_dir: &Path, file_name: &str, package_name: &str) -> Result<LoadedChange> {
let file_path = changeset_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 bump = None;
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()
)
};
ensure!(
name == package_name,
"{}: changeset targets package `{name}`, but the manifest declares `{package_name}`",
file_path.display()
);
let entry_bump = match value.as_str() {
Some("major") => Bump::Major,
Some("minor") => Bump::Minor,
Some("patch") => Bump::Patch,
Some(other) => bail!(
"{}: unknown bump type `{other}`; expected major, minor, or patch",
file_path.display()
),
None => bail!(
"{}: invalid bump type; expected major, minor, or patch",
file_path.display()
),
};
bump = bump.max(Some(entry_bump));
}
}
Some(_) => bail!(
"{}: frontmatter must be a mapping of package names to bump types",
file_path.display()
),
}
let Some(bump) = bump else {
bail!(
"{}: empty changeset (no bump specified)",
file_path.display()
)
};
ensure!(
!summary.is_empty(),
"{}: empty changeset (the summary is empty)",
file_path.display()
);
Ok(LoadedChange {
file_name: file_name.to_owned(),
bump,
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), "ublacklist").unwrap()
}
fn load_err(case: &str) -> String {
format!("{:#}", load(&fixture(case), "ublacklist").unwrap_err())
}
#[test]
fn rejects_a_missing_directory() {
insta::assert_snapshot!(load_err("does-not-exist"));
}
#[test]
fn sorts_by_file_name_and_skips_ignored_files() {
insta::assert_debug_snapshot!(load_ok("ordering"));
}
#[test]
fn parses_a_file_written_by_the_upstream_cli() {
insta::assert_debug_snapshot!(load_ok("upstream-generated"));
}
#[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_bump_is_none_without_changesets() {
assert_eq!(max_bump(&[]), None);
}
#[test]
fn max_bump_picks_the_highest() {
assert_eq!(max_bump(&load_ok("ordering")), Some(Bump::Major));
}
#[test]
fn rejects_a_changeset_that_also_targets_another_package() {
insta::assert_snapshot!(load_err("two-packages"));
}
#[test]
fn rejects_a_mismatched_package_name() {
insta::assert_snapshot!(load_err("name-mismatch"));
}
#[test]
fn rejects_a_frontmatter_only_file() {
insta::assert_snapshot!(load_err("frontmatter-only"));
}
#[test]
fn rejects_an_empty_frontmatter() {
insta::assert_snapshot!(load_err("no-bump"));
}
#[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"));
}
}