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,
}
impl BumpKind {
pub(crate) fn as_str(&self) -> &'static str {
match self {
Self::Patch => "patch",
Self::Minor => "minor",
Self::Major => "major",
}
}
pub(crate) fn severity(&self) -> u8 {
match self {
Self::Patch => 1,
Self::Minor => 2,
Self::Major => 3,
}
}
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),
_ => Some(Self::Patch),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct BumpSuggestion {
pub kind: BumpKind,
pub confidence: f32,
pub reasons: Vec<String>,
}
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(kind) = BumpKind::from_label(&rule.bump) else {
continue;
};
reasons.push(format!(
"{} matches `{}` → {}",
path,
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()],
};
}
let all_docs = paths.iter().all(|p| {
let lower = p.to_ascii_lowercase();
lower.ends_with(".md")
|| lower.ends_with(".mdx")
|| lower.ends_with(".txt")
|| lower.contains("/docs/")
|| lower.starts_with("docs/")
|| lower.ends_with("readme.md")
|| lower.ends_with("changelog.md")
});
if all_docs {
return BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.7,
reasons: vec!["all changes look documentation-related → patch".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()],
}
}
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 let Some((kind, reasons)) = match_associations(paths, rules) {
return BumpSuggestion {
kind,
confidence: 0.9,
reasons,
};
}
if let Some((kind, conf)) = score_from_history(history, scope_label, paths) {
return BumpSuggestion {
kind,
confidence: conf.clamp(0.35, 0.85),
reasons: vec![format!(
"learned from history ({:.0}% confidence)",
conf * 100.0
)],
};
}
if let Some(kind) = cli_default {
return BumpSuggestion {
kind,
confidence: 0.55,
reasons: vec![format!("CLI default --{}", kind.as_str())],
};
}
if let Some(kind) = default_kind {
if kind != BumpKind::Patch || paths.is_empty() {
return BumpSuggestion {
kind,
confidence: 0.5,
reasons: vec![format!("config default_bump = {}", kind.as_str())],
};
}
}
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 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());
}
}