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;
use super::versioning_history::{
append_bump_history, build_history_event, load_bump_history, BumpHistoryEvent,
};
use super::versioning_suggest::{
rules_for_scope, suggest_bump_kind, BumpKind, BumpSuggestion,
};
use super::{
assign_dirty_paths_to_scopes, auto_commit_command_paths, bump_version,
collect_changed_files_since_reference, enforce_version_change_guard, git_dirty_entries,
git_tag_distance_from_head, load_service_version_scopes, parse_git_status_path,
record_version_change_guard, resolve_change_baseline, 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,
};
#[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>,
}
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 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(
&project_root,
&invocation_dir,
®istry,
project_config.as_ref(),
&history,
cli_default.clone(),
args.include_unchanged,
)?;
(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` 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);
}
let plans = if args.all {
let kind = cli_default.unwrap_or(BumpKind::Patch);
build_plans_for_all(&candidates, kind)
} else if args.auto {
build_plans_from_suggestions(&candidates)
} else if !std::io::stdin().is_terminal() {
return Err(
"Interactive terminal required for `xbp version bump`. Use `--auto`, or `--all` with \
`--patch`/`--minor`/`--major`. Preview with `--plan` or `--dry-run`."
.to_string(),
);
} else {
prompt_bump_plans(&candidates)?
};
if plans.is_empty() {
println!("{}", "No packages selected for bump.".dimmed());
return Ok(());
}
if args.plan {
print_rich_plan(&plans, from_dirty_tree);
return Ok(());
}
if preview_only {
print_dry_run_plans(&plans);
return Ok(());
}
for plan in &plans {
enforce_version_change_guard(&project_root, Some(&plan.scope))?;
}
let mut bumped_plans: Vec<BumpPlan> = 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);
println!(
" {} {} {} -> {} ({})",
"✓".bright_green(),
label.bright_white(),
plan.current.to_string().dimmed(),
plan.next.to_string().bright_green().bold(),
plan.kind.as_str().bright_yellow()
);
let unique_paths = dedupe_paths(updated_paths);
auto_commit_command_paths(
&project_root,
unique_paths,
format!("chore(version): bump {label} to {}", plan.next),
"xbp version bump",
)
.await;
record_version_change_guard(&project_root, Some(&plan.scope))?;
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());
}
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(candidates: &[BumpCandidate], kind: BumpKind) -> Vec<BumpPlan> {
candidates
.iter()
.map(|candidate| build_plan(candidate, kind.clone()))
.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()
}
fn prompt_bump_plans(candidates: &[BumpCandidate]) -> 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);
let default_idx = default_action_index(&candidate.suggestion.kind);
let selection = searchable_select("Choose bump action", &BUMP_ACTIONS, default_idx)?;
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(),
baseline_ref: candidate.baseline_ref.clone(),
commits_since: candidate.commits_since,
mode: candidate.mode,
suggestion_reasons: candidate.suggestion.reasons.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 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(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
config: Option<&XbpConfig>,
history: &[BumpHistoryEvent],
cli_default: Option<BumpKind>,
include_unchanged: bool,
) -> Result<Vec<BumpCandidate>, String> {
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 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 in &scopes {
let (source, reference, tag) = resolve_change_baseline(project_root, scope, repo)?;
let baseline_ref = reference.clone().or(tag.clone());
let commits_since = baseline_ref
.as_deref()
.and_then(|tag_name| git_tag_distance_from_head(project_root, tag_name));
let changed =
collect_changed_files_since_reference(project_root, baseline_ref.as_deref()).unwrap_or_default();
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,
baseline_ref,
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(),
));
}
if include_unchanged {
for (scope, source, baseline_ref, commits_since, paths) in scope_baselines {
let key = version_scope_prompt_label(&scope);
if assigned_keys.contains(&key) || !paths.is_empty() {
if assigned_keys.contains(&key) {
continue;
}
}
if paths.is_empty() {
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}"))
}
#[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()]
);
}
}