use std::collections::BTreeSet;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Select};
use semver::Version;
use crate::cli::commands::VersionBumpCmd;
use crate::config::load_versioning_files_registry;
use super::{
auto_commit_command_paths, bump_version, enforce_version_change_guard, git_dirty_entries,
load_service_version_scopes, parse_git_status_path, record_version_change_guard,
resolve_current_version_for_bump, resolve_project_root, sync_cli_version_write_activity,
version_scope_prompt_label, write_version_to_configured_files_with_paths, VersionScope,
};
#[derive(Clone, Debug, PartialEq, Eq)]
enum BumpKind {
Patch,
Minor,
Major,
}
#[derive(Clone, Debug)]
struct BumpCandidate {
scope: VersionScope,
changed_paths: Vec<String>,
current_version: Version,
}
#[derive(Clone, Debug)]
struct BumpPlan {
scope: VersionScope,
current: Version,
next: Version,
kind: BumpKind,
#[allow(dead_code)]
changed_paths: Vec<String>,
}
const BUMP_ACTIONS: [&str; 6] = [
"Patch bump",
"Minor bump",
"Major bump",
"Skip",
"Skip remaining packages",
"Quit without bumping",
];
pub async fn run_version_bump_command(args: &VersionBumpCmd) -> Result<(), String> {
let invocation_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let project_root = resolve_project_root();
let registry = load_versioning_files_registry()?;
let dirty_paths = collect_dirty_normalized_paths(&project_root)?;
if dirty_paths.is_empty() {
return Err(
"Working tree is clean; `xbp version bump` only bumps packages with uncommitted changes."
.to_string(),
);
}
let candidates = build_bump_candidates(
&project_root,
&invocation_dir,
®istry,
&dirty_paths,
);
if candidates.is_empty() {
return Err(
"No versioned packages match the current uncommitted changes. Register services with \
`xbp version discover` or run from a directory with configured version targets."
.to_string(),
);
}
print_candidate_summary(&candidates);
let default_kind = resolve_default_bump_kind(args);
let plans = if args.all {
build_plans_for_all(&candidates, default_kind)
} else if !std::io::stdin().is_terminal() {
return Err(
"Interactive terminal required for `xbp version bump`. Use `--all` with `--patch`, \
`--minor`, or `--major` in CI/non-interactive environments."
.to_string(),
);
} else {
prompt_bump_plans(&candidates, default_kind)?
};
if plans.is_empty() {
println!("{}", "No packages selected for bump.".dimmed());
return Ok(());
}
if args.dry_run {
print_dry_run_plans(&plans);
return Ok(());
}
enforce_version_change_guard(&project_root)?;
let mut all_updated_paths = Vec::new();
let mut summary_lines = Vec::new();
for plan in &plans {
let updated_paths = write_version_to_configured_files_with_paths(
&project_root,
&invocation_dir,
®istry,
&plan.scope,
&plan.next,
)?;
let label = version_scope_prompt_label(&plan.scope);
summary_lines.push(format!(
"{} {} -> {}",
label, plan.current, plan.next
));
println!(
" {} {} {} -> {}",
"✓".bright_green(),
label.bright_white(),
plan.current.to_string().dimmed(),
plan.next.to_string().bright_green().bold()
);
all_updated_paths.extend(updated_paths);
sync_cli_version_write_activity(
&project_root,
&plan.scope,
&plan.next,
format!(
"Bumped {} from {} to {} via `xbp version bump`.",
label, plan.current, plan.next
),
)
.await;
}
let unique_paths = dedupe_paths(all_updated_paths);
let commit_message = if summary_lines.len() == 1 {
format!(
"chore(version): bump {} to {}",
version_scope_prompt_label(&plans[0].scope),
plans[0].next
)
} else {
format!(
"chore(version): bump {} mutated packages",
summary_lines.len()
)
};
auto_commit_command_paths(
&project_root,
unique_paths,
commit_message,
"xbp version bump",
)
.await;
record_version_change_guard(&project_root)?;
println!(
"\n{} Bumped {} package(s).",
"✓".bright_green().bold(),
plans.len()
);
Ok(())
}
fn resolve_default_bump_kind(args: &VersionBumpCmd) -> BumpKind {
if args.major {
BumpKind::Major
} else if args.minor {
BumpKind::Minor
} else {
BumpKind::Patch
}
}
fn build_plans_for_all(candidates: &[BumpCandidate], kind: BumpKind) -> Vec<BumpPlan> {
candidates
.iter()
.map(|candidate| BumpPlan {
scope: candidate.scope.clone(),
current: candidate.current_version.clone(),
next: bump_version_for_kind(&candidate.current_version, &kind),
kind: kind.clone(),
changed_paths: candidate.changed_paths.clone(),
})
.collect()
}
fn prompt_bump_plans(
candidates: &[BumpCandidate],
default_kind: BumpKind,
) -> Result<Vec<BumpPlan>, String> {
let mut plans = Vec::new();
let total = candidates.len();
for (index, candidate) in candidates.iter().enumerate() {
print_candidate_detail(index + 1, total, candidate, default_kind.clone());
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose bump action")
.items(&BUMP_ACTIONS)
.default(default_action_index(&default_kind))
.interact_opt()
.map_err(|error| format!("Prompt failed: {}", error))?;
let Some(selection) = selection else {
println!("{}", "Cancelled.".dimmed());
return Ok(Vec::new());
};
match selection {
0 => plans.push(build_plan(candidate, BumpKind::Patch)),
1 => plans.push(build_plan(candidate, BumpKind::Minor)),
2 => plans.push(build_plan(candidate, BumpKind::Major)),
3 => {}
4 => break,
_ => return Ok(Vec::new()),
}
}
Ok(plans)
}
fn default_action_index(kind: &BumpKind) -> usize {
match kind {
BumpKind::Patch => 0,
BumpKind::Minor => 1,
BumpKind::Major => 2,
}
}
fn build_plan(candidate: &BumpCandidate, kind: BumpKind) -> BumpPlan {
BumpPlan {
scope: candidate.scope.clone(),
current: candidate.current_version.clone(),
next: bump_version_for_kind(&candidate.current_version, &kind),
kind,
changed_paths: candidate.changed_paths.clone(),
}
}
fn bump_version_for_kind(current: &Version, kind: &BumpKind) -> Version {
match kind {
BumpKind::Patch => bump_version(current, "patch"),
BumpKind::Minor => bump_version(current, "minor"),
BumpKind::Major => bump_version(current, "major"),
}
}
fn collect_dirty_normalized_paths(project_root: &Path) -> Result<Vec<String>, String> {
let entries = git_dirty_entries(project_root)?;
let mut paths = entries
.iter()
.filter_map(|entry| parse_git_status_path(entry))
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
Ok(paths)
}
fn build_bump_candidates(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
dirty_paths: &[String],
) -> Vec<BumpCandidate> {
let nested_scopes = collect_nested_version_scopes(project_root, invocation_dir);
let mut candidates = Vec::new();
for scope in nested_scopes {
let changed_paths = dirty_paths
.iter()
.filter(|path| scope_matches_dirty_path(project_root, &scope, path))
.cloned()
.collect::<Vec<_>>();
if changed_paths.is_empty() {
continue;
}
let current_version = resolve_current_version_for_bump(
project_root,
invocation_dir,
registry,
&scope,
);
candidates.push(BumpCandidate {
scope,
changed_paths,
current_version,
});
}
let unscoped_paths = dirty_paths
.iter()
.filter(|path| {
!candidates
.iter()
.any(|candidate| scope_matches_dirty_path(project_root, &candidate.scope, path))
})
.cloned()
.collect::<Vec<_>>();
if !unscoped_paths.is_empty() {
let scope = VersionScope::Repository;
let current_version = resolve_current_version_for_bump(
project_root,
invocation_dir,
registry,
&scope,
);
candidates.push(BumpCandidate {
scope,
changed_paths: unscoped_paths,
current_version,
});
}
candidates.sort_by(|left, right| {
version_scope_prompt_label(&left.scope).cmp(&version_scope_prompt_label(&right.scope))
});
candidates
}
fn collect_nested_version_scopes(
project_root: &Path,
invocation_dir: &Path,
) -> Vec<VersionScope> {
let mut scopes = load_service_version_scopes(project_root, invocation_dir);
for crate_scope in load_crate_version_scopes(project_root) {
if !scopes.iter().any(|scope| scopes_share_root(scope, &crate_scope)) {
scopes.push(crate_scope);
}
}
scopes.sort_by(|left, right| {
version_scope_prompt_label(left).cmp(&version_scope_prompt_label(right))
});
scopes
}
fn load_crate_version_scopes(project_root: &Path) -> Vec<VersionScope> {
let crates_root = project_root.join("crates");
if !crates_root.is_dir() {
return Vec::new();
}
let mut scopes = Vec::new();
let Ok(entries) = fs::read_dir(&crates_root) else {
return scopes;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let cargo_toml = path.join("Cargo.toml");
let Ok(content) = fs::read_to_string(&cargo_toml) else {
continue;
};
let Ok(Some(package_name)) = super::cargo_package_name_from_content_optional(&content)
else {
continue;
};
let crate_relative_root = path
.strip_prefix(project_root)
.ok()
.map(super::normalized_relative_path)
.unwrap_or_else(|| {
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("crate")
.to_string()
});
scopes.push(VersionScope::Crate {
crate_root: path,
crate_relative_root,
package_name: package_name.clone(),
tag_prefix: format!("{}-", package_name),
});
}
scopes
}
fn scopes_share_root(left: &VersionScope, right: &VersionScope) -> bool {
match (super::version_scope_root(left), super::version_scope_root(right)) {
(Some(left_root), Some(right_root)) => left_root == right_root,
_ => false,
}
}
fn scope_matches_dirty_path(project_root: &Path, scope: &VersionScope, path: &str) -> bool {
match scope {
VersionScope::Repository => false,
VersionScope::Crate { crate_root, .. } => path_matches_root(project_root, crate_root, path),
VersionScope::Service {
service_root,
version_targets,
..
} => {
if path_matches_root(project_root, service_root, path) {
return true;
}
version_targets.iter().any(|target| path_matches_target(path, target))
}
}
}
fn path_matches_root(project_root: &Path, scope_root: &Path, path: &str) -> bool {
let relative_root = scope_root
.strip_prefix(project_root)
.ok()
.map(super::normalized_relative_path)
.unwrap_or_else(|| path.replace('\\', "/"));
path_matches_target(path, &relative_root)
}
fn path_matches_target(path: &str, target: &str) -> bool {
let normalized_target = target.replace('\\', "/");
if normalized_target.is_empty() || normalized_target == "." {
return true;
}
path == normalized_target || path.starts_with(&format!("{normalized_target}/"))
}
fn print_candidate_summary(candidates: &[BumpCandidate]) {
println!(
"\n{}",
format!(
"Found {} mutated package(s) in the working tree",
candidates.len()
)
.bright_cyan()
.bold()
);
println!("{}", "─".repeat(72).bright_black());
for candidate in candidates {
println!(
" {:<28} {} {} changed file(s)",
version_scope_prompt_label(&candidate.scope).bright_white(),
candidate.current_version.to_string().bright_green(),
candidate.changed_paths.len().to_string().bright_yellow()
);
}
}
fn print_candidate_detail(index: usize, total: usize, candidate: &BumpCandidate, default_kind: BumpKind) {
println!();
println!(
"{}",
format!(
"[{}/{}] {}",
index,
total,
version_scope_prompt_label(&candidate.scope)
)
.bright_cyan()
.bold()
);
println!(
" {:<18} {}",
"current version".bright_white(),
candidate.current_version.to_string().bright_green()
);
println!(
" {:<18} {}",
"default bump".bright_white(),
bump_kind_label(&default_kind).dimmed()
);
println!(" {}", "changed files".bright_white());
for path in candidate.changed_paths.iter().take(6) {
println!(" {} {}", "•".bright_black(), path);
}
if candidate.changed_paths.len() > 6 {
println!(
" {} … and {} more",
"•".bright_black(),
candidate.changed_paths.len() - 6
);
}
}
fn print_dry_run_plans(plans: &[BumpPlan]) {
println!();
println!("{}", "Dry run — no files written".bright_yellow().bold());
for plan in plans {
println!(
" {} {} {} -> {} ({})",
"•".bright_cyan(),
version_scope_prompt_label(&plan.scope).bright_white(),
plan.current,
plan.next.to_string().bright_green().bold(),
bump_kind_label(&plan.kind)
);
}
}
fn bump_kind_label(kind: &BumpKind) -> &'static str {
match kind {
BumpKind::Patch => "patch",
BumpKind::Minor => "minor",
BumpKind::Major => "major",
}
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = BTreeSet::new();
let mut unique = Vec::new();
for path in paths {
let key = path.to_string_lossy().replace('\\', "/");
if seen.insert(key) {
unique.push(path);
}
}
unique
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_test_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
std::env::temp_dir().join(format!("xbp-version-bump-{label}-{nanos}"))
}
#[test]
fn path_matches_target_handles_nested_paths() {
assert!(path_matches_target("apps/web/src/main.ts", "apps/web"));
assert!(!path_matches_target("apps/api/src/main.ts", "apps/web"));
assert!(path_matches_target("Cargo.toml", "."));
}
#[test]
fn scope_matches_service_root_and_targets() {
let project_root = PathBuf::from("/repo");
let scope = VersionScope::Service {
service_root: PathBuf::from("/repo/apps/web"),
service_relative_root: "apps/web".to_string(),
service_name: "web".to_string(),
tag_prefix: "web-".to_string(),
cargo_package_name: None,
version_targets: vec!["apps/web/package.json".to_string()],
};
assert!(scope_matches_dirty_path(
&project_root,
&scope,
"apps/web/src/routes/index.ts"
));
assert!(scope_matches_dirty_path(
&project_root,
&scope,
"apps/web/package.json"
));
assert!(!scope_matches_dirty_path(
&project_root,
&scope,
"apps/api/package.json"
));
}
#[test]
fn build_bump_candidates_groups_dirty_paths_by_scope() {
let project_root = temp_test_dir("grouping");
let _ = fs::remove_dir_all(&project_root);
fs::create_dir_all(project_root.join("crates/cli/src")).expect("crate dir");
fs::create_dir_all(project_root.join("apps/web/src")).expect("web dir");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir");
fs::write(
project_root.join("crates/cli/Cargo.toml"),
"[package]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
)
.expect("cargo toml");
fs::write(
project_root.join(".xbp/xbp.yaml"),
"project_name: test\nversion: 0.1.0\nport: 3000\nbuild_dir: ./\nservices:\n - name: web\n target: web\n branch: main\n port: 3000\n root_directory: apps/web\n version_targets:\n - apps/web/package.json\n",
)
.expect("xbp yaml");
fs::write(
project_root.join("apps/web/package.json"),
"{\"name\":\"web\",\"version\":\"0.2.0\"}",
)
.expect("package json");
let dirty_paths = vec![
"crates/cli/src/main.rs".to_string(),
"apps/web/src/app.ts".to_string(),
];
let registry = vec!["Cargo.toml".to_string(), "package.json".to_string()];
let candidates = build_bump_candidates(
&project_root,
&project_root,
®istry,
&dirty_paths,
);
assert_eq!(candidates.len(), 2);
assert!(candidates.iter().any(|candidate| {
matches!(candidate.scope, VersionScope::Crate { .. })
&& candidate.changed_paths == vec!["crates/cli/src/main.rs".to_string()]
}));
assert!(candidates.iter().any(|candidate| {
matches!(candidate.scope, VersionScope::Service { .. })
&& candidate.changed_paths == vec!["apps/web/src/app.ts".to_string()]
}));
}
}