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::path::{Path, PathBuf};
use super::release_ledger::{find_latest_successful_release_ledger, ReleasePublishTarget};
use super::{
cargo_package_name, collect_remote_git_versions, current_scope_slug, default_release_tag_name,
filter_version_targets_for_enabled_services, is_nested_repository_scope,
load_version_target_config, normalized_relative_path, parse_version,
read_version_from_resolved_path, resolve_registry_paths, run_git_command, version_scope_guard_id,
version_scope_prompt_label, version_scope_root, write_version_to_resolved_path,
ChangedTargetSelection, ResolvedRegistryPath,
};
use super::VersionScope;
pub(crate) fn resolve_change_baseline(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
) -> 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)? {
if let Some(tag) = ledger.baseline.tag.or(Some(ledger.tag_name)) {
return Ok(("release-ledger".to_string(), Some(tag.clone()), Some(tag)));
}
}
if is_nested_repository_scope(project_root, version_scope) {
return Ok(("initial-release".to_string(), None, None));
}
let tags = collect_remote_git_versions(project_root, "origin", version_scope)?;
if let Some(latest) = tags.first() {
let tag = if let Some(tag) = latest.raw_tags.first().cloned() {
tag
} else {
default_release_tag_name(version_scope, &latest.version, None)?
};
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 mut args = vec!["diff", "--name-only"];
if let Some(reference) = baseline_ref
.map(str::trim)
.filter(|value| !value.is_empty())
{
args.push(reference);
args.push("HEAD");
}
let output = run_git_command(project_root, &args)?;
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)
}
pub(crate) fn resolve_changed_target_selection(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
) -> Result<ChangedTargetSelection, String> {
resolve_changed_target_selection_with_mode(
project_root,
invocation_dir,
registry,
version_scope,
repo,
false,
)
}
pub(crate) fn resolve_changed_target_selection_for_release(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
repo: &str,
) -> Result<ChangedTargetSelection, String> {
resolve_changed_target_selection_with_mode(
project_root,
invocation_dir,
registry,
version_scope,
repo,
true,
)
}
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> {
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 && 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 version_targets = if changed_files.is_empty() || baseline_reference.is_none() {
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 Err(
"No version targets matched commits since the last release baseline.".to_string(),
);
}
selected
};
let publish_targets = resolve_selected_publish_targets(
project_root,
invocation_dir,
version_scope,
&version_targets,
changed_files.is_empty() || baseline_reference.is_none(),
)?;
Ok(ChangedTargetSelection {
changed_files,
version_targets,
publish_targets,
baseline_source,
baseline_reference,
baseline_tag,
})
}
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_scope_matches_path(
project_root,
service_root,
watch_paths,
version_targets,
changed_file,
),
}
}
pub(crate) fn service_scope_matches_path(
project_root: &Path,
service_root: &Path,
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 path_matches_root(project_root, service_root, path) {
return true;
}
version_targets.iter().any(|target| {
let watch_root = watch_root_from_version_target(target);
path_matches_watch_root(path, &watch_root)
})
}
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 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;
};
let manifest_path = target.manifest_path.clone().map(PathBuf::from).or_else(|| {
target
.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 select_all || selected_version_paths.contains(relative.as_str()) {
true
} else if matches!(version_scope, VersionScope::Service { .. }) {
version_scope_root(version_scope).is_some_and(|root| manifest_path.starts_with(root))
} else {
false
};
if include {
targets.push(ReleasePublishTarget {
kind: kind.to_string(),
manifest_path: relative,
package_name: target
.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
)),
}
}