use std::fs;
use std::path::{Path, PathBuf};
use super::{collapse_home_to_env, expand_home_in_string};
pub fn canonicalize_for_subprocess(path: &Path) -> PathBuf {
match fs::canonicalize(path) {
Ok(canonical) => {
PathBuf::from(strip_windows_verbatim_prefix(&canonical.to_string_lossy()))
}
Err(_) => PathBuf::from(strip_windows_verbatim_prefix(&path.to_string_lossy())),
}
}
pub fn collapse_project_path(project_root: &Path, input: &str) -> String {
let project_root = normalize_path(project_root);
let candidate = resolve_absolute_candidate(&project_root, input);
if paths_equal_ignore_case(&candidate, &project_root) {
return "./".to_string();
}
if let Ok(relative) = candidate.strip_prefix(&project_root) {
let rendered = relative.to_string_lossy().replace('\\', "/");
return if rendered.is_empty() {
"./".to_string()
} else {
rendered
};
}
if let Some(relative) = strip_prefix_ignore_case(&project_root, &candidate) {
return if relative.is_empty() {
"./".to_string()
} else {
relative
};
}
let rendered = candidate.to_string_lossy().to_string();
collapse_home_to_env(strip_windows_verbatim_prefix(&rendered))
}
fn paths_equal_ignore_case(left: &Path, right: &Path) -> bool {
left == right
|| left.to_string_lossy().eq_ignore_ascii_case(&right.to_string_lossy())
}
fn strip_prefix_ignore_case(project_root: &Path, candidate: &Path) -> Option<String> {
let root = project_root
.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string();
let path = candidate.to_string_lossy().replace('\\', "/");
if path.len() < root.len() {
return None;
}
let (prefix, suffix) = path.split_at(root.len());
if !prefix.eq_ignore_ascii_case(&root) {
return None;
}
if suffix.is_empty() {
return Some(String::new());
}
if !suffix.starts_with('/') {
return None;
}
Some(suffix.trim_start_matches('/').to_string())
}
pub fn resolve_project_path(project_root: &Path, input: &str) -> String {
let project_root = normalize_path(project_root);
let candidate = resolve_absolute_candidate(&project_root, input);
strip_windows_verbatim_prefix(&candidate.to_string_lossy()).to_string()
}
pub fn resolve_project_dir(project_root: &Path, input: &str) -> String {
let project_root = normalize_path(project_root);
let trimmed = input.trim();
if trimmed.is_empty() || trimmed == "." || trimmed == "./" {
return strip_windows_verbatim_prefix(&project_root.to_string_lossy()).to_string();
}
let expanded = expand_home_in_string(strip_windows_verbatim_prefix(trimmed));
let raw = PathBuf::from(&expanded);
let rooted = is_rooted_path(&raw);
let candidate = if rooted {
normalize_path(&raw)
} else {
normalize_path(project_root.join(&raw))
};
if candidate.exists() {
return strip_windows_verbatim_prefix(&candidate.to_string_lossy()).to_string();
}
if !rooted {
return strip_windows_verbatim_prefix(&candidate.to_string_lossy()).to_string();
}
remap_stale_absolute_dir(&project_root, &raw)
}
pub fn resolve_service_root(project_root: &Path, input: Option<&str>) -> PathBuf {
match input.map(str::trim).filter(|value| !value.is_empty()) {
Some(value) => PathBuf::from(resolve_project_dir(project_root, value)),
None => project_root.to_path_buf(),
}
}
fn is_rooted_path(path: &Path) -> bool {
if path.is_absolute() {
return true;
}
matches!(
path.components().next(),
Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_))
)
}
fn remap_stale_absolute_dir(project_root: &Path, stale: &Path) -> String {
if let (Some(leaf), Some(project_leaf)) = (stale.file_name(), project_root.file_name()) {
if leaf.eq_ignore_ascii_case(project_leaf) {
return strip_windows_verbatim_prefix(&project_root.to_string_lossy()).to_string();
}
}
let parts: Vec<_> = stale
.components()
.filter_map(|component| match component {
std::path::Component::Normal(part) => Some(part.to_os_string()),
_ => None,
})
.collect();
for start in 0..parts.len() {
let mut candidate = project_root.to_path_buf();
for part in &parts[start..] {
candidate.push(part);
}
if candidate.is_dir() {
return strip_windows_verbatim_prefix(&candidate.to_string_lossy()).to_string();
}
}
strip_windows_verbatim_prefix(&project_root.to_string_lossy()).to_string()
}
fn resolve_absolute_candidate(project_root: &Path, input: &str) -> PathBuf {
let expanded = expand_home_in_string(strip_windows_verbatim_prefix(input));
let candidate = PathBuf::from(&expanded);
if candidate.is_absolute() {
normalize_path(candidate)
} else {
normalize_path(project_root.join(candidate))
}
}
fn normalize_path(path: impl AsRef<Path>) -> PathBuf {
PathBuf::from(strip_windows_verbatim_prefix(
&path.as_ref().to_string_lossy(),
))
}
fn strip_windows_verbatim_prefix(input: &str) -> &str {
input.strip_prefix(r"\\?\").unwrap_or(input)
}
#[cfg(test)]
mod tests {
use super::{
canonicalize_for_subprocess, collapse_project_path, resolve_project_dir,
resolve_project_path, resolve_service_root, strip_windows_verbatim_prefix,
};
use std::fs;
use std::path::PathBuf;
fn make_temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-project-paths-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir should be created");
dir
}
#[test]
fn collapse_project_path_uses_root_relative_output() {
let project_root = make_temp_dir("collapse-project-root");
let nested = project_root.join("apps").join("web");
assert_eq!(
collapse_project_path(&project_root, &project_root.to_string_lossy()),
"./"
);
assert_eq!(
collapse_project_path(&project_root, &nested.to_string_lossy()),
"apps/web"
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn resolve_project_path_expands_relative_paths_against_project_root() {
let project_root = make_temp_dir("resolve-project-root");
let nested = project_root.join("apps").join("web");
assert_eq!(
PathBuf::from(resolve_project_path(&project_root, "./")),
project_root
);
assert_eq!(
PathBuf::from(resolve_project_path(&project_root, "apps/web")),
nested
);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn resolve_project_dir_remaps_stale_absolute_path_with_matching_leaf() {
let parent = make_temp_dir("parent");
let project_root = parent.join("suits.finance");
fs::create_dir_all(&project_root).expect("create project root");
let stale = "/home/floris-xlx/repos/suits.finance";
let resolved = PathBuf::from(resolve_project_dir(&project_root, stale));
assert_eq!(resolved, project_root);
let via_service = resolve_service_root(&project_root, Some(stale));
assert_eq!(via_service, project_root);
let _ = fs::remove_dir_all(parent);
}
#[test]
fn resolve_project_dir_remaps_stale_absolute_nested_suffix() {
let project_root = make_temp_dir("resolve-stale-nested");
let nested = project_root.join("apps").join("web");
fs::create_dir_all(&nested).expect("create nested");
let stale = "/home/other/machine/checkout/apps/web";
let resolved = PathBuf::from(resolve_project_dir(&project_root, stale));
assert_eq!(resolved, nested);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn resolve_project_dir_falls_back_to_project_root_when_no_suffix_matches() {
let project_root = make_temp_dir("fallback-root");
let stale = "/home/floris-xlx/repos/totally-missing-project";
let resolved = PathBuf::from(resolve_project_dir(&project_root, stale));
assert_eq!(resolved, project_root);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn resolve_project_dir_keeps_relative_dot_as_project_root() {
let project_root = make_temp_dir("resolve-dot");
assert_eq!(
PathBuf::from(resolve_project_dir(&project_root, "./")),
project_root
);
assert_eq!(resolve_service_root(&project_root, None), project_root);
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn canonicalize_for_subprocess_strips_verbatim_prefix_and_keeps_existing_dir() {
let project_root = make_temp_dir("canonicalize-subprocess");
let resolved = canonicalize_for_subprocess(&project_root);
assert!(resolved.is_dir());
assert!(!resolved.to_string_lossy().starts_with(r"\\?\"));
let _ = fs::remove_dir_all(project_root);
}
#[test]
fn canonicalize_for_subprocess_falls_back_for_missing_path() {
let missing = std::env::temp_dir().join("xbp-missing-path-does-not-exist-xyz");
let resolved = canonicalize_for_subprocess(&missing);
assert_eq!(
resolved,
PathBuf::from(strip_windows_verbatim_prefix(&missing.to_string_lossy()))
);
}
}