use crate::strategies::ServiceConfig;
use crate::utils::{
collapse_project_path, resolve_project_path,
};
use crate::cli::interactive::searchable_select;
use semver::Version;
use std::fs;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use super::adapters::cargo_package_name_from_content_optional;
use super::git_ops::default_release_tag_slug;
use super::release_ledger::{release_ledger_path, release_ledger_scope_slug};
use super::{
enclosing_git_root, load_version_target_config, normalized_relative_path, same_filesystem_path,
watch_root_from_version_target, LAST_VERSION_SCOPE_FILE, ServiceScopeFilter, VersionScope,
};
pub(crate) fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
let version_targets = service
.version_targets
.clone()
.unwrap_or_default()
.into_iter()
.map(|target| collapse_project_path(project_root, &target))
.map(|target| {
if target == "./" {
".".to_string()
} else {
target.trim_start_matches("./").to_string()
}
})
.filter(|target| !target.is_empty())
.collect::<Vec<_>>();
let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
let service_relative_root =
collapse_project_path(project_root, &service_root.to_string_lossy());
let service_relative_root = if service_relative_root == "./" {
".".to_string()
} else {
service_relative_root.trim_start_matches("./").to_string()
};
let cargo_package_name =
resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
let watch_paths = derive_service_watch_paths(
project_root,
&service,
&version_targets,
&service_relative_root,
);
let service_name = service.name.clone();
let tag_prefix = format!("{}-", default_release_tag_slug(&service_name));
Some(VersionScope::Service {
service_root,
service_relative_root,
service_name,
tag_prefix,
cargo_package_name,
version_targets,
watch_paths,
})
}
pub(crate) fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
let mut parents = version_targets.iter().filter_map(|target| {
let absolute = PathBuf::from(resolve_project_path(project_root, target));
absolute.parent().map(Path::to_path_buf)
});
let mut common = parents.next()?;
for parent in parents {
while !parent.starts_with(&common) {
common = common.parent()?.to_path_buf();
}
}
Some(common)
}
pub(crate) fn current_scope_ledger_path(
project_root: &Path,
version_scope: &VersionScope,
repo: &str,
version: &Version,
) -> PathBuf {
release_ledger_path(
project_root,
¤t_scope_slug(version_scope, repo),
&version.to_string(),
)
}
pub(crate) fn current_scope_slug(version_scope: &VersionScope, repo: &str) -> String {
release_ledger_scope_slug(
version_scope_kind(version_scope),
&version_scope_label(version_scope, repo),
)
}
pub(crate) fn derive_service_watch_paths(
project_root: &Path,
service: &ServiceConfig,
version_targets: &[String],
service_relative_root: &str,
) -> Vec<String> {
let mut paths = Vec::new();
if let Some(explicit) = &service.watch_paths {
for path in explicit {
let relative = normalize_watch_path_relative(project_root, path);
if !relative.is_empty() {
paths.push(relative);
}
}
}
if paths.is_empty() {
if let Some(root_directory) = &service.root_directory {
let relative = normalize_watch_path_relative(project_root, root_directory);
if !relative.is_empty() && relative != "." {
paths.push(relative);
}
}
}
if paths.is_empty() {
for target in version_targets {
let relative = normalize_watch_path_relative(
project_root,
&watch_root_from_version_target(target),
);
if !relative.is_empty() && relative != "." {
paths.push(relative);
}
}
}
if paths.is_empty() && !service_relative_root.is_empty() && service_relative_root != "." {
paths.push(service_relative_root.replace('\\', "/"));
}
paths.sort();
paths.dedup();
paths
}
pub(crate) fn ensure_scope_allows_release(
project_root: &Path,
invocation_dir: &Path,
version_scope: &VersionScope,
) -> Result<(), String> {
let VersionScope::Service {
service_name,
service_relative_root,
..
} = version_scope
else {
return Ok(());
};
let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
return Ok(());
};
let Some(services) = config.services.as_ref() else {
return Ok(());
};
let Some(service) = services.iter().find(|service| {
service.name == *service_name
|| service
.root_directory
.as_deref()
.map(|root| {
let normalized = root
.replace('\\', "/")
.trim_start_matches("./")
.trim_matches('/')
.to_string();
normalized == *service_relative_root
|| normalized == service_relative_root.replace('\\', "/")
})
.unwrap_or(false)
}) else {
return Ok(());
};
if !crate::strategies::service_release_allowed(&config, service) {
let _ = project_root;
return Err(format!(
"Service '{service_name}' has releasing disabled \
(set `release: false`, or list it under `release_disabled` / `versioning_disabled` in `.xbp/xbp.yaml`)."
));
}
Ok(())
}
pub(crate) fn infer_version_scope_name(
project_root: &Path,
path: &Path,
) -> Option<(Option<String>, Option<String>)> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
project_root.join(path)
};
let relative = absolute.strip_prefix(project_root).ok()?;
let mut components = relative.components();
let first = components.next()?.as_os_str().to_string_lossy().to_string();
let second = components
.next()
.map(|value| value.as_os_str().to_string_lossy().to_string());
if first == "crates" {
return second.map(|crate_name| (Some(crate_name), None));
}
if first == "apps" {
return second.map(|service_name| (None, Some(service_name)));
}
None
}
pub(crate) fn is_nested_repository_scope(project_root: &Path, version_scope: &VersionScope) -> bool {
is_nested_repository_scope_with_git_root(
project_root,
version_scope,
enclosing_git_root(project_root).as_deref(),
)
}
pub(crate) fn is_nested_repository_scope_with_git_root(
project_root: &Path,
version_scope: &VersionScope,
git_root: Option<&Path>,
) -> bool {
matches!(version_scope, VersionScope::Repository)
&& git_root.is_some_and(|root| !same_filesystem_path(root, project_root))
}
pub(crate) fn last_version_scope_path(project_root: &Path) -> PathBuf {
project_root.join(".xbp").join(LAST_VERSION_SCOPE_FILE)
}
pub(crate) fn load_last_version_scope_preference(project_root: &Path) -> Option<String> {
let path = last_version_scope_path(project_root);
let content = fs::read_to_string(path).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
load_service_version_scopes_filtered(project_root, invocation_dir, ServiceScopeFilter::Versioning)
}
pub(crate) fn load_service_version_scopes_filtered(
project_root: &Path,
invocation_dir: &Path,
filter: ServiceScopeFilter,
) -> Vec<VersionScope> {
let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
return Vec::new();
};
let Some(services) = config.services.clone() else {
return Vec::new();
};
let mut scopes = services
.into_iter()
.filter(|service| match filter {
ServiceScopeFilter::Versioning => {
crate::strategies::service_versioning_allowed(&config, service)
}
ServiceScopeFilter::Release => {
crate::strategies::service_release_allowed(&config, service)
}
})
.filter_map(|service| build_service_scope(project_root, service))
.collect::<Vec<_>>();
scopes.sort_by(|left, right| {
version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
});
scopes
}
pub(crate) fn normalize_watch_path_relative(project_root: &Path, raw: &str) -> String {
let relative = collapse_project_path(project_root, raw);
let trimmed = relative
.trim_start_matches("./")
.trim_matches('/')
.replace('\\', "/");
if trimmed.is_empty() {
".".to_string()
} else {
trimmed
}
}
pub(crate) fn path_is_within(path: &Path, root: &Path) -> bool {
if path.starts_with(root) || same_filesystem_path(path, root) {
return true;
}
let path_s = path.to_string_lossy().replace('\\', "/");
let root_s = root
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string();
path_s.len() >= root_s.len()
&& path_s
.get(..root_s.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(&root_s))
&& (path_s.len() == root_s.len() || path_s.as_bytes().get(root_s.len()) == Some(&b'/'))
}
pub(crate) fn prompt_for_version_scope_selection_in(
project_root: &Path,
service_scopes: &[VersionScope],
) -> Result<VersionScope, String> {
let mut items = service_scopes
.iter()
.map(version_scope_prompt_label)
.collect::<Vec<_>>();
items.push("Repository (all configured version targets)".to_string());
let last_preference = load_last_version_scope_preference(project_root);
let default_index = last_preference
.as_deref()
.and_then(|pref| {
if pref == "repository" {
return Some(items.len().saturating_sub(1));
}
service_scopes
.iter()
.position(|scope| version_scope_preference_key(scope) == pref)
})
.unwrap_or_else(|| items.len().saturating_sub(1));
let selection = searchable_select(
"Choose the project/service to version or release",
&items,
default_index,
)?;
let scope = match selection {
Some(index) if index < service_scopes.len() => service_scopes[index].clone(),
_ => VersionScope::Repository,
};
save_last_version_scope_preference(project_root, &scope);
Ok(scope)
}
pub(crate) fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
let cargo_toml = crate_root.join("Cargo.toml");
let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
let crate_relative_root = crate_root
.strip_prefix(project_root)
.ok()
.map(normalized_relative_path)?;
Some(VersionScope::Crate {
crate_root,
crate_relative_root,
tag_prefix: format!("{}-", default_release_tag_slug(&package_name)),
package_name,
})
}
pub(crate) fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
let crates_root = project_root.join("crates");
let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
let mut components = relative.components();
let crate_name = components.next()?;
Some(crates_root.join(crate_name.as_os_str()))
}
pub(crate) fn resolve_release_scope_with_prompt(
project_root: &Path,
invocation_dir: &Path,
) -> Result<VersionScope, String> {
resolve_version_scope_with_prompt_filtered(
project_root,
invocation_dir,
ServiceScopeFilter::Release,
)
}
pub(crate) fn resolve_service_scope_cargo_package_name(
project_root: &Path,
service_root: &Path,
version_targets: &[String],
) -> Option<String> {
let mut cargo_paths = version_targets
.iter()
.filter(|target| {
Path::new(target)
.file_name()
.and_then(|value| value.to_str())
== Some("Cargo.toml")
})
.map(|target| {
let path = PathBuf::from(target);
if path.is_absolute() {
path
} else {
project_root.join(path)
}
})
.collect::<Vec<_>>();
if cargo_paths.is_empty() {
cargo_paths.push(service_root.join("Cargo.toml"));
}
cargo_paths.into_iter().find_map(|path| {
let content = fs::read_to_string(path).ok()?;
cargo_package_name_from_content_optional(&content)
.ok()
.flatten()
})
}
pub(crate) fn resolve_service_scope_root(
project_root: &Path,
service: &ServiceConfig,
version_targets: &[String],
) -> Option<PathBuf> {
if !version_targets.is_empty() {
return common_parent_from_targets(project_root, version_targets);
}
service
.root_directory
.as_ref()
.map(|root| PathBuf::from(resolve_project_path(project_root, root)))
}
pub(crate) fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
resolve_version_scope_filtered(
project_root,
invocation_dir,
ServiceScopeFilter::Versioning,
)
}
pub(crate) fn resolve_version_scope_filtered(
project_root: &Path,
invocation_dir: &Path,
filter: ServiceScopeFilter,
) -> VersionScope {
let service_scopes =
load_service_version_scopes_filtered(project_root, invocation_dir, filter);
if let Some(service_scope) =
select_matching_service_scope(&service_scopes, project_root, invocation_dir)
{
return service_scope;
}
if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
return crate_scope;
}
VersionScope::Repository
}
pub(crate) fn resolve_version_scope_with_prompt(
project_root: &Path,
invocation_dir: &Path,
) -> Result<VersionScope, String> {
resolve_version_scope_with_prompt_filtered(
project_root,
invocation_dir,
ServiceScopeFilter::Versioning,
)
}
pub(crate) fn resolve_version_scope_with_prompt_filtered(
project_root: &Path,
invocation_dir: &Path,
filter: ServiceScopeFilter,
) -> Result<VersionScope, String> {
let service_scopes =
load_service_version_scopes_filtered(project_root, invocation_dir, filter);
if let Some(scope) =
select_matching_service_scope(&service_scopes, project_root, invocation_dir)
{
save_last_version_scope_preference(project_root, &scope);
return Ok(scope);
}
if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
if !same_filesystem_path(invocation_dir, project_root) {
save_last_version_scope_preference(project_root, &crate_scope);
return Ok(crate_scope);
}
}
if same_filesystem_path(invocation_dir, project_root)
&& service_scopes.len() > 1
&& std::io::stdin().is_terminal()
{
return prompt_for_version_scope_selection_in(project_root, &service_scopes);
}
let scope = resolve_version_scope_filtered(project_root, invocation_dir, filter);
save_last_version_scope_preference(project_root, &scope);
Ok(scope)
}
pub(crate) fn save_last_version_scope_preference(project_root: &Path, scope: &VersionScope) {
let label = version_scope_preference_key(scope);
let path = last_version_scope_path(project_root);
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(path, format!("{label}\n"));
}
pub(crate) fn select_matching_service_scope(
scopes: &[VersionScope],
project_root: &Path,
invocation_dir: &Path,
) -> Option<VersionScope> {
let project_is_nested_package = enclosing_git_root(project_root)
.is_some_and(|git_root| !same_filesystem_path(&git_root, project_root));
scopes
.iter()
.filter_map(|scope| {
let root = version_scope_root(scope)?;
if same_filesystem_path(invocation_dir, project_root)
&& same_filesystem_path(root, project_root)
&& !project_is_nested_package
{
return None;
}
if path_is_within(invocation_dir, root) {
Some((root.components().count(), scope.clone()))
} else {
None
}
})
.max_by_key(|(depth, _)| *depth)
.map(|(_, scope)| scope)
}
pub(crate) fn slugify_scope_name(value: &str) -> String {
let mut slug = value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
while slug.contains("--") {
slug = slug.replace("--", "-");
}
slug.trim_matches('-').to_string()
}
pub(crate) fn version_scope_guard_id(scope: &VersionScope) -> String {
match scope {
VersionScope::Repository => "repository".to_string(),
VersionScope::Crate {
package_name,
crate_relative_root,
..
} => format!("crate:{package_name}:{crate_relative_root}"),
VersionScope::Service {
service_name,
service_relative_root,
..
} => format!("service:{service_name}:{service_relative_root}"),
}
}
pub(crate) fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
match version_scope {
VersionScope::Repository => "repository",
VersionScope::Crate { .. } => "crate",
VersionScope::Service { .. } => "service",
}
}
pub(crate) fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
match version_scope {
VersionScope::Repository => repo.to_string(),
VersionScope::Crate { package_name, .. } => package_name.clone(),
VersionScope::Service { service_name, .. } => service_name.clone(),
}
}
pub(crate) fn version_scope_preference_key(scope: &VersionScope) -> String {
match scope {
VersionScope::Repository => "repository".to_string(),
VersionScope::Crate {
crate_relative_root,
..
} => format!("crate:{crate_relative_root}"),
VersionScope::Service {
service_name,
service_relative_root,
..
} => format!("service:{service_name}:{service_relative_root}"),
}
}
pub(crate) fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
match version_scope {
VersionScope::Repository => "Repository".to_string(),
VersionScope::Crate {
package_name,
crate_relative_root,
..
} => format!("{} ({})", package_name, crate_relative_root),
VersionScope::Service {
service_name,
service_relative_root,
..
} => format!("{} ({})", service_name, service_relative_root),
}
}
pub(crate) fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
match version_scope {
VersionScope::Repository => None,
VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
}
}