use crate::strategies::{
parse_bump_kind_label, path_matches_association_pattern, VersionFileAssociation,
VersioningProjectConfig, XbpConfig,
};
use super::versioning_history::{score_from_history, BumpHistoryEvent};
use super::VersionScope;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum BumpKind {
Patch,
Minor,
Major,
Exact,
}
impl BumpKind {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Patch => "patch",
Self::Minor => "minor",
Self::Major => "major",
Self::Exact => "exact",
}
}
pub(crate) fn severity(&self) -> u8 {
match self {
Self::Patch => 1,
Self::Minor => 2,
Self::Major => 3,
Self::Exact => 0,
}
}
pub(crate) fn max(self, other: Self) -> Self {
if other.severity() > self.severity() {
other
} else {
self
}
}
pub(crate) fn from_label(value: &str) -> Option<Self> {
match parse_bump_kind_label(value)? {
"minor" => Some(Self::Minor),
"major" => Some(Self::Major),
"exact" | "set" | "force" => Some(Self::Exact),
_ => Some(Self::Patch),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct BumpSuggestion {
pub kind: BumpKind,
pub confidence: f32,
pub reasons: Vec<String>,
}
const PATCH_ONLY_EXTENSIONS: &[&str] = &[
"md", "mdx", "txt", "rst", "adoc", "asciidoc",
"json", "jsonc", "json5", "jsonl", "toml", "yaml", "yml", "ini", "cfg", "conf",
"csv", "tsv", "env", "properties", "plist",
"lock", "sum",
"html", "htm", "css", "scss", "sass", "less", "svg",
"editorconfig", "gitignore", "gitattributes", "npmrc", "nvmrc", "prettierignore",
"dockerignore", "eslintrc", "prettierrc",
];
const PATCH_ONLY_PREFIXES: &[&str] = &[
"docs/",
".xbp/",
".github/",
".vscode/",
".idea/",
".cursor/",
".codex/",
".agents/",
".grok/",
"changelog",
"changelogs/",
"license",
"licences/",
"licenses/",
];
const PATCH_ONLY_BASENAMES: &[&str] = &[
"readme",
"readme.md",
"readme.mdx",
"changelog",
"changelog.md",
"license",
"license.md",
"copying",
"authors",
"contributors",
"codeowners",
"security.md",
"contributing.md",
"cargo.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"bun.lockb",
"composer.lock",
"poetry.lock",
"gemfile.lock",
"flake.lock",
];
fn normalize_rel_path(path: &str) -> String {
path.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_ascii_lowercase()
}
fn path_extension(normalized: &str) -> Option<&str> {
let base = normalized.rsplit('/').next().unwrap_or(normalized);
let ext = base.rsplit_once('.')?.1;
if ext.is_empty() || ext.contains('/') {
None
} else {
Some(ext)
}
}
pub(crate) fn is_patch_only_path(path: &str) -> bool {
let normalized = normalize_rel_path(path);
if normalized.is_empty() {
return true;
}
let base = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
if PATCH_ONLY_BASENAMES
.iter()
.any(|name| base == *name || base.starts_with(&format!("{name}.")))
{
return true;
}
for prefix in PATCH_ONLY_PREFIXES {
let prefix = prefix.trim_end_matches('/');
if normalized == prefix
|| normalized.starts_with(&format!("{prefix}/"))
|| normalized.contains(&format!("/{prefix}/"))
{
if !prefix.contains('.') && !prefix.contains('/') && !prefix.starts_with('.') {
if normalized == *prefix
|| normalized.starts_with(&format!("{prefix}/"))
|| normalized.contains(&format!("/{prefix}/"))
{
return true;
}
} else if normalized == *prefix || normalized.starts_with(&format!("{prefix}/")) {
return true;
}
}
}
if normalized == "docs"
|| normalized.starts_with("docs/")
|| normalized.contains("/docs/")
|| normalized == ".xbp"
|| normalized.starts_with(".xbp/")
|| normalized.contains("/.xbp/")
{
return true;
}
if let Some(ext) = path_extension(&normalized) {
if PATCH_ONLY_EXTENSIONS.iter().any(|e| *e == ext) {
return true;
}
}
if base.starts_with('.') {
let rest = base.trim_start_matches('.');
if rest.is_empty() {
return true;
}
if rest == "env" || rest.starts_with("env.") {
return true;
}
if PATCH_ONLY_EXTENSIONS.iter().any(|e| rest == *e || rest.ends_with(&format!(".{e}"))) {
return true;
}
if matches!(
rest,
"gitignore"
| "gitattributes"
| "editorconfig"
| "npmrc"
| "nvmrc"
| "dockerignore"
| "prettierignore"
| "eslintignore"
) {
return true;
}
}
false
}
pub(crate) fn all_paths_patch_only(paths: &[String]) -> bool {
paths.is_empty() || paths.iter().all(|p| is_patch_only_path(p))
}
pub(crate) fn clamp_kind_for_paths(kind: BumpKind, paths: &[String]) -> BumpKind {
if matches!(kind, BumpKind::Exact) {
return kind;
}
if all_paths_patch_only(paths) && !matches!(kind, BumpKind::Patch) {
BumpKind::Patch
} else {
kind
}
}
pub(crate) fn rules_for_scope(
config: Option<&XbpConfig>,
scope: &VersionScope,
) -> (Option<BumpKind>, Vec<VersionFileAssociation>) {
let Some(config) = config else {
return (Some(BumpKind::Patch), Vec::new());
};
let mut rules = config
.versioning
.as_ref()
.map(|v| v.file_associations.clone())
.unwrap_or_default();
let default = config
.versioning
.as_ref()
.and_then(|v| v.default_bump.as_deref())
.and_then(BumpKind::from_label)
.or(Some(BumpKind::Patch));
if let VersionScope::Service { service_name, .. } = scope {
if let Some(services) = &config.services {
if let Some(service) = services.iter().find(|s| s.name == *service_name) {
let mut combined = service.file_associations.clone();
combined.extend(rules);
rules = combined;
}
}
}
let _ = VersioningProjectConfig::default();
(default, rules)
}
pub(crate) fn match_associations(
paths: &[String],
rules: &[VersionFileAssociation],
) -> Option<(BumpKind, Vec<String>)> {
let mut best: Option<BumpKind> = None;
let mut reasons = Vec::new();
for path in paths {
for rule in rules {
if !path_matches_association_pattern(&rule.pattern, path) {
continue;
}
let Some(mut kind) = BumpKind::from_label(&rule.bump) else {
continue;
};
if is_patch_only_path(path) && !matches!(kind, BumpKind::Exact) {
if kind != BumpKind::Patch {
reasons.push(format!(
"{path} matches `{}` → {} (clamped to patch: docs/config path)",
rule.pattern,
kind.as_str()
));
} else {
reasons.push(format!(
"{path} matches `{}` → {}",
rule.pattern,
kind.as_str()
));
}
kind = BumpKind::Patch;
} else {
reasons.push(format!(
"{path} matches `{}` → {}",
rule.pattern,
kind.as_str()
));
}
best = Some(match best {
Some(current) => current.max(kind),
None => kind,
});
}
}
best.map(|kind| (kind, reasons))
}
fn heuristic_suggestion(paths: &[String], commits_since: Option<usize>) -> BumpSuggestion {
if paths.is_empty() {
return BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.35,
reasons: vec!["no changed paths; default patch".into()],
};
}
if all_paths_patch_only(paths) {
return BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.85,
reasons: vec![
"all changes are docs/config/metadata (extensions or docs/.xbp paths) → patch only"
.into(),
],
};
}
let only_locks = paths.iter().all(|p| {
let name = p.rsplit('/').next().unwrap_or(p);
matches!(
name,
"Cargo.lock" | "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" | "bun.lockb"
)
});
if only_locks {
return BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.65,
reasons: vec!["lockfile-only changes → patch".into()],
};
}
if commits_since.unwrap_or(0) >= 15 {
return BumpSuggestion {
kind: BumpKind::Minor,
confidence: 0.45,
reasons: vec![format!(
"{} commits since baseline → lean minor",
commits_since.unwrap_or(0)
)],
};
}
BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.4,
reasons: vec!["default heuristic → patch".into()],
}
}
fn patch_only_cap_reason(paths: &[String]) -> String {
let sample: Vec<&str> = paths.iter().take(4).map(String::as_str).collect();
let more = if paths.len() > 4 {
format!(" (+{} more)", paths.len() - 4)
} else {
String::new()
};
format!(
"patch-only change set ({}){} — minor/major not allowed for docs/config extensions and docs/.xbp paths",
sample.join(", "),
more
)
}
pub(crate) fn suggest_bump_kind(
paths: &[String],
rules: &[VersionFileAssociation],
default_kind: Option<BumpKind>,
history: &[BumpHistoryEvent],
scope_label: &str,
commits_since: Option<usize>,
cli_default: Option<BumpKind>,
) -> BumpSuggestion {
if all_paths_patch_only(paths) && !paths.is_empty() {
let mut reasons = vec![patch_only_cap_reason(paths)];
if let Some((_, assoc_reasons)) = match_associations(paths, rules) {
reasons.extend(assoc_reasons);
}
return BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.95,
reasons,
};
}
if let Some((kind, reasons)) = match_associations(paths, rules) {
let kind = clamp_kind_for_paths(kind, paths);
return BumpSuggestion {
kind,
confidence: 0.9,
reasons,
};
}
if let Some((kind, conf)) = score_from_history(history, scope_label, paths) {
let kind = clamp_kind_for_paths(kind, paths);
let mut reasons = vec![format!(
"learned from history ({:.0}% confidence)",
conf * 100.0
)];
if all_paths_patch_only(paths) {
reasons.push(patch_only_cap_reason(paths));
}
return BumpSuggestion {
kind,
confidence: conf.clamp(0.35, 0.85),
reasons,
};
}
if let Some(kind) = cli_default {
let clamped = clamp_kind_for_paths(kind, paths);
let mut reasons = vec![format!("CLI default --{}", kind.as_str())];
if clamped != kind {
reasons.push(patch_only_cap_reason(paths));
}
return BumpSuggestion {
kind: clamped,
confidence: 0.55,
reasons,
};
}
if let Some(kind) = default_kind {
if kind != BumpKind::Patch || paths.is_empty() {
let clamped = clamp_kind_for_paths(kind, paths);
let mut reasons = vec![format!("config default_bump = {}", kind.as_str())];
if clamped != kind {
reasons.push(patch_only_cap_reason(paths));
}
return BumpSuggestion {
kind: clamped,
confidence: 0.5,
reasons,
};
}
}
heuristic_suggestion(paths, commits_since)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn association_md_is_patch() {
let rules = vec![VersionFileAssociation {
pattern: "**/*.md".into(),
bump: "patch".into(),
}];
let (kind, _) =
match_associations(&["docs/guide.md".into()], &rules).expect("match");
assert_eq!(kind, BumpKind::Patch);
}
#[test]
fn association_takes_max_severity() {
let rules = vec![
VersionFileAssociation {
pattern: "**/*.md".into(),
bump: "patch".into(),
},
VersionFileAssociation {
pattern: "crates/**".into(),
bump: "minor".into(),
},
];
let (kind, _) = match_associations(
&["docs/a.md".into(), "crates/cli/src/lib.rs".into()],
&rules,
)
.expect("match");
assert_eq!(kind, BumpKind::Minor);
}
#[test]
fn patch_only_path_clamps_association_severity() {
let rules = vec![VersionFileAssociation {
pattern: "**/*".into(),
bump: "major".into(),
}];
let (kind, reasons) =
match_associations(&["docs/guide.md".into(), "config.yaml".into()], &rules)
.expect("match");
assert_eq!(kind, BumpKind::Patch);
assert!(reasons.iter().any(|r| r.contains("clamped")));
}
#[test]
fn brace_and_basename_patterns() {
let rules = vec![
VersionFileAssociation {
pattern: "**/*.{md,mdx}".into(),
bump: "patch".into(),
},
VersionFileAssociation {
pattern: "README.md".into(),
bump: "patch".into(),
},
];
assert!(match_associations(&["apps/x.mdx".into()], &rules).is_some());
assert!(match_associations(&["packages/foo/README.md".into()], &rules).is_some());
}
#[test]
fn classifies_patch_only_extensions_and_prefixes() {
for path in [
"docs/guide.md",
"apps/docs/page.mdx",
".xbp/xbp.yaml",
"packages/foo/.xbp/config.toml",
"Cargo.toml",
"package.json",
"data/events.jsonl",
"compose.yml",
"config.yaml",
"notes.txt",
"README.md",
".github/workflows/ci.yml",
".env",
".env.local",
"pnpm-lock.yaml",
] {
assert!(
is_patch_only_path(path),
"expected patch-only: {path}"
);
}
for path in [
"crates/cli/src/lib.rs",
"packages/web/src/index.ts",
"apps/api/main.go",
"src/main.py",
"Dockerfile",
] {
assert!(
!is_patch_only_path(path),
"expected code path (not patch-only): {path}"
);
}
}
#[test]
fn suggest_forces_patch_when_all_paths_are_patch_only() {
let paths = vec![
"docs/runbook.md".into(),
".xbp/xbp.yaml".into(),
"package.json".into(),
"Cargo.toml".into(),
];
let suggestion = suggest_bump_kind(
&paths,
&[],
Some(BumpKind::Minor),
&[],
"repo",
Some(100),
Some(BumpKind::Major),
);
assert_eq!(suggestion.kind, BumpKind::Patch);
assert!(
suggestion.reasons.iter().any(|r| r.contains("patch-only")),
"{:?}",
suggestion.reasons
);
}
#[test]
fn suggest_allows_minor_when_code_paths_present() {
let rules = vec![VersionFileAssociation {
pattern: "crates/**".into(),
bump: "minor".into(),
}];
let paths = vec![
"docs/note.md".into(),
"crates/cli/src/main.rs".into(),
];
let suggestion = suggest_bump_kind(&paths, &rules, None, &[], "cli", None, None);
assert_eq!(suggestion.kind, BumpKind::Minor);
}
#[test]
fn clamp_kind_for_paths_respects_exact() {
assert_eq!(
clamp_kind_for_paths(BumpKind::Exact, &["docs/a.md".into()]),
BumpKind::Exact
);
assert_eq!(
clamp_kind_for_paths(BumpKind::Major, &["docs/a.md".into()]),
BumpKind::Patch
);
}
}