use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use crate::KimetsuResult;
static DISCOVER_AT_ROOT_ONLY: OnceLock<bool> = OnceLock::new();
pub fn pin_discover_to_root() {
let _ = DISCOVER_AT_ROOT_ONLY.set(true);
}
fn discover_pins_to_root() -> bool {
*DISCOVER_AT_ROOT_ONLY.get().unwrap_or(&false)
}
#[derive(Debug, Clone)]
pub struct ProjectPaths {
pub repo_root: PathBuf,
pub kimetsu_dir: PathBuf,
pub project_toml: PathBuf,
pub brain_db: PathBuf,
pub project_log: PathBuf,
pub runs_dir: PathBuf,
pub lock_file: PathBuf,
}
impl ProjectPaths {
pub fn discover(start: impl AsRef<Path>) -> KimetsuResult<Self> {
if discover_pins_to_root() {
return Ok(Self::at_root(start.as_ref()));
}
let repo_root = discover_repo_root(start.as_ref())?;
Ok(Self::at_root(repo_root))
}
pub fn at_root(repo_root: impl Into<PathBuf>) -> Self {
let repo_root = repo_root.into();
let kimetsu_dir = repo_root.join(".kimetsu");
Self {
repo_root,
project_toml: kimetsu_dir.join("project.toml"),
brain_db: kimetsu_dir.join("brain.db"),
project_log: kimetsu_dir.join("kimetsu.log"),
runs_dir: kimetsu_dir.join("runs"),
lock_file: kimetsu_dir.join("project.lock"),
kimetsu_dir,
}
}
pub fn validate_state_dir(&self) -> KimetsuResult<()> {
let canonical_root = if self.repo_root.exists() {
self.repo_root.canonicalize()?
} else {
self.repo_root.clone()
};
if let Ok(metadata) = std::fs::symlink_metadata(&self.kimetsu_dir) {
if metadata.file_type().is_symlink() {
return Err(format!(
"refusing to use symlinked Kimetsu state dir: {}",
self.kimetsu_dir.display()
)
.into());
}
if !metadata.is_dir() {
return Err(format!(
"Kimetsu state path exists but is not a directory: {}",
self.kimetsu_dir.display()
)
.into());
}
let canonical_state = self.kimetsu_dir.canonicalize()?;
if !canonical_state.starts_with(&canonical_root) {
return Err(format!(
"Kimetsu state dir escaped the project root: {}",
self.kimetsu_dir.display()
)
.into());
}
}
for path in [
&self.project_toml,
&self.brain_db,
&self.project_log,
&self.runs_dir,
&self.lock_file,
] {
reject_symlink(path)?;
}
Ok(())
}
}
fn reject_symlink(path: &Path) -> KimetsuResult<()> {
if let Ok(metadata) = std::fs::symlink_metadata(path)
&& metadata.file_type().is_symlink()
{
return Err(format!(
"refusing to use symlinked Kimetsu state path: {}",
path.display()
)
.into());
}
Ok(())
}
pub fn discover_repo_root(start: &Path) -> KimetsuResult<PathBuf> {
if let Some(root) = git_root(start) {
return Ok(root);
}
let start = start.canonicalize()?;
if start.is_file() {
Ok(start
.parent()
.ok_or("file path has no parent")?
.to_path_buf())
} else {
Ok(start)
}
}
pub fn git_init_boundary(dir: &Path) -> bool {
if std::fs::create_dir_all(dir).is_err() {
return false;
}
Command::new("git")
.args(["init", "--quiet"])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git_root(start: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(start)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let root = stdout.trim();
if root.is_empty() {
return None;
}
PathBuf::from(root).canonicalize().ok()
}
pub fn user_kimetsu_dir() -> Option<PathBuf> {
if let Ok(override_dir) = std::env::var("KIMETSU_USER_BRAIN_DIR") {
let trimmed = override_dir.trim();
if !trimmed.is_empty() {
return Some(PathBuf::from(trimmed));
}
}
let home = if cfg!(windows) {
std::env::var("USERPROFILE").ok()
} else {
std::env::var("HOME").ok()
};
home.filter(|h| !h.trim().is_empty())
.map(|h| PathBuf::from(h).join(".kimetsu"))
}
pub fn user_brain_db_path() -> Option<PathBuf> {
user_kimetsu_dir().map(|dir| dir.join("brain.db"))
}
pub fn user_brain_enabled() -> bool {
user_brain_enabled_with(true)
}
pub fn user_brain_enabled_with(config_use_user_brain: bool) -> bool {
match std::env::var("KIMETSU_USER_BRAIN") {
Ok(value) => {
let v = value.trim().to_ascii_lowercase();
!matches!(v.as_str(), "0" | "false" | "off" | "no")
}
Err(_) => config_use_user_brain,
}
}
pub fn default_project_id(repo_root: &Path) -> String {
repo_root
.file_name()
.and_then(OsStr::to_str)
.map(slug)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "kimetsu-project".to_string())
}
fn slug(value: &str) -> String {
value
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-")
}
pub fn user_cache_dir_for(repo_root: &Path) -> PathBuf {
let hash = default_project_id(repo_root);
match user_kimetsu_dir() {
Some(home) => home.join("cache").join(&hash),
None => std::env::temp_dir().join("kimetsu-cache").join(&hash),
}
}
pub fn display_path(p: &std::path::Path) -> String {
let s = p.to_string_lossy();
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
return rest.to_string();
}
s.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::sync::Mutex;
fn env_lock() -> &'static Mutex<()> {
static LOCK: Mutex<()> = Mutex::new(());
&LOCK
}
fn with_brain_dir<R>(dir: &Path, f: impl FnOnce() -> R) -> R {
let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
unsafe {
std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
}
let out = f();
unsafe {
match prev {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
}
out
}
fn without_brain_dir<R>(f: impl FnOnce() -> R) -> R {
let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
let prev_override = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
let prev_home = std::env::var(home_key).ok();
unsafe {
std::env::remove_var("KIMETSU_USER_BRAIN_DIR");
std::env::remove_var(home_key);
}
let out = f();
unsafe {
match prev_override {
Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
}
match prev_home {
Some(v) => std::env::set_var(home_key, v),
None => std::env::remove_var(home_key),
}
}
out
}
#[test]
fn user_cache_dir_for_lands_under_user_home() {
let tmp = std::env::temp_dir().join("kimetsu-test-cache-home");
let repo = Path::new("/some/project/my-repo");
let result = with_brain_dir(&tmp, || user_cache_dir_for(repo));
assert!(
result.starts_with(tmp.join("cache")),
"expected result under <tmp>/cache, got {result:?}"
);
assert!(
!result.starts_with(repo.join(".kimetsu")),
"must not be inside repo .kimetsu, got {result:?}"
);
let leaf = result.file_name().unwrap().to_str().unwrap();
assert_eq!(leaf, "my-repo");
}
#[test]
fn user_cache_dir_for_falls_back_to_temp_when_no_home() {
let repo = Path::new("/some/project/fallback-repo");
let result = without_brain_dir(|| user_cache_dir_for(repo));
let tmp = std::env::temp_dir();
assert!(
result.starts_with(&tmp),
"expected result under OS temp dir, got {result:?}"
);
assert!(
result
.components()
.any(|c| c.as_os_str() == "kimetsu-cache"),
"expected 'kimetsu-cache' in path, got {result:?}"
);
}
#[test]
fn display_path_strips_extended_prefix() {
assert_eq!(
display_path(Path::new(r"\\?\C:\Users\foo\.kimetsu\brain.db")),
r"C:\Users\foo\.kimetsu\brain.db"
);
assert_eq!(
display_path(Path::new(r"\\?\UNC\server\share\path")),
r"\\server\share\path"
);
assert_eq!(
display_path(Path::new(r"C:\Users\foo\.kimetsu")),
r"C:\Users\foo\.kimetsu"
);
assert_eq!(
display_path(Path::new("/home/user/.kimetsu")),
"/home/user/.kimetsu"
);
}
#[test]
fn slug_is_filesystem_safe() {
let id = default_project_id(Path::new("/tmp/my repo with spaces & stuff!"));
assert!(
id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
"slug contains unsafe chars: {id:?}"
);
assert!(!id.is_empty());
}
#[test]
fn validate_state_dir_rejects_symlinked_kimetsu_dir() {
let root = temp_root("state_symlink_root");
let outside = temp_root("state_symlink_outside");
let link = root.join(".kimetsu");
if create_dir_symlink(&outside, &link).is_err() {
std::fs::remove_dir_all(root).ok();
std::fs::remove_dir_all(outside).ok();
return;
}
let err = ProjectPaths::at_root(&root)
.validate_state_dir()
.expect_err("symlinked .kimetsu must be rejected");
assert!(
format!("{err}").contains("symlinked Kimetsu state dir"),
"unexpected error: {err}"
);
std::fs::remove_dir_all(root).ok();
std::fs::remove_dir_all(outside).ok();
}
fn temp_root(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!("kimetsu_{label}_{nanos}"));
std::fs::create_dir_all(&path).expect("create temp root");
path
}
#[cfg(unix)]
fn create_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
#[test]
fn pin_discover_to_root_skips_git_climb() {
let nested = std::env::temp_dir()
.join("kimetsu-pin-test")
.join("nested")
.join("deep");
std::fs::create_dir_all(&nested).expect("create nested dir");
pin_discover_to_root();
let paths = ProjectPaths::discover(&nested).expect("discover with pin should not fail");
let canonical_nested = nested.canonicalize().unwrap_or(nested.clone());
let canonical_root = paths
.repo_root
.canonicalize()
.unwrap_or(paths.repo_root.clone());
assert_eq!(
canonical_root, canonical_nested,
"pin_discover_to_root: expected repo_root == nested dir, got {canonical_root:?}"
);
}
}