use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
pub fn project_root(cwd: &str) -> String {
project_root_with(cwd, std::env::var_os("KINDLING_REPO_ROOT"))
}
fn project_root_with(cwd: &str, env_root: Option<OsString>) -> String {
let resolved = resolve(cwd);
if let Some(cached) = env_root {
let cached = cached.to_string_lossy().into_owned();
if !cached.is_empty() && resolved.starts_with(&cached) {
return cached;
}
}
if let Some(toplevel) = git_toplevel(cwd) {
return toplevel;
}
resolved
}
fn git_toplevel(cwd: &str) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let root = String::from_utf8(output.stdout).ok()?;
let trimmed = root.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn resolve(cwd: &str) -> String {
let path = Path::new(cwd);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
normalize(&absolute)
}
fn normalize(path: &Path) -> String {
use std::path::Component;
let mut out: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
} else if !matches!(
out.last(),
Some(Component::RootDir) | Some(Component::Prefix(_))
) {
out.push(component);
}
}
Component::CurDir => {}
other => out.push(other),
}
}
let mut buf = PathBuf::new();
for c in out {
buf.push(c.as_os_str());
}
buf.to_string_lossy().into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_root_used_when_cwd_is_under_it() {
assert_eq!(
project_root_with("/home/u/proj/sub/dir", Some("/home/u/proj".into())),
"/home/u/proj"
);
}
#[test]
fn env_root_ignored_when_cwd_outside_it() {
let root = project_root_with("/tmp/elsewhere/x", Some("/home/u/proj".into()));
assert_ne!(root, "/home/u/proj");
}
#[test]
fn env_root_ignored_when_empty() {
let root = project_root_with("/tmp/elsewhere/x", Some(OsString::new()));
assert_ne!(root, "");
}
#[test]
fn normalize_collapses_dot_segments() {
assert_eq!(normalize(Path::new("/a/b/../c/./d")), "/a/c/d");
}
}