pub const WORKSPACE_MANIFESTS: &[&str] = &["Cargo.toml", "Cargo.lock"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChangelogScope {
pub dirs: Vec<String>,
pub narrow: Option<Vec<String>>,
}
impl ChangelogScope {
pub fn pathspecs(&self) -> &[String] {
&self.dirs
}
pub fn commit_survives_narrow(&self, touched_files: &[String]) -> bool {
let Some(ref globs) = self.narrow else {
return true;
};
touched_files
.iter()
.any(|f| globs.iter().any(|g| path_matches_glob(g, f)))
}
}
fn path_matches_glob(glob: &str, file: &str) -> bool {
let glob = glob.trim().trim_start_matches("./");
let file = file.trim().trim_start_matches("./");
if glob.is_empty() || glob == "." {
return true;
}
if let Ok(pat) = glob::Pattern::new(glob)
&& pat.matches(file)
{
return true;
}
file == glob || file.starts_with(&format!("{glob}/"))
}
fn normalize_dir(path: &str) -> Option<String> {
let trimmed = path.trim().trim_start_matches("./");
if trimmed.is_empty() || trimmed == "." {
None
} else {
Some(trimmed.to_string())
}
}
fn dir_covered_by(dir: &str, paths: &[String]) -> bool {
paths.iter().any(|p| {
let Some(p) = normalize_dir(p) else {
return true;
};
if glob_covers_dir(&p, dir) {
return true;
}
dir == p || dir.starts_with(&format!("{p}/"))
})
}
fn glob_covers_dir(p: &str, dir: &str) -> bool {
for suffix in ["/**", "/**/*", "/*", "**"] {
if let Some(base) = p.strip_suffix(suffix) {
let base = base.trim_end_matches('/');
if base.is_empty() {
return true;
}
if dir == base || dir.starts_with(&format!("{base}/")) {
return true;
}
}
}
false
}
pub fn resolve_changelog_scope(
crate_path: &str,
all_crate_dirs: &[String],
monorepo_dir: Option<&str>,
changelog_paths: &[String],
) -> ChangelogScope {
let is_sole_crate = match normalize_dir(crate_path) {
Some(ref dir) => {
let distinct: Vec<String> = all_crate_dirs
.iter()
.filter_map(|d| normalize_dir(d))
.collect();
distinct.len() == 1 && distinct.first() == Some(dir)
}
None => false,
};
let derived = match normalize_dir(crate_path) {
Some(dir) if !is_sole_crate => vec![dir],
_ => {
let mut dirs: Vec<String> = Vec::new();
for d in all_crate_dirs {
if let Some(d) = normalize_dir(d)
&& !dirs.contains(&d)
{
dirs.push(d);
}
}
if dirs.is_empty() {
match monorepo_dir.and_then(normalize_dir) {
Some(dir) => vec![dir],
None => Vec::new(),
}
} else {
for m in WORKSPACE_MANIFESTS {
dirs.push((*m).to_string());
}
dirs
}
}
};
let narrow = resolve_narrow(&derived, changelog_paths);
ChangelogScope {
dirs: derived,
narrow,
}
}
fn resolve_narrow(derived: &[String], changelog_paths: &[String]) -> Option<Vec<String>> {
let paths: Vec<String> = changelog_paths
.iter()
.filter(|p| !p.trim().is_empty())
.cloned()
.collect();
if paths.is_empty() {
return None;
}
if derived.is_empty() {
return Some(paths);
}
let superset = derived.iter().all(|d| dir_covered_by(d, &paths));
if superset { None } else { Some(paths) }
}
#[cfg(test)]
mod tests {
use super::*;
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn per_crate_scopes_to_its_own_dir() {
let scope =
resolve_changelog_scope("crates/core", &s(&["crates/core", "crates/cli"]), None, &[]);
assert_eq!(scope.dirs, s(&["crates/core"]));
assert_eq!(scope.narrow, None);
}
#[test]
fn per_crate_ignores_global_paths_when_superset() {
let scope = resolve_changelog_scope(
"crates/core",
&s(&["crates/core", "crates/cli"]),
None,
&s(&["crates/**", "Cargo.toml", "Cargo.lock"]),
);
assert_eq!(scope.dirs, s(&["crates/core"]));
assert_eq!(scope.narrow, None, "superset paths must not narrow");
}
#[test]
fn per_crate_narrows_when_paths_exclude_the_dir() {
let scope = resolve_changelog_scope(
"crates/core",
&s(&["crates/core", "crates/cli"]),
None,
&s(&["docs/**"]),
);
assert_eq!(scope.dirs, s(&["crates/core"]));
assert_eq!(scope.narrow, Some(s(&["docs/**"])));
}
#[test]
fn sole_crate_is_an_aggregate_with_manifests() {
let scope = resolve_changelog_scope("crates/app", &s(&["crates/app"]), None, &[]);
assert_eq!(scope.dirs, s(&["crates/app", "Cargo.toml", "Cargo.lock"]));
assert_eq!(scope.narrow, None);
}
#[test]
fn aggregate_unions_crate_dirs_plus_manifests() {
let scope = resolve_changelog_scope("", &s(&["crates/core", "crates/cli"]), None, &[]);
assert_eq!(
scope.dirs,
s(&["crates/core", "crates/cli", "Cargo.toml", "Cargo.lock"])
);
assert_eq!(scope.narrow, None);
}
#[test]
fn aggregate_dot_path_is_aggregate() {
let scope = resolve_changelog_scope(".", &s(&["crates/core"]), None, &[]);
assert_eq!(scope.dirs, s(&["crates/core", "Cargo.toml", "Cargo.lock"]));
}
#[test]
fn aggregate_no_crate_dirs_falls_back_to_monorepo_dir() {
let scope = resolve_changelog_scope("", &[], Some("packages/app"), &[]);
assert_eq!(scope.dirs, s(&["packages/app"]));
}
#[test]
fn aggregate_no_crate_dirs_no_monorepo_is_whole_repo() {
let scope = resolve_changelog_scope("", &[], None, &[]);
assert!(scope.dirs.is_empty(), "whole repo = empty pathspec");
assert_eq!(scope.narrow, None);
}
#[test]
fn aggregate_superset_paths_are_a_noop() {
let scope = resolve_changelog_scope(
"",
&s(&["crates/core", "crates/cli"]),
None,
&s(&["crates/**", "Cargo.toml", "Cargo.lock"]),
);
assert_eq!(
scope.dirs,
s(&["crates/core", "crates/cli", "Cargo.toml", "Cargo.lock"])
);
assert_eq!(
scope.narrow, None,
"crates/** + manifests ⊇ derived ⇒ no narrowing"
);
}
#[test]
fn aggregate_partial_paths_narrow() {
let scope = resolve_changelog_scope(
"",
&s(&["crates/core", "crates/cli"]),
None,
&s(&["crates/**"]),
);
assert_eq!(scope.narrow, Some(s(&["crates/**"])));
}
#[test]
fn whole_repo_aggregate_with_paths_narrows() {
let scope = resolve_changelog_scope("", &[], None, &s(&["src/**"]));
assert!(scope.dirs.is_empty());
assert_eq!(scope.narrow, Some(s(&["src/**"])));
}
#[test]
fn glob_covers_dir_handles_recursion_suffixes() {
assert!(glob_covers_dir("crates/**", "crates/core"));
assert!(glob_covers_dir("crates/*", "crates/core"));
assert!(glob_covers_dir("crates/**/*", "crates/core"));
assert!(glob_covers_dir("**", "anything/here"));
assert!(!glob_covers_dir("docs/**", "crates/core"));
}
#[test]
fn literal_prefix_covers_nested_dir() {
assert!(dir_covered_by("crates/core", &s(&["crates"])));
assert!(dir_covered_by("crates", &s(&["crates"])));
assert!(!dir_covered_by("cratesx", &s(&["crates"])));
}
#[test]
fn no_narrow_keeps_every_commit() {
let scope = ChangelogScope {
dirs: s(&["crates/core"]),
narrow: None,
};
assert!(scope.commit_survives_narrow(&s(&["anything/at/all.rs"])));
assert!(scope.commit_survives_narrow(&[]));
}
#[test]
fn narrow_keeps_commit_touching_a_matching_file() {
let scope = ChangelogScope {
dirs: Vec::new(),
narrow: Some(s(&["crates/**"])),
};
assert!(scope.commit_survives_narrow(&s(&["crates/core/src/lib.rs"])));
assert!(scope.commit_survives_narrow(&s(&["README.md", "crates/cli/main.rs"])));
assert!(!scope.commit_survives_narrow(&s(&["docs/guide.md"])));
assert!(!scope.commit_survives_narrow(&[]));
}
#[test]
fn path_matches_glob_supports_recursion_and_literal_prefix() {
assert!(path_matches_glob("crates/**", "crates/core/src/lib.rs"));
assert!(path_matches_glob("crates", "crates/core/src/lib.rs"));
assert!(path_matches_glob("Cargo.toml", "Cargo.toml"));
assert!(path_matches_glob("*.toml", "Cargo.toml"));
assert!(!path_matches_glob("crates/**", "docs/guide.md"));
assert!(path_matches_glob(".", "anything"));
}
}