xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Suggest bump kinds from file associations, history, and heuristics.

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) {
                // Service rules take precedence: place them first so matching
                // uses max severity across all, but service-only patterns still apply.
                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()],
    }
}

/// Suggest a bump kind. Config associations hard-override history when they match.
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());
    }
}