use percent_encoding::percent_decode_str;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};
fn unquote(input: &str) -> &str {
let trimmed = input.trim();
if trimmed.len() >= 2 {
let bytes = trimmed.as_bytes();
let first = bytes[0] as char;
let last = bytes[trimmed.len() - 1] as char;
if (first == '\'' && last == '\'') || (first == '"' && last == '"') {
return &trimmed[1..trimmed.len() - 1];
}
}
trimmed
}
pub fn resolve_path(raw_path: &str, workdir: Option<&str>, session_cwd: Option<&str>) -> PathBuf {
let cleaned = unquote(raw_path);
let path = Path::new(cleaned);
if path.is_absolute() || looks_like_windows_drive_path(cleaned) {
return path.to_path_buf();
}
if let Some(wd) = workdir {
return Path::new(wd).join(path);
}
if let Some(cwd) = session_cwd {
return Path::new(cwd).join(path);
}
path.to_path_buf()
}
pub fn detect_repo_root(abs_path: &Path) -> Option<PathBuf> {
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Option<PathBuf>>>> = OnceLock::new();
let query_dir = path_query_dir(abs_path);
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache
.lock()
.ok()
.and_then(|guard| guard.get(&query_dir).cloned())
{
return cached;
}
let resolved = detect_git_top_level(&query_dir);
if let Ok(mut guard) = cache.lock() {
guard.insert(query_dir, resolved.clone());
}
resolved
}
pub fn to_rel_path(repo_root: Option<&Path>, abs_path: &Path) -> Option<String> {
let root = repo_root?;
if let Ok(rel) = abs_path.strip_prefix(root) {
return Some(normalize_rel_path(rel));
}
let normalized_root = std::fs::canonicalize(root)
.ok()
.unwrap_or_else(|| root.to_path_buf());
let normalized_path = std::fs::canonicalize(abs_path).ok()?;
let rel = normalized_path.strip_prefix(&normalized_root).ok()?;
Some(normalize_rel_path(rel))
}
pub fn path_to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
pub fn strip_file_scheme(uri: &str) -> String {
if let Some(p) = uri.strip_prefix("file://localhost/") {
normalize_file_uri_path(p)
} else if let Some(p) = uri.strip_prefix("file:///") {
normalize_file_uri_path(p)
} else if let Some(p) = uri.strip_prefix("file://") {
normalize_file_uri_path(p)
} else {
uri.to_string()
}
}
pub fn normalize_filesystem_path(path: &str) -> String {
normalize_windows_drive_path(path)
}
fn normalize_file_uri_path(path: &str) -> String {
let decoded = percent_decode_str(path).decode_utf8_lossy();
let normalized = normalize_windows_drive_path(&decoded);
if normalized.starts_with('/') || looks_like_windows_drive_path(&normalized) {
normalized
} else {
format!("/{}", normalized)
}
}
fn looks_like_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'/' || bytes[2] == b'\\')
}
fn normalize_windows_drive_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(without_leading_slash) = path.strip_prefix('/')
&& looks_like_windows_drive_path(without_leading_slash)
{
return uppercase_windows_drive_letter(without_leading_slash);
}
if looks_like_windows_drive_path(&path) {
return uppercase_windows_drive_letter(&path);
}
path
}
fn uppercase_windows_drive_letter(path: &str) -> String {
let mut chars = path.chars();
let drive = chars
.next()
.expect("windows drive path should start with a drive letter")
.to_ascii_uppercase();
let rest: String = chars.collect();
format!("{drive}{rest}")
}
fn normalize_rel_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn path_query_dir(abs_path: &Path) -> PathBuf {
if abs_path.is_dir() {
return abs_path.to_path_buf();
}
if abs_path.is_file() || abs_path.extension().is_some() {
return abs_path.parent().unwrap_or(abs_path).to_path_buf();
}
abs_path.to_path_buf()
}
fn nearest_existing_dir(path: &Path) -> Option<PathBuf> {
let mut dir = path.to_path_buf();
loop {
if dir.is_dir() {
return Some(dir);
}
match dir.parent() {
Some(parent) if parent != dir => dir = parent.to_path_buf(),
_ => return None,
}
}
}
fn detect_git_top_level(query_dir: &Path) -> Option<PathBuf> {
let existing_dir = nearest_existing_dir(query_dir)?;
let output = Command::new("git")
.arg("-C")
.arg(&existing_dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8(output.stdout).ok()?;
let root = raw.trim();
if root.is_empty() {
return None;
}
let root_path = PathBuf::from(root);
std::fs::canonicalize(&root_path).ok().or(Some(root_path))
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use tempfile::tempdir;
fn git(args: &[&str], cwd: &Path) -> Result<()> {
let status = Command::new("git").current_dir(cwd).args(args).status()?;
anyhow::ensure!(
status.success(),
"git {:?} failed in {}",
args,
cwd.display()
);
Ok(())
}
#[test]
fn detect_repo_root_uses_canonical_git_toplevel() -> Result<()> {
let tempdir = tempdir()?;
let repo_root = tempdir.path().join("sample-repo");
std::fs::create_dir_all(repo_root.join("profile_app/src/app"))?;
std::fs::write(
repo_root.join("profile_app/package.json"),
"{\"name\":\"profile-app\"}",
)?;
std::fs::write(
repo_root.join("profile_app/src/app/page.tsx"),
"export default function Page() {}\n",
)?;
git(&["init", "-q"], &repo_root)?;
let file_path = repo_root.join("profile_app/src/app/page.tsx");
let detected = detect_repo_root(&file_path).expect("git repo should be detected");
assert_eq!(detected, std::fs::canonicalize(&repo_root)?);
assert_eq!(
to_rel_path(Some(&detected), &file_path).as_deref(),
Some("profile_app/src/app/page.tsx")
);
Ok(())
}
#[test]
fn detect_repo_root_does_not_treat_manifest_only_dir_as_repo() -> Result<()> {
let tempdir = tempdir()?;
let project_root = tempdir.path().join("manifest-only");
std::fs::create_dir_all(project_root.join("src"))?;
std::fs::write(
project_root.join("package.json"),
"{\"name\":\"manifest-only\"}",
)?;
std::fs::write(project_root.join("src/index.ts"), "console.log('hi');\n")?;
let detected = detect_repo_root(&project_root.join("src/index.ts"));
assert!(detected.is_none());
Ok(())
}
#[test]
fn strip_file_scheme_keeps_windows_drive_letter_paths() {
assert_eq!(
strip_file_scheme("file:///C:/Users/alice/code/src/main.rs"),
"C:/Users/alice/code/src/main.rs"
);
assert_eq!(
strip_file_scheme("file://localhost/C:/Users/alice/code/src/main.rs"),
"C:/Users/alice/code/src/main.rs"
);
}
#[test]
fn strip_file_scheme_decodes_percent_encoded_windows_drive_paths() {
assert_eq!(
strip_file_scheme("file:///c%3A/dev/paceflow/src/main.rs"),
"C:/dev/paceflow/src/main.rs"
);
assert_eq!(
strip_file_scheme("file://localhost/c%3A/dev/paceflow/src/main.rs"),
"C:/dev/paceflow/src/main.rs"
);
}
#[test]
fn normalize_filesystem_path_normalizes_backslashes() {
assert_eq!(
normalize_filesystem_path(r"C:\dev\paceflow\paceflow-backend"),
"C:/dev/paceflow/paceflow-backend"
);
}
#[test]
fn path_to_string_preserves_windows_verbatim_prefix() {
assert_eq!(
path_to_string(Path::new(r"\\?\C:\dev\paceflow\paceflow-backend")),
r"\\?\C:\dev\paceflow\paceflow-backend"
);
}
#[test]
fn resolve_path_does_not_join_windows_drive_paths() {
let resolved = resolve_path("C:/Users/alice/code/src/main.rs", Some("/tmp/work"), None);
assert_eq!(resolved, PathBuf::from("C:/Users/alice/code/src/main.rs"));
}
#[cfg(windows)]
#[test]
fn detect_repo_root_and_rel_path_work_on_windows_paths() -> Result<()> {
let tempdir = tempdir()?;
let repo_root = tempdir.path().join("windows-repo");
std::fs::create_dir_all(repo_root.join("src"))?;
std::fs::write(repo_root.join("src").join("lib.rs"), "pub fn demo() {}\n")?;
git(&["init", "-q"], &repo_root)?;
let file_path = repo_root.join("src").join("lib.rs");
let detected = detect_repo_root(&file_path).expect("git repo should be detected");
assert_eq!(detected, std::fs::canonicalize(&repo_root)?);
assert_eq!(
to_rel_path(Some(&detected), &file_path).as_deref(),
Some("src/lib.rs")
);
Ok(())
}
}