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());
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,
}
}
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];
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 {
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);
}
}