xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Append-only bump learning store under `.xbp/versioning/`.

use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use super::versioning_suggest::BumpKind;

const HISTORY_REL: &str = ".xbp/versioning/history.jsonl";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct BumpHistoryEvent {
    pub ts: String,
    pub scope: String,
    pub bump: String,
    pub from: String,
    pub to: String,
    pub mode: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub baseline: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub commits_since: Option<usize>,
    #[serde(default)]
    pub paths: Vec<String>,
    #[serde(default)]
    pub extensions: Vec<String>,
    #[serde(default)]
    pub path_prefixes: Vec<String>,
    #[serde(default)]
    pub filenames: Vec<String>,
}

pub(crate) fn versioning_history_path(project_root: &Path) -> PathBuf {
    project_root.join(HISTORY_REL)
}

pub(crate) fn features_from_paths(paths: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
    let mut extensions = Vec::new();
    let mut prefixes = Vec::new();
    let mut filenames = Vec::new();
    for path in paths {
        let normalized = path.replace('\\', "/");
        let name = normalized.rsplit('/').next().unwrap_or(normalized.as_str());
        filenames.push(name.to_string());
        if let Some((_, ext)) = name.rsplit_once('.') {
            if !ext.is_empty() && !ext.contains('/') {
                extensions.push(format!(".{ext}").to_ascii_lowercase());
            }
        }
        if let Some((parent, _)) = normalized.rsplit_once('/') {
            if !parent.is_empty() {
                prefixes.push(parent.to_string());
                // also top two segments for broader learning
                let parts: Vec<_> = parent.split('/').filter(|p| !p.is_empty()).collect();
                if parts.len() >= 2 {
                    prefixes.push(parts[..2].join("/"));
                }
                if let Some(first) = parts.first() {
                    prefixes.push((*first).to_string());
                }
            }
        }
    }
    extensions.sort();
    extensions.dedup();
    prefixes.sort();
    prefixes.dedup();
    filenames.sort();
    filenames.dedup();
    (extensions, prefixes, filenames)
}

pub(crate) fn append_bump_history(
    project_root: &Path,
    event: &BumpHistoryEvent,
) -> Result<(), String> {
    let path = versioning_history_path(project_root);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&path)
        .map_err(|e| format!("Failed to open {}: {e}", path.display()))?;
    let line = serde_json::to_string(event)
        .map_err(|e| format!("Failed to serialize bump history: {e}"))?;
    writeln!(file, "{line}").map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
    Ok(())
}

pub(crate) fn load_bump_history(project_root: &Path) -> Vec<BumpHistoryEvent> {
    let path = versioning_history_path(project_root);
    let Ok(content) = fs::read_to_string(path) else {
        return Vec::new();
    };
    content
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .filter_map(|line| serde_json::from_str::<BumpHistoryEvent>(line).ok())
        .collect()
}

pub(crate) fn build_history_event(
    scope_label: &str,
    kind: &BumpKind,
    from: &str,
    to: &str,
    mode: &str,
    baseline: Option<&str>,
    commits_since: Option<usize>,
    paths: &[String],
) -> BumpHistoryEvent {
    let (extensions, path_prefixes, filenames) = features_from_paths(paths);
    BumpHistoryEvent {
        ts: Utc::now().to_rfc3339(),
        scope: scope_label.to_string(),
        bump: kind.as_str().to_string(),
        from: from.to_string(),
        to: to.to_string(),
        mode: mode.to_string(),
        baseline: baseline.map(str::to_string),
        commits_since,
        paths: paths.to_vec(),
        extensions,
        path_prefixes,
        filenames,
    }
}

/// Score patch/minor/major from co-occurrence of path features with past bumps.
/// Returns (kind, confidence 0.0–1.0) or None when history is empty.
pub(crate) fn score_from_history(
    history: &[BumpHistoryEvent],
    scope_label: &str,
    paths: &[String],
) -> Option<(BumpKind, f32)> {
    if history.is_empty() || paths.is_empty() {
        return None;
    }
    let (exts, prefixes, filenames) = features_from_paths(paths);
    let mut scores = [1.0f32, 1.0f32, 1.0f32]; // laplace: patch, minor, major

    for event in history {
        let kind_idx = match event.bump.as_str() {
            "minor" => 1,
            "major" => 2,
            _ => 0,
        };
        let mut weight = 0.25f32;
        if event.scope == scope_label {
            weight += 0.75;
        }
        for ext in &exts {
            if event.extensions.iter().any(|e| e == ext) {
                weight += 0.5;
            }
        }
        for prefix in &prefixes {
            if event.path_prefixes.iter().any(|p| p == prefix || prefix.starts_with(p) || p.starts_with(prefix)) {
                weight += 0.35;
            }
        }
        for name in &filenames {
            if event.filenames.iter().any(|f| f == name) {
                weight += 0.8;
            }
        }
        if weight <= 0.25 {
            continue;
        }
        scores[kind_idx] += weight;
    }

    let total: f32 = scores.iter().sum();
    if total <= 3.0 {
        // only laplace priors
        return None;
    }
    let (idx, best) = scores
        .iter()
        .enumerate()
        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
        .unwrap();
    let kind = match idx {
        1 => BumpKind::Minor,
        2 => BumpKind::Major,
        _ => BumpKind::Patch,
    };
    Some((kind, best / total))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn features_extract_extensions_and_prefixes() {
        let (exts, prefixes, names) = features_from_paths(&[
            "docs/guide.md".into(),
            "apps/web/src/app.ts".into(),
        ]);
        assert!(exts.contains(&".md".into()));
        assert!(exts.contains(&".ts".into()));
        assert!(names.contains(&"guide.md".into()));
        assert!(prefixes.iter().any(|p| p == "docs" || p.starts_with("docs")));
    }

    #[test]
    fn history_scores_prefer_matching_extension() {
        let history = vec![
            BumpHistoryEvent {
                ts: "t".into(),
                scope: "service:web".into(),
                bump: "patch".into(),
                from: "1.0.0".into(),
                to: "1.0.1".into(),
                mode: "dirty".into(),
                baseline: None,
                commits_since: None,
                paths: vec!["docs/a.md".into()],
                extensions: vec![".md".into()],
                path_prefixes: vec!["docs".into()],
                filenames: vec!["a.md".into()],
            },
            BumpHistoryEvent {
                ts: "t2".into(),
                scope: "service:web".into(),
                bump: "patch".into(),
                from: "1.0.1".into(),
                to: "1.0.2".into(),
                mode: "dirty".into(),
                baseline: None,
                commits_since: None,
                paths: vec!["README.md".into()],
                extensions: vec![".md".into()],
                path_prefixes: vec![],
                filenames: vec!["README.md".into()],
            },
        ];
        let (kind, conf) = score_from_history(
            &history,
            "service:web",
            &["docs/new.md".into()],
        )
        .expect("score");
        assert_eq!(kind, BumpKind::Patch);
        assert!(conf > 0.4);
    }
}