use std::collections::BTreeSet;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use colored::Colorize;
use semver::Version;
use crate::cli::commands::VersionBumpCmd;
use crate::cli::interactive::{searchable_multi_select, searchable_select};
use crate::config::load_versioning_files_registry;
use crate::strategies::XbpConfig;
use crate::utils::{find_xbp_config_upwards, write_xbp_project_config_at_path};
use dialoguer::{theme::ColorfulTheme, Input};
use super::versioning_history::{
append_bump_history, build_history_event, load_bump_history, BumpHistoryEvent,
};
use super::versioning_suggest::{
all_paths_patch_only, clamp_kind_for_paths, rules_for_scope, suggest_bump_kind, BumpKind,
BumpSuggestion,
};
use super::{
assign_dirty_paths_to_scopes, auto_commit_command_paths, auto_commit_command_paths_result,
bump_version, clear_version_change_guards_for_scopes, collect_changed_files_since_reference,
git_dirty_entries, git_tag_distance_from_head, load_service_version_scopes,
parse_git_status_path, record_version_change_guard_after_write, resolve_current_version_for_bump,
resolve_project_root, run_version_release_command, scope_matches_changed_path,
sync_cli_version_write_activity, version_scope_prompt_label,
write_version_to_configured_files_with_paths, ReleaseLatestPolicy, VersionReleaseOptions,
VersionScope,
};
use crate::cli::auto_commit::AutoCommitResult;
#[derive(Clone, Debug)]
struct BumpCandidate {
scope: VersionScope,
changed_paths: Vec<String>,
current_version: Version,
baseline_source: Option<String>,
baseline_ref: Option<String>,
commits_since: Option<usize>,
suggestion: BumpSuggestion,
mode: BumpMode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BumpMode {
DirtyTree,
SinceRelease,
ManualOffer,
}
#[derive(Clone, Debug)]
struct BumpPlan {
scope: VersionScope,
current: Version,
next: Version,
kind: BumpKind,
changed_paths: Vec<String>,
baseline_ref: Option<String>,
commits_since: Option<usize>,
mode: BumpMode,
suggestion_reasons: Vec<String>,
force: bool,
}
const BUMP_ACTIONS: [&str; 8] = [
"Patch bump",
"Minor bump",
"Major bump",
"Set exact version (force override)…",
"Disable versioning for this service",
"Skip",
"Skip remaining packages",
"Quit without bumping",
];
const BUMP_ACTIONS_PATCH_ONLY: [&str; 6] = [
"Patch bump (docs/config change set — minor/major not allowed)",
"Set exact version (force override)…",
"Disable versioning for this service",
"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 project_config = load_project_config(&project_root);
let history = load_bump_history(&project_root);
let preview_only = args.dry_run || args.plan;
let cli_default = resolve_cli_default_kind(args);
let dirty_paths = collect_dirty_normalized_paths(&project_root)?;
let (mut candidates, from_dirty_tree) = if dirty_paths.is_empty() {
let candidates = build_bump_candidates_since_release_with_options(
&project_root,
&invocation_dir,
®istry,
project_config.as_ref(),
&history,
cli_default.clone(),
args.include_unchanged,
args.refresh_tags,
)?;
(candidates, false)
} else {
let candidates = build_bump_candidates_dirty(
&project_root,
&invocation_dir,
®istry,
&dirty_paths,
project_config.as_ref(),
&history,
cli_default.clone(),
);
(candidates, true)
};
if candidates.is_empty() {
return Err(
"No versioned packages found to bump. Register services with `xbp version discover`, \
make changes under a watched path, or use `--include-unchanged` to offer packages \
that already have a release tag but no commits since then. Never-released packages \
are offered automatically on a clean tree."
.to_string(),
);
}
prefer_invocation_scope(&mut candidates, &invocation_dir, &project_root);
if from_dirty_tree {
print_candidate_summary(&candidates, BumpMode::DirtyTree);
} else {
print_candidate_summary(&candidates, BumpMode::SinceRelease);
}
if let Some(raw_set) = args.set.as_deref() {
let target = parse_explicit_version(raw_set)?;
let candidate = candidates
.first()
.ok_or_else(|| "No versioned package found for --set.".to_string())?;
let plan = build_exact_plan(candidate, target);
let plans = vec![plan];
if args.plan {
print_rich_plan(&plans, from_dirty_tree);
return Ok(());
}
if preview_only {
print_dry_run_plans(&plans);
return Ok(());
}
let bumped =
apply_bump_plans(&project_root, &invocation_dir, ®istry, &plans, true).await?;
maybe_chain_releases(args, &project_root, &bumped).await?;
return Ok(());
}
if let Some(service) = args.disable_versioning.as_deref() {
if preview_only {
println!(
"Would disable versioning for service `{}` (dry-run).",
service
);
return Ok(());
}
disable_versioning_for_service_name(&project_root, service).await?;
return Ok(());
}
let prompt_result = if args.all {
let kind = cli_default.unwrap_or(BumpKind::Patch);
BumpPromptResult {
plans: build_plans_for_all_clamped(&candidates, kind),
disable_scopes: Vec::new(),
}
} else if args.auto {
BumpPromptResult {
plans: build_plans_from_suggestions(&candidates),
disable_scopes: Vec::new(),
}
} else if !std::io::stdin().is_terminal() {
return Err(
"Interactive terminal required for `xbp version bump`. Use `--auto`, or `--all` with \
`--patch`/`--minor`/`--major`, or `--set <version>`. Preview with `--plan` or `--dry-run`."
.to_string(),
);
} else {
prompt_bump_plans(&candidates)?
};
let plans = prompt_result.plans;
let disable_scopes = prompt_result.disable_scopes;
if plans.is_empty() && disable_scopes.is_empty() {
println!("{}", "No packages selected for bump.".dimmed());
return Ok(());
}
if args.plan {
print_rich_plan(&plans, from_dirty_tree);
if !disable_scopes.is_empty() {
println!("\n{}", "Would also disable versioning for:".bright_yellow());
for scope in &disable_scopes {
println!(" • {}", version_scope_prompt_label(scope));
}
}
return Ok(());
}
if preview_only {
print_dry_run_plans(&plans);
if !disable_scopes.is_empty() {
println!("\n{}", "Would also disable versioning for:".bright_yellow());
for scope in &disable_scopes {
println!(" • {}", version_scope_prompt_label(scope));
}
}
return Ok(());
}
for scope in &disable_scopes {
disable_versioning_for_scope(&project_root, scope).await?;
}
if plans.is_empty() {
println!("{}", "No version bumps applied.".dimmed());
return Ok(());
}
let bumped_plans = apply_bump_plans(
&project_root,
&invocation_dir,
®istry,
&plans,
args.force,
)
.await?;
println!(
"\n{} Bumped {} package(s) independently.",
"✓".bright_green().bold(),
bumped_plans.len()
);
maybe_chain_releases(args, &project_root, &bumped_plans).await?;
let _ = args.push; Ok(())
}
async fn maybe_chain_releases(
args: &VersionBumpCmd,
_project_root: &Path,
plans: &[BumpPlan],
) -> Result<(), String> {
if plans.is_empty() || args.no_release_prompt {
return Ok(());
}
let labels: Vec<String> = plans
.iter()
.map(|p| {
format!(
"{} ({} → {})",
version_scope_prompt_label(&p.scope),
p.current,
p.next
)
})
.collect();
let selected_indices: Vec<usize> = if args.release {
(0..plans.len()).collect()
} else if std::io::stdin().is_terminal() {
println!();
println!(
"{}",
"Continue with version release for bumped packages?"
.bright_cyan()
.bold()
);
let defaults = vec![false; plans.len()];
searchable_multi_select(
"Select services to release (Enter with none to skip)",
&labels,
&defaults,
)?
} else {
return Ok(());
};
if selected_indices.is_empty() {
println!("{}", "Skipping release chain.".dimmed());
return Ok(());
}
let to_release: Vec<&BumpPlan> = selected_indices
.into_iter()
.filter_map(|idx| plans.get(idx))
.collect();
for plan in to_release {
let label = version_scope_prompt_label(&plan.scope);
println!(
"\n{} Releasing {} @ {}…",
"→".bright_cyan(),
label.bright_white(),
plan.next.to_string().bright_green()
);
let options = VersionReleaseOptions {
explicit_version: Some(plan.next.to_string()),
release_flag: None,
allow_dirty: false,
title: None,
notes: None,
notes_file: None,
draft: false,
prerelease: false,
publish: false,
force: false,
dry_run: false,
latest_policy: ReleaseLatestPolicy::Legacy,
force_scope: Some(plan.scope.clone()),
};
if let Err(error) = run_version_release_command(options).await {
eprintln!(
"{} Release for {label} failed: {error}",
"✗".bright_red().bold()
);
}
}
Ok(())
}
fn load_project_config(project_root: &Path) -> Option<XbpConfig> {
let found = find_xbp_config_upwards(project_root)?;
let content = fs::read_to_string(&found.config_path).ok()?;
let kind = crate::utils::config_kind_from_path(&found.config_path).ok()?;
crate::utils::parse_config_with_auto_heal::<XbpConfig>(&content, kind)
.ok()
.map(|(config, _)| config)
}
fn resolve_cli_default_kind(args: &VersionBumpCmd) -> Option<BumpKind> {
if args.major {
Some(BumpKind::Major)
} else if args.minor {
Some(BumpKind::Minor)
} else if args.patch {
Some(BumpKind::Patch)
} else {
None
}
}
fn build_plans_for_all_clamped(candidates: &[BumpCandidate], kind: BumpKind) -> Vec<BumpPlan> {
candidates
.iter()
.map(|candidate| {
let effective = clamp_kind_for_paths(kind, &candidate.changed_paths);
if effective != kind && all_paths_patch_only(&candidate.changed_paths) {
eprintln!(
" {} {} has only docs/config changes — clamping {} → patch",
"ℹ".bright_cyan(),
version_scope_prompt_label(&candidate.scope).bright_white(),
kind.as_str()
);
}
build_plan(candidate, effective)
})
.collect()
}
fn build_plans_from_suggestions(candidates: &[BumpCandidate]) -> Vec<BumpPlan> {
candidates
.iter()
.filter(|c| !c.changed_paths.is_empty() || matches!(c.mode, BumpMode::ManualOffer))
.map(|candidate| build_plan(candidate, candidate.suggestion.kind.clone()))
.collect()
}
struct BumpPromptResult {
plans: Vec<BumpPlan>,
disable_scopes: Vec<VersionScope>,
}
fn prompt_bump_plans(candidates: &[BumpCandidate]) -> Result<BumpPromptResult, String> {
let mut plans = Vec::new();
let mut disable_scopes = Vec::new();
let total = candidates.len();
for (index, candidate) in candidates.iter().enumerate() {
print_candidate_detail(index + 1, total, candidate);
let patch_only = all_paths_patch_only(&candidate.changed_paths)
&& !candidate.changed_paths.is_empty();
let selection = if patch_only {
eprintln!(
" {} Change set is docs/config only — minor/major bumps are not offered.",
"ℹ".bright_cyan()
);
let selection =
searchable_select("Choose bump action", &BUMP_ACTIONS_PATCH_ONLY, 0)?;
let Some(selection) = selection else {
println!("{}", "Cancelled.".dimmed());
return Ok(BumpPromptResult {
plans: Vec::new(),
disable_scopes: Vec::new(),
});
};
Some(match selection {
0 => 0, 1 => 3, 2 => 4, 3 => 5, 4 => 6, _ => 7, })
} else {
let default_idx = default_action_index(&candidate.suggestion.kind);
searchable_select("Choose bump action", &BUMP_ACTIONS, default_idx)?
};
let Some(selection) = selection else {
println!("{}", "Cancelled.".dimmed());
return Ok(BumpPromptResult {
plans: Vec::new(),
disable_scopes: 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 => match prompt_exact_version(&candidate.current_version)? {
Some(version) => plans.push(build_exact_plan(candidate, version)),
None => {
println!("{}", "Skipped exact set.".dimmed());
}
},
4 => {
disable_scopes.push(candidate.scope.clone());
println!(
" {} Will disable versioning for {}",
"⊘".bright_yellow(),
version_scope_prompt_label(&candidate.scope).bright_white()
);
}
5 => {}
6 => break,
_ => {
return Ok(BumpPromptResult {
plans: Vec::new(),
disable_scopes: Vec::new(),
});
}
}
}
Ok(BumpPromptResult {
plans,
disable_scopes,
})
}
fn prompt_exact_version(current: &Version) -> Result<Option<Version>, String> {
let input: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(format!(
"Exact version (current {current}; allows force override / downgrade)"
))
.allow_empty(true)
.interact_text()
.map_err(|e| format!("Failed to read version: {e}"))?;
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(parse_explicit_version(trimmed)?))
}
fn parse_explicit_version(raw: &str) -> Result<Version, String> {
let trimmed = raw.trim().trim_start_matches('v');
Version::parse(trimmed).map_err(|e| format!("Invalid semver `{raw}`: {e}"))
}
fn build_exact_plan(candidate: &BumpCandidate, next: Version) -> BumpPlan {
BumpPlan {
scope: candidate.scope.clone(),
current: candidate.current_version.clone(),
next,
kind: BumpKind::Exact,
changed_paths: candidate.changed_paths.clone(),
baseline_ref: candidate.baseline_ref.clone(),
commits_since: candidate.commits_since,
mode: candidate.mode,
suggestion_reasons: vec!["interactive force override".to_string()],
force: true,
}
}
async fn apply_bump_plans(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
plans: &[BumpPlan],
force_all: bool,
) -> Result<Vec<BumpPlan>, String> {
if !force_all {
let scopes: Vec<&VersionScope> = plans.iter().map(|p| &p.scope).collect();
clear_version_change_guards_for_scopes(project_root, &scopes)?;
}
let mut bumped_plans: Vec<BumpPlan> = Vec::new();
let mut failed_labels: Vec<String> = Vec::new();
for plan in plans {
let label = version_scope_prompt_label(&plan.scope);
let updated_paths = match write_version_to_configured_files_with_paths(
project_root,
invocation_dir,
registry,
&plan.scope,
&plan.next,
) {
Ok(paths) => paths,
Err(error) => {
eprintln!(
" {} {} skipped — failed to write version files: {error}",
"✗".bright_red(),
label.bright_white()
);
failed_labels.push(label);
continue;
}
};
let kind_label = if plan.force || matches!(plan.kind, BumpKind::Exact) {
"force-set"
} else {
plan.kind.as_str()
};
println!(
" {} {} {} -> {} ({})",
"✓".bright_green(),
label.bright_white(),
plan.current.to_string().dimmed(),
plan.next.to_string().bright_green().bold(),
kind_label.bright_yellow()
);
let mut unique_paths = dedupe_paths(updated_paths);
if unique_paths.iter().any(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("Cargo.toml"))
}) {
let lock = project_root.join("Cargo.lock");
if lock.is_file() {
unique_paths.push(lock);
}
}
let unique_paths = dedupe_paths(unique_paths);
let commit_msg = if plan.force || matches!(plan.kind, BumpKind::Exact) {
format!("chore(version): set {label} to {} (force)", plan.next)
} else {
format!("chore(version): bump {label} to {}", plan.next)
};
let commit_ok = match auto_commit_command_paths_result(
project_root,
unique_paths,
commit_msg,
"xbp version bump",
false,
)
.await
{
Ok(AutoCommitResult::Committed(_)) => true,
Ok(AutoCommitResult::Skipped(reason)) => {
eprintln!(
" {} Auto-commit skipped for {}: {}",
"!".bright_yellow(),
label.dimmed(),
reason.dimmed()
);
false
}
Err(error) => {
eprintln!(
" {} Auto-commit failed for {}: {}",
"!".bright_yellow(),
label.dimmed(),
error.dimmed()
);
false
}
};
if let Err(error) =
record_version_change_guard_after_write(project_root, Some(&plan.scope), commit_ok)
{
eprintln!(
" {} Failed to update version-change guard for {}: {error}",
"!".bright_yellow(),
label
);
}
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 mode_label = match plan.mode {
BumpMode::DirtyTree => "dirty",
BumpMode::SinceRelease => "since-release",
BumpMode::ManualOffer => "manual",
};
let event = build_history_event(
&label,
&plan.kind,
&plan.current.to_string(),
&plan.next.to_string(),
mode_label,
plan.baseline_ref.as_deref(),
plan.commits_since,
&plan.changed_paths,
);
if let Err(error) = append_bump_history(project_root, &event) {
eprintln!(
"{} Failed to append versioning history: {error}",
"!".bright_yellow()
);
}
bumped_plans.push(plan.clone());
}
if bumped_plans.is_empty() && !failed_labels.is_empty() {
return Err(format!(
"No packages were bumped. Failures: {}",
failed_labels.join(", ")
));
}
if !failed_labels.is_empty() {
eprintln!(
" {} {} package(s) failed; {} succeeded",
"!".bright_yellow(),
failed_labels.len(),
bumped_plans.len()
);
}
Ok(bumped_plans)
}
async fn disable_versioning_for_scope(
project_root: &Path,
scope: &VersionScope,
) -> Result<(), String> {
match scope {
VersionScope::Service { service_name, .. } => {
disable_versioning_for_service_name(project_root, service_name).await
}
VersionScope::Crate {
crate_relative_root,
package_name,
..
} => {
if let Some(name) = find_service_name_for_crate(project_root, package_name, crate_relative_root)
{
disable_versioning_for_service_name(project_root, &name).await
} else {
append_versioning_disabled_pattern(project_root, crate_relative_root).await
}
}
VersionScope::Repository => Err(
"Cannot disable versioning for the whole repository from bump; set per-service `versioning: false`."
.to_string(),
),
}
}
fn find_service_name_for_crate(
project_root: &Path,
package_name: &str,
crate_relative_root: &str,
) -> Option<String> {
let config = load_project_config(project_root)?;
let services = config.services.as_ref()?;
let rel = crate_relative_root.replace('\\', "/");
services.iter().find_map(|service| {
let root = service
.root_directory
.as_deref()
.unwrap_or("")
.replace('\\', "/")
.trim_start_matches("./")
.to_string();
if service.name == package_name || root == rel || root.ends_with(&rel) {
Some(service.name.clone())
} else {
None
}
})
}
fn push_versioning_disabled_pattern(config: &mut XbpConfig, pattern: &str) {
let pattern = pattern.trim().trim_start_matches("./");
if pattern.is_empty() {
return;
}
if !config
.versioning_disabled
.iter()
.any(|p| p.eq_ignore_ascii_case(pattern))
{
config.versioning_disabled.push(pattern.to_string());
}
}
async fn disable_versioning_for_service_name(
project_root: &Path,
service_name: &str,
) -> Result<(), String> {
let found = find_xbp_config_upwards(project_root)
.ok_or_else(|| "No project XBP config found to update.".to_string())?;
let content = fs::read_to_string(&found.config_path).map_err(|e| {
format!(
"Failed to read {}: {e}",
found.config_path.display()
)
})?;
let kind = crate::utils::config_kind_from_path(&found.config_path)?;
let (mut config, _) =
crate::utils::parse_config_with_auto_heal::<XbpConfig>(&content, kind)
.map_err(|e| format!("Failed to parse project config: {e}"))?;
let mut roots: Vec<String> = Vec::new();
if let Some(services) = config.services.as_mut() {
for service in services.iter_mut() {
if service.name == service_name {
service.versioning = Some(false);
service.release = Some(false);
service.version_targets = None;
if let Some(root) = service.root_directory.as_deref() {
let normalized = root
.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string();
if !normalized.is_empty() {
roots.push(normalized);
}
}
}
}
}
push_versioning_disabled_pattern(&mut config, service_name);
for root in &roots {
push_versioning_disabled_pattern(&mut config, root);
}
write_xbp_project_config_at_path(&found.config_path, &config)?;
println!(
" {} Disabled versioning for service `{}` in {}",
"✓".bright_green(),
service_name.bright_white(),
found.config_path.display()
);
auto_commit_command_paths(
project_root,
vec![found.config_path.clone()],
format!("chore(xbp): disable versioning for {service_name}"),
"xbp version bump",
)
.await;
Ok(())
}
async fn append_versioning_disabled_pattern(
project_root: &Path,
pattern: &str,
) -> Result<(), String> {
let found = find_xbp_config_upwards(project_root)
.ok_or_else(|| "No project XBP config found to update.".to_string())?;
let content = fs::read_to_string(&found.config_path).map_err(|e| {
format!(
"Failed to read {}: {e}",
found.config_path.display()
)
})?;
let kind = crate::utils::config_kind_from_path(&found.config_path)?;
let (mut config, _) =
crate::utils::parse_config_with_auto_heal::<XbpConfig>(&content, kind)
.map_err(|e| format!("Failed to parse project config: {e}"))?;
let pattern = pattern.trim().trim_start_matches("./");
if !config
.versioning_disabled
.iter()
.any(|p| p == pattern)
{
config.versioning_disabled.push(pattern.to_string());
}
write_xbp_project_config_at_path(&found.config_path, &config)?;
println!(
" {} Added `{}` to versioning_disabled in {}",
"✓".bright_green(),
pattern.bright_white(),
found.config_path.display()
);
auto_commit_command_paths(
project_root,
vec![found.config_path.clone()],
format!("chore(xbp): disable versioning for {pattern}"),
"xbp version bump",
)
.await;
Ok(())
}
fn default_action_index(kind: &BumpKind) -> usize {
match kind {
BumpKind::Patch => 0,
BumpKind::Minor => 1,
BumpKind::Major => 2,
BumpKind::Exact => 3,
}
}
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(),
baseline_ref: candidate.baseline_ref.clone(),
commits_since: candidate.commits_since,
mode: candidate.mode,
suggestion_reasons: candidate.suggestion.reasons.clone(),
force: false,
}
}
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"),
BumpKind::Exact => current.clone(),
}
}
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 enrich_candidate(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
scope: VersionScope,
changed_paths: Vec<String>,
mode: BumpMode,
baseline_source: Option<String>,
baseline_ref: Option<String>,
commits_since: Option<usize>,
config: Option<&XbpConfig>,
history: &[BumpHistoryEvent],
cli_default: Option<BumpKind>,
) -> BumpCandidate {
let current_version =
resolve_current_version_for_bump(project_root, invocation_dir, registry, &scope);
let label = version_scope_prompt_label(&scope);
let (default_kind, rules) = rules_for_scope(config, &scope);
let suggestion = suggest_bump_kind(
&changed_paths,
&rules,
default_kind,
history,
&label,
commits_since,
cli_default,
);
BumpCandidate {
scope,
changed_paths,
current_version,
baseline_source,
baseline_ref,
commits_since,
suggestion,
mode,
}
}
fn build_bump_candidates_dirty(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
dirty_paths: &[String],
config: Option<&XbpConfig>,
history: &[BumpHistoryEvent],
cli_default: Option<BumpKind>,
) -> Vec<BumpCandidate> {
let nested_scopes = collect_nested_version_scopes(project_root, invocation_dir);
let assigned = assign_dirty_paths_to_scopes(project_root, &nested_scopes, dirty_paths);
let mut candidates = assigned
.into_iter()
.map(|(scope, changed_paths)| {
enrich_candidate(
project_root,
invocation_dir,
registry,
scope,
changed_paths,
BumpMode::DirtyTree,
None,
None,
None,
config,
history,
cli_default.clone(),
)
})
.collect::<Vec<_>>();
let unscoped_paths = dirty_paths
.iter()
.filter(|path| {
!candidates
.iter()
.any(|candidate| scope_matches_changed_path(project_root, &candidate.scope, path))
})
.cloned()
.collect::<Vec<_>>();
if !unscoped_paths.is_empty() {
candidates.push(enrich_candidate(
project_root,
invocation_dir,
registry,
VersionScope::Repository,
unscoped_paths,
BumpMode::DirtyTree,
None,
None,
None,
config,
history,
cli_default,
));
}
sort_candidates(&mut candidates);
candidates
}
fn build_bump_candidates_since_release_with_options(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
config: Option<&XbpConfig>,
history: &[BumpHistoryEvent],
cli_default: Option<BumpKind>,
include_unchanged: bool,
force_refresh_remote: bool,
) -> Result<Vec<BumpCandidate>, String> {
use super::change_selection::{
resolve_change_baseline_with_options, ChangeBaselineOptions,
};
let mut scopes = collect_nested_version_scopes(project_root, invocation_dir);
if scopes.is_empty() {
scopes.push(VersionScope::Repository);
}
let repo = project_root
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("repo");
let baseline_opts = ChangeBaselineOptions {
allow_remote: true,
force_refresh_remote,
};
let mut scope_baseline_meta: Vec<(VersionScope, String, Option<String>)> = Vec::new();
let mut unique_refs: BTreeSet<String> = BTreeSet::new();
for scope in &scopes {
let (source, reference, tag) =
resolve_change_baseline_with_options(project_root, scope, repo, baseline_opts)?;
let baseline_ref = reference.or(tag);
if let Some(ref_name) = baseline_ref.as_ref() {
unique_refs.insert(ref_name.clone());
}
scope_baseline_meta.push((scope.clone(), source, baseline_ref));
}
let mut commits_by_ref: std::collections::BTreeMap<String, Option<usize>> =
std::collections::BTreeMap::new();
let mut changed_by_ref: std::collections::BTreeMap<String, Vec<String>> =
std::collections::BTreeMap::new();
for ref_name in &unique_refs {
commits_by_ref.insert(
ref_name.clone(),
git_tag_distance_from_head(project_root, ref_name),
);
changed_by_ref.insert(
ref_name.clone(),
collect_changed_files_since_reference(project_root, Some(ref_name.as_str()))
.unwrap_or_default(),
);
}
let changed_no_baseline =
if scope_baseline_meta.iter().any(|(_, _, br)| br.is_none()) {
collect_changed_files_since_reference(project_root, None).unwrap_or_default()
} else {
Vec::new()
};
let mut path_to_meta: std::collections::BTreeMap<String, (String, Option<String>, Option<usize>)> =
std::collections::BTreeMap::new();
let mut scope_baselines: Vec<(VersionScope, String, Option<String>, Option<usize>, Vec<String>)> =
Vec::new();
for (scope, source, baseline_ref) in &scope_baseline_meta {
let commits_since = baseline_ref
.as_ref()
.and_then(|r| commits_by_ref.get(r).copied())
.flatten();
let changed = match baseline_ref.as_ref() {
Some(r) => changed_by_ref.get(r).cloned().unwrap_or_default(),
None => changed_no_baseline.clone(),
};
let scoped_paths: Vec<String> = changed
.into_iter()
.filter(|path| {
matches!(scope, VersionScope::Repository)
|| scope_matches_changed_path(project_root, scope, path)
})
.collect();
for path in &scoped_paths {
path_to_meta.entry(path.clone()).or_insert_with(|| {
(
source.clone(),
baseline_ref.clone(),
commits_since,
)
});
}
scope_baselines.push((
scope.clone(),
source.clone(),
baseline_ref.clone(),
commits_since,
scoped_paths,
));
}
let all_paths: Vec<String> = path_to_meta.keys().cloned().collect();
let assigned = assign_dirty_paths_to_scopes(project_root, &scopes, &all_paths);
let mut candidates = Vec::new();
let mut assigned_keys = BTreeSet::new();
for (scope, paths) in assigned {
let key = version_scope_prompt_label(&scope);
assigned_keys.insert(key.clone());
let meta = paths
.first()
.and_then(|p| path_to_meta.get(p))
.cloned()
.unwrap_or_else(|| {
scope_baselines
.iter()
.find(|(s, _, _, _, _)| version_scope_prompt_label(s) == key)
.map(|(_, src, br, cs, _)| (src.clone(), br.clone(), *cs))
.unwrap_or_else(|| ("none".into(), None, None))
});
candidates.push(enrich_candidate(
project_root,
invocation_dir,
registry,
scope,
paths,
BumpMode::SinceRelease,
Some(meta.0),
meta.1,
meta.2,
config,
history,
cli_default.clone(),
));
}
for (scope, source, baseline_ref, commits_since, paths) in scope_baselines {
let key = version_scope_prompt_label(&scope);
if assigned_keys.contains(&key) {
continue;
}
if !paths.is_empty() {
continue;
}
let never_released = baseline_ref.is_none();
if !(include_unchanged || never_released) {
continue;
}
candidates.push(enrich_candidate(
project_root,
invocation_dir,
registry,
scope,
Vec::new(),
BumpMode::ManualOffer,
Some(source),
baseline_ref,
commits_since,
config,
history,
cli_default.clone(),
));
}
let unscoped: Vec<String> = all_paths
.iter()
.filter(|path| {
!candidates
.iter()
.any(|c| scope_matches_changed_path(project_root, &c.scope, path))
})
.cloned()
.collect();
if !unscoped.is_empty() {
let meta = unscoped
.first()
.and_then(|p| path_to_meta.get(p))
.cloned()
.unwrap_or_else(|| ("since-release".into(), None, None));
candidates.push(enrich_candidate(
project_root,
invocation_dir,
registry,
VersionScope::Repository,
unscoped,
BumpMode::SinceRelease,
Some(meta.0),
meta.1,
meta.2,
config,
history,
cli_default,
));
}
sort_candidates(&mut candidates);
Ok(candidates)
}
fn prefer_invocation_scope(
candidates: &mut [BumpCandidate],
invocation_dir: &Path,
project_root: &Path,
) {
if invocation_dir == project_root {
return;
}
if let Some(matching) = candidates.iter().position(|candidate| {
super::version_scope_root(&candidate.scope)
.map(|root| invocation_dir.starts_with(root) || root.starts_with(invocation_dir))
.unwrap_or(false)
}) {
candidates.swap(0, matching);
}
}
fn sort_candidates(candidates: &mut [BumpCandidate]) {
candidates.sort_by(|left, right| {
version_scope_prompt_label(&left.scope).cmp(&version_scope_prompt_label(&right.scope))
});
}
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!("{}-", super::default_release_tag_slug(&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 print_candidate_summary(candidates: &[BumpCandidate], mode: BumpMode) {
let title = match mode {
BumpMode::DirtyTree => format!(
"Found {} mutated package(s) in the working tree",
candidates.len()
),
BumpMode::SinceRelease | BumpMode::ManualOffer => format!(
"Working tree is clean — {} package(s) with changes since last release (or offered)",
candidates.len()
),
};
println!("\n{}", title.bright_cyan().bold());
println!("{}", "─".repeat(72).bright_black());
for candidate in candidates {
let suggest = format!(
"→ {} ({:.0}%)",
candidate.suggestion.kind.as_str(),
candidate.suggestion.confidence * 100.0
);
let commits = candidate
.commits_since
.map(|n| format!("{n} commits"))
.unwrap_or_else(|| "—".into());
println!(
" {:<28} {} {} 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(),
commits.dimmed(),
suggest.bright_magenta()
);
}
}
fn print_candidate_detail(index: usize, total: usize, candidate: &BumpCandidate) {
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} {} {}",
"suggested".bright_white(),
candidate.suggestion.kind.as_str().bright_yellow().bold(),
format!("({:.0}%)", candidate.suggestion.confidence * 100.0).dimmed()
);
if let Some(baseline) = &candidate.baseline_ref {
println!(
" {:<18} {} {}",
"baseline".bright_white(),
baseline.bright_black(),
candidate
.baseline_source
.as_deref()
.unwrap_or("")
.dimmed()
);
}
if let Some(n) = candidate.commits_since {
println!(
" {:<18} {}",
"commits since".bright_white(),
n.to_string().bright_yellow()
);
}
for reason in candidate.suggestion.reasons.iter().take(3) {
println!(" {} {}", "·".bright_black(), reason.dimmed());
}
println!(" {}", "changed files".bright_white());
for path in candidate.changed_paths.iter().take(8) {
println!(" {} {}", "•".bright_black(), path);
}
if candidate.changed_paths.len() > 8 {
println!(
" {} … and {} more",
"•".bright_black(),
candidate.changed_paths.len() - 8
);
}
if candidate.changed_paths.is_empty() {
println!(" {}", "(none — manual offer)".dimmed());
}
}
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(),
plan.kind.as_str()
);
}
}
fn print_rich_plan(plans: &[BumpPlan], from_dirty: bool) {
println!();
println!(
"{}",
format!(
"Bump plan ({})",
if from_dirty {
"dirty worktree"
} else {
"since last release"
}
)
.bright_cyan()
.bold()
);
println!("{}", "═".repeat(72).bright_black());
for plan in plans {
println!(
"\n{} {} → {} ({})",
version_scope_prompt_label(&plan.scope).bright_white().bold(),
plan.current.to_string().dimmed(),
plan.next.to_string().bright_green().bold(),
plan.kind.as_str().bright_yellow()
);
if let Some(b) = &plan.baseline_ref {
println!(" baseline: {}", b.bright_black());
}
if let Some(n) = plan.commits_since {
println!(" commits: {}", n.to_string().bright_yellow());
}
for reason in plan.suggestion_reasons.iter().take(4) {
println!(" reason: {}", reason.dimmed());
}
println!(" files ({}):", plan.changed_paths.len());
for path in plan.changed_paths.iter().take(12) {
println!(" • {path}");
}
if plan.changed_paths.len() > 12 {
println!(" … +{} more", plan.changed_paths.len() - 12);
}
}
println!(
"\n{}",
"Plan only — re-run without --plan to apply.".bright_yellow()
);
}
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}"))
}
fn git(dir: &Path, args: &[&str]) {
let git_home = dir.join("_git_home");
let _ = fs::create_dir_all(&git_home);
let output = std::process::Command::new("git")
.current_dir(dir)
.env("HOME", &git_home)
.env("XDG_CONFIG_HOME", git_home.join(".config"))
.env("GIT_CONFIG_GLOBAL", git_home.join(".gitconfig"))
.env("GIT_CONFIG_NOSYSTEM", "1")
.args([
"-c",
"init.defaultBranch=main",
"-c",
"advice.defaultBranchName=false",
])
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn git {:?}: {e}", args));
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn clean_tree_offers_never_released_service_without_include_unchanged() {
let project_root = temp_test_dir("never-released");
let _ = fs::remove_dir_all(&project_root);
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir");
fs::create_dir_all(project_root.join("src")).expect("src");
fs::write(
project_root.join(".xbp/xbp.toml"),
r#"project_name = "demo"
version = "0.1.0"
port = 3000
build_dir = "./"
version_targets = ["package.json"]
[[services]]
name = "demo"
target = "nodejs"
branch = "main"
port = 3000
root_directory = "./"
version_targets = ["package.json"]
"#,
)
.expect("xbp.toml");
fs::write(
project_root.join("package.json"),
r#"{"name":"demo","version":"0.1.0"}"#,
)
.expect("package.json");
fs::write(project_root.join("src/index.ts"), "export {}\n").expect("src");
git(&project_root, &["init", "-b", "main"]);
git(&project_root, &["config", "user.email", "test@example.com"]);
git(&project_root, &["config", "user.name", "Test"]);
git(&project_root, &["remote", "add", "origin", project_root.to_str().unwrap()]);
git(&project_root, &["add", "."]);
git(&project_root, &["commit", "-m", "init"]);
fs::write(project_root.join("src/index.ts"), "export const x = 1\n").expect("edit");
git(&project_root, &["add", "src/index.ts"]);
git(&project_root, &["commit", "-m", "feat: change"]);
let registry = vec!["package.json".to_string()];
let without_flag = build_bump_candidates_since_release_with_options(
&project_root,
&project_root,
®istry,
None,
&[],
None,
false, false,
)
.expect("candidates");
assert!(
!without_flag.is_empty(),
"never-released service should be offered on clean tree without --include-unchanged"
);
assert!(without_flag.iter().any(|c| {
matches!(
&c.scope,
VersionScope::Service {
service_name,
..
} if service_name == "demo"
) && matches!(c.mode, BumpMode::ManualOffer)
}));
git(&project_root, &["tag", "-a", "demo-0.1.0", "-m", "release"]);
let with_tag = build_bump_candidates_since_release_with_options(
&project_root,
&project_root,
®istry,
None,
&[],
None,
false,
false,
)
.expect("candidates with tag");
let still_offers_demo = with_tag.iter().any(|c| {
matches!(
&c.scope,
VersionScope::Service {
service_name,
..
} if service_name == "demo"
)
});
assert!(
!still_offers_demo,
"post-release clean tree must not auto-offer without --include-unchanged"
);
let with_flag = build_bump_candidates_since_release_with_options(
&project_root,
&project_root,
®istry,
None,
&[],
None,
true,
false,
)
.expect("candidates include_unchanged");
assert!(
with_flag.iter().any(|c| {
matches!(
&c.scope,
VersionScope::Service {
service_name,
..
} if service_name == "demo"
)
}),
"--include-unchanged should still offer released-but-unchanged scopes"
);
let _ = fs::remove_dir_all(&project_root);
}
#[test]
fn parse_explicit_version_accepts_v_prefix() {
let v = parse_explicit_version("v1.2.3").expect("semver");
assert_eq!(v.to_string(), "1.2.3");
}
#[test]
fn build_exact_plan_marks_force() {
let candidate = BumpCandidate {
scope: VersionScope::Repository,
changed_paths: vec!["README.md".into()],
current_version: Version::new(1, 0, 0),
baseline_source: None,
baseline_ref: None,
commits_since: None,
suggestion: BumpSuggestion {
kind: BumpKind::Patch,
confidence: 0.5,
reasons: vec![],
},
mode: BumpMode::DirtyTree,
};
let plan = build_exact_plan(&candidate, Version::new(0, 9, 0));
assert!(plan.force);
assert_eq!(plan.kind, BumpKind::Exact);
assert_eq!(plan.next.to_string(), "0.9.0");
}
#[test]
fn scope_matches_service_watch_paths_in_isolation() {
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()],
watch_paths: vec!["apps/web".to_string()],
};
assert!(scope_matches_changed_path(
&project_root,
&scope,
"apps/web/src/routes/index.ts"
));
assert!(scope_matches_changed_path(
&project_root,
&scope,
"apps/web/package.json"
));
assert!(!scope_matches_changed_path(
&project_root,
&scope,
"apps/api/package.json"
));
}
#[test]
fn build_bump_candidates_versions_sibling_services_independently() {
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("apps/api/src")).expect("api 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"),
r#"project_name: test
version: 0.1.0
port: 3000
build_dir: ./
services:
- name: web
target: web
branch: main
port: 3000
root_directory: apps/web
version_targets:
- apps/web/package.json
watch_paths:
- apps/web
- name: api
target: api
branch: main
port: 3001
root_directory: apps/api
version_targets:
- apps/api/package.json
watch_paths:
- apps/api
"#,
)
.expect("xbp yaml");
fs::write(
project_root.join("apps/web/package.json"),
"{\"name\":\"web\",\"version\":\"0.2.0\"}",
)
.expect("package json");
fs::write(
project_root.join("apps/api/package.json"),
"{\"name\":\"api\",\"version\":\"0.3.0\"}",
)
.expect("package json");
let dirty_paths = vec![
"crates/cli/src/main.rs".to_string(),
"apps/web/src/app.ts".to_string(),
"apps/api/src/handler.ts".to_string(),
];
let registry = vec!["Cargo.toml".to_string(), "package.json".to_string()];
let candidates = build_bump_candidates_dirty(
&project_root,
&project_root,
®istry,
&dirty_paths,
None,
&[],
None,
);
assert_eq!(candidates.len(), 3);
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 {
service_name,
..
} if service_name == "web"
) && candidate.changed_paths == vec!["apps/web/src/app.ts".to_string()]
}));
assert!(candidates.iter().any(|candidate| {
matches!(
&candidate.scope,
VersionScope::Service {
service_name,
..
} if service_name == "api"
) && candidate.changed_paths == vec!["apps/api/src/handler.ts".to_string()]
}));
}
#[test]
fn nested_watch_paths_assign_to_deepest_service_only() {
let project_root = temp_test_dir("nested-watch");
let _ = fs::remove_dir_all(&project_root);
fs::create_dir_all(project_root.join("apps/web/packages/icons/src")).expect("icons");
fs::create_dir_all(project_root.join("apps/web/src")).expect("web src");
fs::create_dir_all(project_root.join(".xbp")).expect("xbp dir");
fs::write(
project_root.join(".xbp/xbp.yaml"),
r#"project_name: test
version: 0.1.0
port: 3000
build_dir: ./
services:
- name: web
target: web
branch: main
port: 3000
root_directory: apps/web
version_targets:
- apps/web/package.json
watch_paths:
- apps/web
- name: icons
target: nodejs
branch: main
port: 3001
root_directory: apps/web/packages/icons
version_targets:
- apps/web/packages/icons/package.json
watch_paths:
- apps/web/packages/icons
"#,
)
.expect("yaml");
fs::write(
project_root.join("apps/web/package.json"),
"{\"name\":\"web\",\"version\":\"1.0.0\"}",
)
.expect("web pkg");
fs::write(
project_root.join("apps/web/packages/icons/package.json"),
"{\"name\":\"icons\",\"version\":\"2.0.0\"}",
)
.expect("icons pkg");
let dirty_paths = vec!["apps/web/packages/icons/src/index.ts".to_string()];
let registry = vec!["package.json".to_string()];
let candidates = build_bump_candidates_dirty(
&project_root,
&project_root,
®istry,
&dirty_paths,
None,
&[],
None,
);
assert_eq!(candidates.len(), 1);
assert!(matches!(
&candidates[0].scope,
VersionScope::Service {
service_name,
..
} if service_name == "icons"
));
assert_eq!(
candidates[0].changed_paths,
vec!["apps/web/packages/icons/src/index.ts".to_string()]
);
}
}