use crate::utils::collapse_project_path;
use colored::Colorize;
use semver::Version;
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use super::release_ledger::{find_latest_successful_release_ledger, ReleasePublishTarget};
use super::VersionScope;
use super::{
cargo_package_name, current_scope_slug, filter_version_targets_for_enabled_services,
is_nested_repository_scope, load_version_target_config, normalized_relative_path, parse_version,
read_version_from_resolved_path, release_tag_name_for_project, resolve_registry_paths,
run_git_command, save_last_version_scope_preference, version_scope_guard_id,
version_scope_prompt_label, version_scope_root, write_version_to_resolved_path,
ChangedTargetSelection, ResolvedRegistryPath,
};
pub(crate) fn resolve_change_baseline(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
) -> Result<(String, Option<String>, Option<String>), String> {
resolve_change_baseline_with_options(
project_root,
version_scope,
repo,
ChangeBaselineOptions::default(),
)
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ChangeBaselineOptions {
pub allow_remote: bool,
pub force_refresh_remote: bool,
}
impl Default for ChangeBaselineOptions {
fn default() -> Self {
Self {
allow_remote: true,
force_refresh_remote: false,
}
}
}
fn pick_latest_tag_for_scope(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
tags: &[super::GitTagObservation],
) -> Option<String> {
let latest = tags.first()?;
let canonical_tag =
release_tag_name_for_project(project_root, version_scope, &latest.version, repo);
Some(
latest
.raw_tags
.iter()
.find(|candidate| candidate.as_str() == canonical_tag.as_str())
.cloned()
.or_else(|| latest.raw_tags.first().cloned())
.unwrap_or(canonical_tag),
)
}
pub(crate) fn resolve_change_baseline_with_options(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
options: ChangeBaselineOptions,
) -> Result<(String, Option<String>, Option<String>), String> {
let scope_slug = current_scope_slug(version_scope, repo);
if let Some(ledger) = find_latest_successful_release_ledger(project_root, &scope_slug)? {
let tag = ledger.tag_name.trim();
if !tag.is_empty() {
return Ok((
"release-ledger".to_string(),
Some(tag.to_string()),
Some(tag.to_string()),
));
}
}
if is_nested_repository_scope(project_root, version_scope) {
return Ok(("initial-release".to_string(), None, None));
}
let local_tags = super::collect_git_versions(project_root, version_scope).unwrap_or_default();
if let Some(tag) = pick_latest_tag_for_scope(project_root, version_scope, repo, &local_tags) {
return Ok(("local-tag".to_string(), Some(tag.clone()), Some(tag)));
}
if !options.allow_remote {
return Ok(("initial-release".to_string(), None, None));
}
let tags = super::collect_remote_git_versions_cached(
project_root,
"origin",
version_scope,
options.force_refresh_remote,
)
.unwrap_or_default();
if let Some(tag) = pick_latest_tag_for_scope(project_root, version_scope, repo, &tags) {
return Ok(("github-tag".to_string(), Some(tag.clone()), Some(tag)));
}
Ok(("initial-release".to_string(), None, None))
}
pub(crate) fn collect_changed_files_since_reference(
project_root: &Path,
baseline_ref: Option<&str>,
) -> Result<Vec<String>, String> {
let output = if let Some(reference) = baseline_ref
.map(str::trim)
.filter(|value| !value.is_empty())
{
let _ = super::ensure_git_tag_available_for_log(project_root, reference);
let tag_ref = format!("refs/tags/{reference}");
let resolved = if super::git_tag_exists(project_root, reference).unwrap_or(false) {
tag_ref.as_str()
} else {
reference
};
run_git_command(project_root, &["diff", "--name-only", resolved, "HEAD"])?
} else {
run_git_command(project_root, &["diff", "--name-only"])?
};
let mut changed_files = output
.lines()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect::<Vec<_>>();
changed_files.sort();
changed_files.dedup();
Ok(changed_files)
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct ChangeSelectionOptions {
pub for_release: bool,
pub force_include_all: bool,
pub interactive: bool,
}
#[derive(Debug, Clone)]
pub(crate) enum ReleaseTargetSelection {
Ready(ChangedTargetSelection),
SwitchScope(VersionScope),
}
#[derive(Debug, Clone)]
pub(crate) struct ScopeChangeSuggestion {
pub scope: VersionScope,
pub label: String,
pub baseline_tag: Option<String>,
pub changed_file_count: usize,
pub matched_target_count: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct ScopeChangeStatus {
pub has_matched_targets: bool,
pub changed_file_count: usize,
pub matched_target_count: usize,
pub baseline_label: String,
}
pub(crate) fn resolve_changed_target_selection(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
) -> Result<ChangedTargetSelection, String> {
match resolve_changed_target_selection_with_options(
project_root,
invocation_dir,
registry,
version_scope,
repo,
ChangeSelectionOptions {
for_release: false,
force_include_all: false,
interactive: false,
},
)? {
ReleaseTargetSelection::Ready(selection) => Ok(selection),
ReleaseTargetSelection::SwitchScope(_) => Err(
"Internal error: scope switch is only valid during release selection.".to_string(),
),
}
}
pub(crate) fn resolve_changed_target_selection_for_release(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
force: bool,
) -> Result<ReleaseTargetSelection, String> {
resolve_changed_target_selection_with_options(
project_root,
invocation_dir,
registry,
version_scope,
repo,
ChangeSelectionOptions {
for_release: true,
force_include_all: force,
interactive: std::io::stdin().is_terminal(),
},
)
}
pub(crate) fn resolve_changed_target_selection_with_mode(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
for_release: bool,
) -> Result<ChangedTargetSelection, String> {
match resolve_changed_target_selection_with_options(
project_root,
invocation_dir,
registry,
version_scope,
repo,
ChangeSelectionOptions {
for_release,
force_include_all: false,
interactive: false,
},
)? {
ReleaseTargetSelection::Ready(selection) => Ok(selection),
ReleaseTargetSelection::SwitchScope(_) => Err(
"Internal error: scope switch is only valid during release selection.".to_string(),
),
}
}
pub(crate) fn resolve_changed_target_selection_with_options(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
options: ChangeSelectionOptions,
) -> Result<ReleaseTargetSelection, String> {
let (baseline_source, baseline_reference, baseline_tag) =
resolve_change_baseline(project_root, version_scope, repo)?;
let changed_files =
collect_changed_files_since_reference(project_root, baseline_reference.as_deref())?;
let mut all_version_targets =
resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
if options.for_release && matches!(version_scope, VersionScope::Repository) {
all_version_targets = filter_version_targets_for_enabled_services(
project_root,
invocation_dir,
all_version_targets,
true,
);
}
if all_version_targets.is_empty() {
return Err(
"No configured version files were found to evaluate for release selection.".to_string(),
);
}
let select_all = changed_files.is_empty() || baseline_reference.is_none();
let version_targets = if select_all {
dedupe_resolved_registry_paths(&all_version_targets)
} else {
let selected = select_changed_version_targets(
project_root,
version_scope,
&all_version_targets,
&changed_files,
);
if selected.is_empty() {
return recover_empty_release_selection(
project_root,
invocation_dir,
registry,
version_scope,
repo,
&baseline_source,
baseline_reference.as_deref(),
baseline_tag.as_deref(),
&changed_files,
&all_version_targets,
options,
);
}
selected
};
let publish_targets = resolve_selected_publish_targets(
project_root,
invocation_dir,
version_scope,
&version_targets,
select_all,
)?;
Ok(ReleaseTargetSelection::Ready(ChangedTargetSelection {
changed_files,
version_targets,
publish_targets,
baseline_source,
baseline_reference,
baseline_tag,
}))
}
fn recover_empty_release_selection(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
baseline_source: &str,
baseline_reference: Option<&str>,
baseline_tag: Option<&str>,
changed_files: &[String],
all_version_targets: &[ResolvedRegistryPath],
options: ChangeSelectionOptions,
) -> Result<ReleaseTargetSelection, String> {
let scope_label = version_scope_prompt_label(version_scope);
let baseline_display = baseline_tag
.or(baseline_reference)
.unwrap_or("unknown")
.to_string();
let suggestions =
collect_scope_change_suggestions(project_root, invocation_dir, registry, version_scope, repo);
if options.force_include_all {
println!(
"{} No version targets for {} matched commits since {} [{}].",
"!".bright_yellow().bold(),
scope_label.bright_white(),
baseline_display.bright_cyan(),
baseline_source.dimmed()
);
println!(
" {} `--force`: releasing all {} configured version target(s) for this scope anyway.",
"→".bright_cyan(),
all_version_targets.len()
);
if !suggestions.is_empty() {
println!(
" {} Other scopes with matching changes: {}",
"i".bright_blue(),
suggestions
.iter()
.map(|s| format!(
"{} ({} file(s))",
s.label, s.changed_file_count
))
.collect::<Vec<_>>()
.join(", ")
);
}
return finalize_forced_all_targets(
project_root,
invocation_dir,
version_scope,
changed_files,
all_version_targets,
baseline_source,
baseline_reference,
baseline_tag,
);
}
if options.interactive && options.for_release && std::io::stdin().is_terminal() {
return interactive_empty_match_recovery(
project_root,
invocation_dir,
version_scope,
&scope_label,
&baseline_display,
baseline_source,
baseline_reference,
baseline_tag,
changed_files,
all_version_targets,
&suggestions,
);
}
Err(format_empty_match_error(
&scope_label,
&baseline_display,
baseline_source,
changed_files,
&suggestions,
))
}
fn finalize_forced_all_targets(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
changed_files: &[String],
all_version_targets: &[ResolvedRegistryPath],
baseline_source: &str,
baseline_reference: Option<&str>,
baseline_tag: Option<&str>,
) -> Result<ReleaseTargetSelection, String> {
let version_targets = dedupe_resolved_registry_paths(all_version_targets);
let publish_targets = resolve_selected_publish_targets(
project_root,
invocation_dir,
version_scope,
&version_targets,
true,
)?;
Ok(ReleaseTargetSelection::Ready(ChangedTargetSelection {
changed_files: changed_files.to_vec(),
version_targets,
publish_targets,
baseline_source: baseline_source.to_string(),
baseline_reference: baseline_reference.map(str::to_string),
baseline_tag: baseline_tag.map(str::to_string),
}))
}
fn interactive_empty_match_recovery(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
scope_label: &str,
baseline_display: &str,
baseline_source: &str,
baseline_reference: Option<&str>,
baseline_tag: Option<&str>,
changed_files: &[String],
all_version_targets: &[ResolvedRegistryPath],
suggestions: &[ScopeChangeSuggestion],
) -> Result<ReleaseTargetSelection, String> {
use crate::cli::interactive::{print_picker_header, searchable_select, select_one};
println!();
print_picker_header(
"No matching version targets for this release scope",
&format!(
"{scope_label} has no version-target hits since {baseline_display} [{baseline_source}]"
),
);
if !changed_files.is_empty() {
println!(
" {} {} changed path(s) since baseline (not in this scope's targets):",
"·".dimmed(),
changed_files.len()
);
for path in changed_files.iter().take(8) {
println!(" {}", path.dimmed());
}
if changed_files.len() > 8 {
println!(
" {} +{} more",
"…".dimmed(),
changed_files.len() - 8
);
}
}
if !suggestions.is_empty() {
println!(
" {} Scopes that *do* match their own baselines:",
"✓".bright_green()
);
for suggestion in suggestions {
let tag = suggestion
.baseline_tag
.as_deref()
.unwrap_or("initial");
println!(
" {} {} file(s), {} target(s) since {}",
suggestion.label.bright_white(),
suggestion.changed_file_count,
suggestion.matched_target_count,
tag.bright_cyan()
);
}
}
let mut actions: Vec<String> = Vec::new();
if let Some(first) = suggestions.first() {
actions.push(format!(
"Switch to {} (has matching changes)",
first.label
));
}
if suggestions.len() > 1 {
actions.push("Pick a different scope with matching changes…".to_string());
}
actions.push(format!(
"Force release {scope_label} anyway (all {} version target(s))",
all_version_targets.len()
));
actions.push("Cancel release".to_string());
let choice = select_one("What do you want to do?", &actions, 0)?
.ok_or_else(|| "Release cancelled (no selection).".to_string())?;
let has_preferred_switch = !suggestions.is_empty();
let has_pick_other = suggestions.len() > 1;
let force_idx = usize::from(has_preferred_switch) + usize::from(has_pick_other);
let cancel_idx = force_idx + 1;
if choice == cancel_idx {
return Err("Release cancelled: no version targets matched the selected scope.".to_string());
}
if choice == force_idx {
println!(
"{} Force-releasing all version targets for {}.",
"→".bright_cyan(),
scope_label.bright_white()
);
return finalize_forced_all_targets(
project_root,
invocation_dir,
version_scope,
changed_files,
all_version_targets,
baseline_source,
baseline_reference,
baseline_tag,
);
}
let switch_to = if choice == 0 && has_preferred_switch {
suggestions[0].scope.clone()
} else if has_pick_other {
let labels: Vec<String> = suggestions
.iter()
.map(|s| {
format!(
"{} · {} file(s) since {}",
s.label,
s.changed_file_count,
s.baseline_tag.as_deref().unwrap_or("initial")
)
})
.collect();
let idx = searchable_select(
"Choose a scope with matching changes",
&labels,
0,
)?
.ok_or_else(|| "Release cancelled (scope switch aborted).".to_string())?;
suggestions
.get(idx)
.map(|s| s.scope.clone())
.ok_or_else(|| "Invalid scope selection.".to_string())?
} else {
return Err("Release cancelled: no alternate scope available.".to_string());
};
let new_label = version_scope_prompt_label(&switch_to);
println!(
"{} Switching release scope to {}…",
"→".bright_cyan(),
new_label.bright_white().bold()
);
save_last_version_scope_preference(project_root, &switch_to);
Ok(ReleaseTargetSelection::SwitchScope(switch_to))
}
fn format_empty_match_error(
scope_label: &str,
baseline_display: &str,
baseline_source: &str,
changed_files: &[String],
suggestions: &[ScopeChangeSuggestion],
) -> String {
let mut msg = format!(
"No version targets for `{scope_label}` matched commits since the last release baseline (`{baseline_display}`, source: {baseline_source})."
);
if !changed_files.is_empty() {
let sample = changed_files
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>()
.join(", ");
msg.push_str(&format!(
" Changed paths since baseline: {sample}{}",
if changed_files.len() > 5 {
format!(" (+{} more)", changed_files.len() - 5)
} else {
String::new()
}
));
}
if !suggestions.is_empty() {
let alts = suggestions
.iter()
.map(|s| format!("{} ({} file(s))", s.label, s.changed_file_count))
.collect::<Vec<_>>()
.join(", ");
msg.push_str(&format!(
" Other scopes with matching changes: {alts}. Re-run and select one of those, or pass `--force` to release `{scope_label}` with all of its version targets anyway."
));
} else {
msg.push_str(&format!(
" Pass `--force` to release `{scope_label}` with all of its version targets anyway, or bump/select a scope that actually changed."
));
}
msg
}
pub(crate) fn collect_scope_change_suggestions(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
current: &VersionScope,
repo: &str,
) -> Vec<ScopeChangeSuggestion> {
let mut scopes =
super::load_service_version_scopes_filtered(
project_root,
invocation_dir,
super::ServiceScopeFilter::Release,
);
scopes.push(VersionScope::Repository);
let current_key = version_scope_guard_id(current);
let mut suggestions = Vec::new();
for scope in scopes {
if version_scope_guard_id(&scope) == current_key {
continue;
}
let Ok(status) = evaluate_scope_change_status(
project_root,
invocation_dir,
registry,
&scope,
repo,
matches!(scope, VersionScope::Repository),
) else {
continue;
};
if !status.has_matched_targets {
continue;
}
suggestions.push(ScopeChangeSuggestion {
label: version_scope_prompt_label(&scope),
baseline_tag: if status.baseline_label == "none" {
None
} else {
Some(status.baseline_label.clone())
},
changed_file_count: status.changed_file_count,
matched_target_count: status.matched_target_count,
scope,
});
}
suggestions.sort_by(|a, b| {
b.matched_target_count
.cmp(&a.matched_target_count)
.then_with(|| b.changed_file_count.cmp(&a.changed_file_count))
.then_with(|| a.label.cmp(&b.label))
});
suggestions
}
pub(crate) fn evaluate_scope_change_status(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
for_release_repository_filter: bool,
) -> Result<ScopeChangeStatus, String> {
let (baseline_source, baseline_reference, baseline_tag) =
resolve_change_baseline(project_root, version_scope, repo)?;
let changed_files =
collect_changed_files_since_reference(project_root, baseline_reference.as_deref())?;
let mut all_version_targets =
resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
if for_release_repository_filter && matches!(version_scope, VersionScope::Repository) {
all_version_targets = filter_version_targets_for_enabled_services(
project_root,
invocation_dir,
all_version_targets,
true,
);
}
let baseline_label = baseline_tag
.clone()
.or(baseline_reference.clone())
.unwrap_or_else(|| {
if baseline_source == "initial-release" {
"initial".to_string()
} else {
"none".to_string()
}
});
if all_version_targets.is_empty() {
return Ok(ScopeChangeStatus {
has_matched_targets: false,
changed_file_count: changed_files.len(),
matched_target_count: 0,
baseline_label,
});
}
if changed_files.is_empty() || baseline_reference.is_none() {
let has = baseline_reference.is_none();
return Ok(ScopeChangeStatus {
has_matched_targets: has,
changed_file_count: changed_files.len(),
matched_target_count: if has {
all_version_targets.len()
} else {
0
},
baseline_label,
});
}
let selected = select_changed_version_targets(
project_root,
version_scope,
&all_version_targets,
&changed_files,
);
Ok(ScopeChangeStatus {
has_matched_targets: !selected.is_empty(),
changed_file_count: changed_files.len(),
matched_target_count: selected.len(),
baseline_label,
})
}
pub(crate) fn select_changed_version_targets(
project_root: &Path,
version_scope: &VersionScope,
targets: &[ResolvedRegistryPath],
changed_files: &[String],
) -> Vec<ResolvedRegistryPath> {
if !matches!(version_scope, VersionScope::Repository) {
if changed_files
.iter()
.any(|path| scope_matches_changed_path(project_root, version_scope, path))
{
return targets.to_vec();
}
return Vec::new();
}
let mut selected = Vec::new();
let mut shared_root_change = false;
for changed_file in changed_files {
let mut matched_any = false;
for target in targets {
if changed_file_matches_version_target(changed_file, target) {
matched_any = true;
if !selected
.iter()
.any(|existing: &ResolvedRegistryPath| existing.relative == target.relative)
{
selected.push(target.clone());
}
}
}
if !matched_any {
shared_root_change = true;
}
}
if shared_root_change {
return dedupe_resolved_registry_paths(targets);
}
selected
}
pub(crate) fn dedupe_resolved_registry_paths(
targets: &[ResolvedRegistryPath],
) -> Vec<ResolvedRegistryPath> {
let mut seen = BTreeSet::new();
targets
.iter()
.filter(|target| seen.insert(target.relative.clone()))
.cloned()
.collect()
}
pub(crate) fn changed_file_matches_version_target(
changed_file: &str,
target: &ResolvedRegistryPath,
) -> bool {
let parent = Path::new(&target.relative)
.parent()
.map(normalized_relative_path)
.unwrap_or_else(|| ".".to_string());
if parent == "." || parent.is_empty() {
return true;
}
changed_file == parent || changed_file.starts_with(&format!("{parent}/"))
}
pub(crate) fn scope_matches_changed_path(
project_root: &Path,
version_scope: &VersionScope,
changed_file: &str,
) -> bool {
match version_scope {
VersionScope::Repository => true,
VersionScope::Crate { crate_root, .. } => {
path_matches_root(project_root, crate_root, changed_file)
}
VersionScope::Service {
service_root,
version_targets,
watch_paths,
service_relative_root,
..
} => service_scope_matches_path(
project_root,
service_root,
service_relative_root,
watch_paths,
version_targets,
changed_file,
),
}
}
pub(crate) fn dirty_path_in_release_scope(
project_root: &Path,
version_scope: &VersionScope,
changed_file: &str,
) -> bool {
let path = changed_file.replace('\\', "/");
let path = path.trim_start_matches("./");
match version_scope {
VersionScope::Repository => true,
VersionScope::Crate { crate_root, .. } => {
path_matches_root(project_root, crate_root, path)
|| (path == "Cargo.lock" && crate_touches_cargo(version_scope))
}
VersionScope::Service {
service_root,
service_relative_root,
version_targets,
watch_paths,
..
} => {
if !watch_paths.is_empty() {
let hit = watch_paths
.iter()
.any(|watch| path_matches_watch_root(path, watch));
if hit {
return true;
}
return path == "Cargo.lock" && service_touches_cargo(version_targets);
}
let rooted_at_project = service_is_project_root(
project_root,
service_root,
service_relative_root,
);
if rooted_at_project {
if version_targets.is_empty() {
return true;
}
let hit = version_targets
.iter()
.any(|target| path_matches_version_target_for_dirty(path, target));
if hit {
return true;
}
return path == "Cargo.lock" && service_touches_cargo(version_targets);
}
service_scope_matches_path(
project_root,
service_root,
service_relative_root,
watch_paths,
version_targets,
path,
) || (path == "Cargo.lock" && service_touches_cargo(version_targets))
}
}
}
fn service_is_project_root(
project_root: &Path,
service_root: &Path,
service_relative_root: &str,
) -> bool {
let rel = service_relative_root.replace('\\', "/");
let rel = rel.trim().trim_start_matches("./");
if rel.is_empty() || rel == "." {
return true;
}
path_matches_root(project_root, service_root, ".")
|| same_path_relaxed(project_root, service_root)
}
fn same_path_relaxed(left: &Path, right: &Path) -> bool {
if left == right {
return true;
}
let l = left.to_string_lossy().replace('\\', "/").to_ascii_lowercase();
let r = right.to_string_lossy().replace('\\', "/").to_ascii_lowercase();
l.trim_end_matches('/') == r.trim_end_matches('/')
}
fn service_touches_cargo(version_targets: &[String]) -> bool {
version_targets.iter().any(|t| {
let n = t.replace('\\', "/");
n.ends_with("Cargo.toml") || n.ends_with("Cargo.lock") || n == "Cargo.toml" || n == "Cargo.lock"
})
}
fn crate_touches_cargo(scope: &VersionScope) -> bool {
matches!(scope, VersionScope::Crate { .. })
}
pub(crate) fn service_scope_matches_path(
project_root: &Path,
service_root: &Path,
service_relative_root: &str,
watch_paths: &[String],
version_targets: &[String],
path: &str,
) -> bool {
if !watch_paths.is_empty() {
return watch_paths
.iter()
.any(|watch| path_matches_watch_root(path, watch));
}
if !service_is_project_root(project_root, service_root, service_relative_root)
&& path_matches_root(project_root, service_root, path)
{
return true;
}
if !version_targets.is_empty() {
return version_targets.iter().any(|target| {
let watch_root = watch_root_from_version_target(target);
path_matches_watch_root(path, &watch_root)
});
}
path_matches_root(project_root, service_root, path)
}
pub(crate) fn path_matches_watch_root(path: &str, watch_root: &str) -> bool {
let normalized = watch_root.replace('\\', "/").trim_matches('/').to_string();
if normalized.is_empty() || normalized == "." {
return true;
}
path == normalized || path.starts_with(&format!("{normalized}/"))
}
pub(crate) fn path_matches_version_target_for_dirty(path: &str, target: &str) -> bool {
let target_norm = target.replace('\\', "/");
let target_norm = target_norm.trim_start_matches("./");
if path == target_norm {
return true;
}
let watch_root = watch_root_from_version_target(target);
let normalized = watch_root.replace('\\', "/").trim_matches('/').to_string();
if normalized.is_empty() || normalized == "." {
if target_norm == "Cargo.toml" && path == "Cargo.lock" {
return true;
}
return false;
}
path == normalized || path.starts_with(&format!("{normalized}/"))
}
pub(crate) fn watch_root_from_version_target(target: &str) -> String {
let normalized = target.replace('\\', "/");
let path = Path::new(&normalized);
let looks_like_file = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| !ext.is_empty())
|| path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
name,
"Cargo.toml"
| "package.json"
| "pyproject.toml"
| "composer.json"
| "Chart.yaml"
| "Chart.yml"
)
});
if looks_like_file {
path.parent()
.map(normalized_relative_path)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ".".to_string())
} else {
normalized
}
}
pub(crate) fn scope_watch_depth(project_root: &Path, scope: &VersionScope) -> usize {
match scope {
VersionScope::Repository => 0,
VersionScope::Crate {
crate_relative_root,
..
} => path_depth(crate_relative_root),
VersionScope::Service {
service_relative_root,
watch_paths,
..
} => {
let from_watch = watch_paths
.iter()
.map(|path| path_depth(path))
.max()
.unwrap_or(0);
let from_root = path_depth(service_relative_root);
let _ = project_root;
from_watch.max(from_root)
}
}
}
pub(crate) fn path_depth(path: &str) -> usize {
let normalized = path.replace('\\', "/").trim_matches('/').to_string();
if normalized.is_empty() || normalized == "." {
return 0;
}
normalized
.split('/')
.filter(|part| !part.is_empty())
.count()
}
pub(crate) fn assign_dirty_paths_to_scopes(
project_root: &Path,
scopes: &[VersionScope],
dirty_paths: &[String],
) -> Vec<(VersionScope, Vec<String>)> {
let mut grouped: BTreeMap<String, (VersionScope, Vec<String>)> = BTreeMap::new();
for path in dirty_paths {
let mut best: Option<(usize, &VersionScope)> = None;
for scope in scopes {
if matches!(scope, VersionScope::Repository) {
continue;
}
if !scope_matches_changed_path(project_root, scope, path) {
continue;
}
let depth = scope_watch_depth(project_root, scope);
best = match best {
Some((best_depth, best_scope)) if depth < best_depth => {
Some((best_depth, best_scope))
}
Some((best_depth, best_scope)) if depth == best_depth => {
if version_scope_prompt_label(scope) < version_scope_prompt_label(best_scope) {
Some((depth, scope))
} else {
Some((best_depth, best_scope))
}
}
_ => Some((depth, scope)),
};
}
if let Some((_, scope)) = best {
let key = version_scope_guard_id(scope);
grouped
.entry(key)
.and_modify(|(_, paths)| paths.push(path.clone()))
.or_insert_with(|| (scope.clone(), vec![path.clone()]));
}
}
let mut result = grouped.into_values().collect::<Vec<_>>();
for (_, paths) in &mut result {
paths.sort();
paths.dedup();
}
result.sort_by(|(left, _), (right, _)| {
version_scope_prompt_label(left).cmp(&version_scope_prompt_label(right))
});
result
}
pub(crate) fn path_matches_root(project_root: &Path, scope_root: &Path, path: &str) -> bool {
let relative_root = collapse_project_path(project_root, &scope_root.to_string_lossy());
let relative_root = if relative_root == "./" {
".".to_string()
} else {
relative_root.trim_start_matches("./").to_string()
};
path_matches_target(path, &relative_root)
}
pub(crate) 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}/"))
}
pub(crate) fn resolve_selected_publish_targets(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
selected_version_targets: &[ResolvedRegistryPath],
select_all: bool,
) -> Result<Vec<ReleasePublishTarget>, String> {
let Some((_, config)) = load_version_target_config(invocation_dir) else {
return Ok(Vec::new());
};
let Some(publish) = config.publish else {
return Ok(Vec::new());
};
let selected_version_paths = selected_version_targets
.iter()
.map(|target| target.relative.as_str())
.collect::<BTreeSet<_>>();
let mut targets = Vec::new();
for (kind, target) in [("npm", publish.npm), ("crates", publish.crates)] {
let Some(target) = target.filter(|value| value.enabled.unwrap_or(true)) else {
continue;
};
for entry in target.package_entries() {
let manifest_path = entry.manifest_path.clone().map(PathBuf::from).or_else(|| {
entry
.working_directory
.clone()
.map(PathBuf::from)
.map(|directory| {
directory.join(if kind == "npm" {
"package.json"
} else {
"Cargo.toml"
})
})
});
let Some(manifest_path) = manifest_path else {
continue;
};
let relative = manifest_path
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&manifest_path));
let include = if matches!(version_scope, VersionScope::Service { .. }) {
let manifest_absolute = if manifest_path.is_absolute() {
manifest_path.clone()
} else {
project_root.join(&manifest_path)
};
version_scope_root(version_scope)
.is_some_and(|root| manifest_absolute.starts_with(root))
} else if select_all || selected_version_paths.contains(relative.as_str()) {
true
} else {
false
};
if include {
targets.push(ReleasePublishTarget {
kind: kind.to_string(),
manifest_path: relative,
package_name: entry
.package_name
.clone()
.filter(|value| !value.trim().is_empty()),
});
}
}
}
Ok(targets)
}
pub(crate) fn read_highest_version_from_targets(
targets: &[ResolvedRegistryPath],
) -> Result<Option<Version>, String> {
let mut highest = None;
for target in targets {
if !target.absolute.exists() {
continue;
}
match read_version_from_resolved_path(target) {
Ok(Some(version)) => {
let parsed = parse_version(&version)?;
if highest
.as_ref()
.is_none_or(|current: &Version| parsed > *current)
{
highest = Some(parsed);
}
}
Ok(None) => {}
Err(err) => return Err(err),
}
}
Ok(highest)
}
pub(crate) fn write_version_to_selected_paths(
targets: &[ResolvedRegistryPath],
version: &Version,
allow_noop_when_targets_exist: bool,
) -> Result<Vec<PathBuf>, String> {
let mut updated = 0usize;
let mut matched_targets = 0usize;
let mut updated_paths = Vec::new();
let mut errors = Vec::new();
for entry in targets {
let path = &entry.absolute;
if !path.exists() {
continue;
}
matched_targets += 1;
match write_version_to_resolved_path(entry, version) {
Ok(true) => {
updated += 1;
updated_paths.push(path.clone());
}
Ok(false) => {}
Err(error) => errors.push(format!("{}: {}", path.display(), error)),
}
}
if matched_targets == 0 && errors.is_empty() {
return Err("No selected version files were found to update.".to_string());
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some selected version targets failed:\n{}",
updated,
errors.join("\n")
));
}
if updated == 0 && allow_noop_when_targets_exist {
return Ok(updated_paths);
}
Ok(updated_paths)
}
pub(crate) fn print_selected_release_targets(selection: &ChangedTargetSelection) {
println!("{}", "Selected release targets".bright_cyan().bold());
if selection.version_targets.is_empty() {
println!(" {}", "No version targets selected".dimmed());
} else {
println!(" {}", "Version targets".bright_white());
for target in &selection.version_targets {
println!(" {} {}", "•".bright_cyan(), target.relative);
}
}
if selection.publish_targets.is_empty() {
println!(" {}", "Publish targets: none".dimmed());
} else {
println!(" {}", "Publish targets".bright_white());
for target in &selection.publish_targets {
println!(
" {} {} {}",
"•".bright_cyan(),
target.kind,
target.manifest_path
);
}
}
}
pub(crate) fn detect_package_name_from_manifest(
kind: &str,
manifest_path: &Path,
) -> Option<String> {
match kind {
"npm" => {
let content = fs::read_to_string(manifest_path).ok()?;
let json: JsonValue = serde_json::from_str(&content).ok()?;
json.get("name")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
"crates" => cargo_package_name(manifest_path).ok().flatten(),
_ => None,
}
}
pub(crate) fn publish_target_filter_from_selection(
selected_publish_targets: &[ReleasePublishTarget],
) -> Result<Option<String>, String> {
let kinds = selected_publish_targets
.iter()
.map(|target| target.kind.as_str())
.collect::<BTreeSet<_>>();
if kinds.is_empty() || kinds.len() > 1 {
return Ok(None);
}
let kind = kinds.iter().next().copied().unwrap_or_default();
if kind.is_empty() {
return Ok(None);
}
match kind {
"npm" | "crates" => Ok(Some(kind.to_string())),
other => Err(format!(
"Unsupported selected publish target kind `{}` in release ledger.",
other
)),
}
}
#[cfg(test)]
mod tests {
use super::{resolve_selected_publish_targets, VersionScope};
use std::fs;
#[test]
fn service_release_does_not_inherit_repository_publish_target() {
let root = std::env::temp_dir().join(format!(
"xbp-service-publish-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
let service_root = root.join("services").join("athena-operator");
fs::create_dir_all(root.join(".xbp")).expect("config dir");
fs::create_dir_all(&service_root).expect("service dir");
fs::write(
root.join(".xbp").join("xbp.yaml"),
r#"
project_name: athena
version: 0.2.0
port: 3000
build_dir: ./
publish:
crates:
enabled: true
package_name: athena_rs
manifest_path: Cargo.toml
services:
- name: athena-operator
target: go
branch: main
port: 82
root_directory: services/athena-operator
version_targets:
- services/athena-operator/README.md
"#,
)
.expect("config");
let scope = VersionScope::Service {
service_root,
service_relative_root: "services/athena-operator".to_string(),
service_name: "athena-operator".to_string(),
tag_prefix: "athena-operator-".to_string(),
cargo_package_name: None,
version_targets: vec!["services/athena-operator/README.md".to_string()],
watch_paths: vec!["services/athena-operator".to_string()],
};
let targets: Vec<crate::commands::version::release_ledger::ReleasePublishTarget> =
resolve_selected_publish_targets(&root, &root, &scope, &[], true).expect("targets");
assert!(targets.is_empty());
let _ = fs::remove_dir_all(root);
}
#[test]
fn repository_toml_config_loads_crates_publish_target() {
let root = std::env::temp_dir().join(format!(
"xbp-toml-publish-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos()
));
let xbp_dir = root.join(".xbp");
let crate_dir = root.join("crates").join("cli");
fs::create_dir_all(&xbp_dir).expect("config dir");
fs::create_dir_all(&crate_dir).expect("crate dir");
fs::write(
crate_dir.join("Cargo.toml"),
r#"[package]
name = "xbp"
version = "1.2.3"
edition = "2021"
"#,
)
.expect("manifest");
fs::write(
xbp_dir.join("xbp.toml"),
r#"
project_name = "xbp"
version = "1.2.3"
port = 3000
build_dir = "./"
version_targets = ["crates/cli/Cargo.toml"]
[publish.crates]
enabled = true
package_name = "xbp"
manifest_path = "crates/cli/Cargo.toml"
"#,
)
.expect("toml config");
let scope = VersionScope::Repository;
let selected = vec![crate::commands::version::ResolvedRegistryPath {
relative: "crates/cli/Cargo.toml".to_string(),
absolute: crate_dir.join("Cargo.toml"),
cargo_package_override: None,
}];
let targets = resolve_selected_publish_targets(&root, &root, &scope, &selected, false)
.expect("targets");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].kind, "crates");
assert_eq!(targets[0].manifest_path, "crates/cli/Cargo.toml");
assert_eq!(targets[0].package_name.as_deref(), Some("xbp"));
let _ = fs::remove_dir_all(root);
}
}