use crate::strategies::normalize_config_paths_for_persistence;
use crate::utils::{
collapse_project_path, find_xbp_config_upwards,
maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal, resolve_project_path,
};
use crate::strategies::XbpConfig;
use semver::Version;
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;
use super::adapters::{
cargo_package_name_from_content_optional,
write_version_to_resolved_path,
};
use super::{
is_nested_repository_scope, normalized_relative_path, path_is_within,
version_scope_root, ResolvedRegistryPath, VersionScope, WorkspacePrimaryCargoTarget,
};
pub(crate) fn filter_version_targets_for_enabled_services(
project_root: &Path,
invocation_dir: &Path,
targets: Vec<ResolvedRegistryPath>,
for_release: bool,
) -> Vec<ResolvedRegistryPath> {
let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
return targets;
};
let Some(services) = config.services.as_ref() else {
return targets;
};
let disabled_only_targets: BTreeSet<String> = services
.iter()
.filter(|service| {
if for_release {
!crate::strategies::service_release_allowed(&config, service)
} else {
!crate::strategies::service_versioning_allowed(&config, service)
}
})
.flat_map(|service| service.version_targets.clone().unwrap_or_default())
.map(|target| {
let relative = collapse_project_path(project_root, &target);
relative
.trim_start_matches("./")
.replace('\\', "/")
.trim_matches('/')
.to_string()
})
.collect();
if disabled_only_targets.is_empty() {
return targets;
}
let enabled_targets: BTreeSet<String> = services
.iter()
.filter(|service| {
if for_release {
crate::strategies::service_release_allowed(&config, service)
} else {
crate::strategies::service_versioning_allowed(&config, service)
}
})
.flat_map(|service| service.version_targets.clone().unwrap_or_default())
.map(|target| {
let relative = collapse_project_path(project_root, &target);
relative
.trim_start_matches("./")
.replace('\\', "/")
.trim_matches('/')
.to_string()
})
.collect();
targets
.into_iter()
.filter(|target| {
let rel = target
.relative
.trim_start_matches("./")
.replace('\\', "/")
.trim_matches('/')
.to_string();
if enabled_targets.contains(&rel) {
return true;
}
!disabled_only_targets.contains(&rel)
})
.collect()
}
pub(crate) fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
let found = find_xbp_config_upwards(invocation_dir)?;
let config_path = if found.kind == "json" {
maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
.ok()
.flatten()
.unwrap_or_else(|| found.config_path.clone())
} else {
found.config_path.clone()
};
let kind = if config_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
.unwrap_or(false)
{
"yaml"
} else {
"json"
};
let content = fs::read_to_string(&config_path).ok()?;
let (mut config, healed): (XbpConfig, Option<String>) =
parse_config_with_auto_heal(&content, kind).ok()?;
if let Some(healed_content) = healed {
let _ = fs::write(&config_path, healed_content);
}
normalize_config_paths_for_persistence(&mut config, &found.project_root);
Some((found.project_root, config))
}
pub(crate) fn resolve_configured_version_target_paths(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Vec<ResolvedRegistryPath> {
let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
return Vec::new();
};
let service_targets = match version_scope {
VersionScope::Service {
version_targets, ..
} if !version_targets.is_empty() => Some(version_targets.clone()),
_ => None,
};
let manifest_paths = if let Some(service_targets) = &service_targets {
service_targets
.iter()
.cloned()
.map(PathBuf::from)
.collect::<Vec<_>>()
} else {
let mut targets = config
.version_targets
.into_iter()
.map(PathBuf::from)
.collect::<Vec<_>>();
targets.extend(
config
.publish
.into_iter()
.flat_map(|publish| [publish.npm, publish.crates])
.flatten()
.filter_map(|target| target.manifest_path)
.map(PathBuf::from),
);
targets
};
let scope_root = match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
};
manifest_paths
.into_iter()
.filter_map(|manifest_path| {
let absolute = PathBuf::from(resolve_project_path(
&config_root,
&manifest_path.to_string_lossy(),
));
if !absolute.exists() {
return None;
}
if let Some(scope_root) = scope_root {
if !path_is_within(&absolute, scope_root) {
return None;
}
}
let relative = collapse_project_path(project_root, &absolute.to_string_lossy());
let relative = if relative == "./" {
normalized_relative_path(&absolute)
} else {
relative.trim_start_matches("./").to_string()
};
Some(ResolvedRegistryPath {
relative,
absolute,
cargo_package_override: None,
})
})
.collect()
}
pub(crate) fn resolve_registry_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
) -> Vec<ResolvedRegistryPath> {
let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
let nested_repository_scope = is_nested_repository_scope(project_root, version_scope);
let workspace_primary_target = match version_scope {
VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
};
for relative in registry {
match version_scope {
VersionScope::Repository => {
if *relative == *"Cargo.lock" {
let resolved_relative = resolve_registry_relative_path(
project_root,
invocation_dir,
version_scope,
relative,
);
let absolute = project_root.join(&resolved_relative);
if nested_repository_scope && !absolute.exists() {
continue;
}
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute,
relative: resolved_relative,
cargo_package_override: workspace_primary_target
.as_ref()
.map(|target| target.package_name.clone()),
});
continue;
}
if *relative == *"Cargo.toml" {
if let Some(target) = workspace_primary_target.as_ref() {
if !seen.insert(target.manifest_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: target.manifest_absolute.clone(),
relative: target.manifest_relative.clone(),
cargo_package_override: None,
});
continue;
}
}
let resolved_relative = resolve_registry_relative_path(
project_root,
invocation_dir,
version_scope,
relative,
);
let absolute = project_root.join(&resolved_relative);
if nested_repository_scope && !absolute.exists() {
continue;
}
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute,
relative: resolved_relative,
cargo_package_override: None,
});
}
VersionScope::Crate {
crate_root,
package_name,
..
} => {
if *relative == *"Cargo.lock" {
let cargo_lock = project_root.join("Cargo.lock");
if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
resolved.push(ResolvedRegistryPath {
absolute: cargo_lock,
relative: "Cargo.lock".to_string(),
cargo_package_override: Some(package_name.clone()),
});
}
continue;
}
let preferred = crate_root.join(relative);
if !preferred.exists() {
continue;
}
let Ok(stripped) = preferred.strip_prefix(project_root) else {
continue;
};
let resolved_relative = normalized_relative_path(stripped);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: preferred,
relative: resolved_relative,
cargo_package_override: None,
});
}
VersionScope::Service {
service_root,
cargo_package_name,
..
} => {
if *relative == *"Cargo.lock" {
let local_cargo_lock = service_root.join("Cargo.lock");
if local_cargo_lock.exists() {
let relative_path = local_cargo_lock
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
if seen.insert(relative_path.clone()) {
resolved.push(ResolvedRegistryPath {
absolute: local_cargo_lock,
relative: relative_path,
cargo_package_override: cargo_package_name.clone(),
});
}
} else if let Some(package_name) = cargo_package_name.as_ref() {
let workspace_cargo_lock = project_root.join("Cargo.lock");
if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
resolved.push(ResolvedRegistryPath {
absolute: workspace_cargo_lock,
relative: "Cargo.lock".to_string(),
cargo_package_override: Some(package_name.clone()),
});
}
}
continue;
}
let preferred = service_root.join(relative);
if !preferred.exists() {
continue;
}
let Ok(stripped) = preferred.strip_prefix(project_root) else {
continue;
};
let resolved_relative = normalized_relative_path(stripped);
if !seen.insert(resolved_relative.clone()) {
continue;
}
resolved.push(ResolvedRegistryPath {
absolute: preferred,
relative: resolved_relative,
cargo_package_override: None,
});
}
}
}
for configured_path in
resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
{
if seen.insert(configured_path.relative.clone()) {
resolved.push(configured_path);
}
}
if matches!(version_scope, VersionScope::Repository) {
resolved = filter_version_targets_for_enabled_services(
project_root,
invocation_dir,
resolved,
false,
);
}
resolved
}
pub(crate) fn resolve_registry_relative_path(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
relative: &str,
) -> String {
if let Some(scope_root) = version_scope_root(version_scope) {
let preferred = scope_root.join(relative);
if preferred.exists() {
if let Ok(stripped) = preferred.strip_prefix(project_root) {
return normalized_relative_path(stripped);
}
}
return relative.replace('\\', "/");
}
if relative == "Cargo.toml" {
if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
return target.manifest_relative;
}
}
let preferred: PathBuf = invocation_dir.join(relative);
if preferred.exists() {
if let Ok(stripped) = preferred.strip_prefix(project_root) {
return normalized_relative_path(stripped);
}
}
relative.replace('\\', "/")
}
#[cfg(test)]
pub(crate) fn resolve_release_publish_target_filter(
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Result<Option<String>, String> {
let VersionScope::Service {
service_name,
service_root,
version_targets,
..
} = version_scope
else {
return Ok(None);
};
let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
return Ok(None);
};
let Some(publish) = config.publish else {
return Ok(None);
};
let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
.into_iter()
.filter_map(|(kind, target)| {
let target = target?;
if target.enabled.unwrap_or(true) {
Some((kind, target))
} else {
None
}
})
.collect::<Vec<_>>();
if enabled_targets.is_empty() {
return Ok(None);
}
let matched = enabled_targets
.iter()
.filter_map(|(kind, target)| {
let manifest_path = target.manifest_path.as_ref()?;
let absolute = if Path::new(manifest_path).is_absolute() {
PathBuf::from(manifest_path)
} else {
project_root.join(manifest_path)
};
let relative = absolute
.strip_prefix(&project_root)
.ok()
.map(normalized_relative_path)
.unwrap_or_else(|| normalized_relative_path(&absolute));
if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
{
Some((*kind).to_string())
} else {
None
}
})
.collect::<Vec<_>>();
if matched.is_empty() {
return Err(format!(
"`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
service_name
));
}
if matched.len() == enabled_targets.len() {
if matched.len() == 1 {
return Ok(matched.into_iter().next());
}
return Ok(None);
}
if matched.len() == 1 {
return Ok(matched.into_iter().next());
}
Err(format!(
"Service `{}` matches multiple publish targets while other publish targets are also enabled. Narrow the publish config before using `xbp version release --publish` for this service.",
service_name
))
}
pub(crate) fn resolve_workspace_primary_cargo_target(
project_root: &Path,
) -> Option<WorkspacePrimaryCargoTarget> {
let workspace_manifest = project_root.join("Cargo.toml");
let content = fs::read_to_string(&workspace_manifest).ok()?;
let value: TomlValue = toml::from_str(&content).ok()?;
if value.get("package").and_then(TomlValue::as_table).is_some() {
return None;
}
let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
let mut candidate_roots = workspace
.get("default-members")
.and_then(TomlValue::as_array)
.map(|members| {
members
.iter()
.filter_map(TomlValue::as_str)
.map(str::trim)
.filter(|member| !member.is_empty() && !member.contains('*'))
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if candidate_roots.is_empty() {
let members = workspace
.get("members")
.and_then(TomlValue::as_array)
.map(|members| {
members
.iter()
.filter_map(TomlValue::as_str)
.map(str::trim)
.filter(|member| !member.is_empty() && !member.contains('*'))
.map(str::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if members.len() == 1 {
candidate_roots = members;
}
}
candidate_roots.sort();
candidate_roots.dedup();
if candidate_roots.len() != 1 {
return None;
}
let crate_relative_root = candidate_roots.into_iter().next()?;
let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
Some(WorkspacePrimaryCargoTarget {
manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
manifest_absolute,
package_name,
})
}
#[cfg(test)]
pub(crate) fn sync_version_to_configured_files_with_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
) -> Result<Vec<PathBuf>, String> {
write_version_to_configured_files_with_paths_internal(
project_root,
invocation_dir,
registry,
version_scope,
version,
true,
)
}
#[cfg(test)]
pub(crate) fn write_version_to_configured_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
) -> Result<usize, String> {
write_version_to_configured_files_with_paths(
project_root,
invocation_dir,
registry,
version_scope,
version,
)
.map(|paths| paths.len())
}
pub(crate) fn write_version_to_configured_files_with_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
version: &Version,
) -> Result<Vec<PathBuf>, String> {
write_version_to_configured_files_with_paths_internal(
project_root,
invocation_dir,
registry,
version_scope,
version,
false,
)
}
pub(crate) fn write_version_to_configured_files_with_paths_internal(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
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 resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
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(err) => errors.push(format!("{}: {}", path.display(), err)),
}
}
if matched_targets == 0 && errors.is_empty() {
return Err("No configured version files were found to update.".to_string());
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some version targets failed:\n{}",
updated,
errors.join("\n")
));
}
if updated == 0 && allow_noop_when_targets_exist {
return Ok(updated_paths);
}
Ok(updated_paths)
}