xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use super::{collapse_home_to_env, expand_home_in_string};

/// Resolve an existing path for child-process `current_dir` / path probes.
///
/// On Windows, shells often use non-canonical casing (e.g. `documents\github`
/// instead of `Documents\GitHub`). Tools such as cargo-dist walk ancestors with
/// case-sensitive checks and report a missing `Cargo.toml` even when the file
/// exists. `fs::canonicalize` returns the filesystem's real casing; the
/// extended-length `\\?\` prefix is stripped so consumers get a normal path.
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
        };
    }

    // Windows paths often differ only by drive/folder casing; strip_prefix is
    // case-sensitive even on Windows, so fall back to a string prefix match.
    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()
}

/// Resolve a project/service directory for runtime use.
///
/// Relative paths are joined to `project_root`. Absolute (or rooted) paths that
/// exist are kept. Paths that do not exist — including configs written on
/// another machine/checkout (e.g. Linux `/home/user/repos/app` opened on
/// Windows) — are remapped onto the current project root when a matching
/// suffix exists, otherwise fall back to `project_root`.
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);
    // On Windows, Unix paths like `/home/...` have a root but no drive prefix,
    // so `is_absolute()` is false — treat them as rooted/foreign absolutes.
    let rooted = is_rooted_path(&raw);

    let candidate = if rooted {
        // Keep the foreign path for existence checks / remapping; do not
        // join onto project_root (that yields `C:/home/...` on Windows).
        normalize_path(&raw)
    } else {
        normalize_path(project_root.join(&raw))
    };

    if candidate.exists() {
        return strip_windows_verbatim_prefix(&candidate.to_string_lossy()).to_string();
    }

    // Relative paths that do not exist yet (e.g. create-on-write) keep the join.
    if !rooted {
        return strip_windows_verbatim_prefix(&candidate.to_string_lossy()).to_string();
    }

    // Remap using the original rooted path components (not the drive-prefixed form).
    remap_stale_absolute_dir(&project_root, &raw)
}

/// Same as [`resolve_project_dir`], returning a `PathBuf`.
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(),
    }
}

/// True for drive-absolute paths and for root-only paths without a prefix
/// (Unix `/home/...` or Windows `\Users\...`), which are not `is_absolute()`
/// on Windows but are still machine-local absolute locations.
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 {
    // Same leaf folder name as the current project → treat as project root.
    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();
        }
    }

    // Try longest path suffix that exists under the current project root
    // (e.g. `/old/machine/repo/apps/web` → `<project>/apps/web`).
    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() {
        // Leaf name must match the foreign path's final component.
        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()))
        );
    }
}