1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::sync::OnceLock;
5
6use crate::KimetsuResult;
7
8static DISCOVER_AT_ROOT_ONLY: OnceLock<bool> = OnceLock::new();
9
10pub fn pin_discover_to_root() {
15 let _ = DISCOVER_AT_ROOT_ONLY.set(true);
16}
17
18fn discover_pins_to_root() -> bool {
19 *DISCOVER_AT_ROOT_ONLY.get().unwrap_or(&false)
20}
21
22#[derive(Debug, Clone)]
23pub struct ProjectPaths {
24 pub repo_root: PathBuf,
25 pub kimetsu_dir: PathBuf,
26 pub project_toml: PathBuf,
27 pub brain_db: PathBuf,
28 pub project_log: PathBuf,
29 pub runs_dir: PathBuf,
30 pub lock_file: PathBuf,
31}
32
33impl ProjectPaths {
34 pub fn discover(start: impl AsRef<Path>) -> KimetsuResult<Self> {
35 if discover_pins_to_root() {
36 return Ok(Self::at_root(start.as_ref()));
37 }
38 let repo_root = discover_repo_root(start.as_ref())?;
39 Ok(Self::at_root(repo_root))
40 }
41
42 pub fn at_root(repo_root: impl Into<PathBuf>) -> Self {
47 let repo_root = repo_root.into();
48 let kimetsu_dir = repo_root.join(".kimetsu");
49 Self {
50 repo_root,
51 project_toml: kimetsu_dir.join("project.toml"),
52 brain_db: kimetsu_dir.join("brain.db"),
53 project_log: kimetsu_dir.join("kimetsu.log"),
54 runs_dir: kimetsu_dir.join("runs"),
55 lock_file: kimetsu_dir.join("project.lock"),
56 kimetsu_dir,
57 }
58 }
59
60 pub fn validate_state_dir(&self) -> KimetsuResult<()> {
64 let canonical_root = if self.repo_root.exists() {
65 self.repo_root.canonicalize()?
66 } else {
67 self.repo_root.clone()
68 };
69
70 if let Ok(metadata) = std::fs::symlink_metadata(&self.kimetsu_dir) {
71 if metadata.file_type().is_symlink() {
72 return Err(format!(
73 "refusing to use symlinked Kimetsu state dir: {}",
74 self.kimetsu_dir.display()
75 )
76 .into());
77 }
78 if !metadata.is_dir() {
79 return Err(format!(
80 "Kimetsu state path exists but is not a directory: {}",
81 self.kimetsu_dir.display()
82 )
83 .into());
84 }
85 let canonical_state = self.kimetsu_dir.canonicalize()?;
86 if !canonical_state.starts_with(&canonical_root) {
87 return Err(format!(
88 "Kimetsu state dir escaped the project root: {}",
89 self.kimetsu_dir.display()
90 )
91 .into());
92 }
93 }
94
95 for path in [
96 &self.project_toml,
97 &self.brain_db,
98 &self.project_log,
99 &self.runs_dir,
100 &self.lock_file,
101 ] {
102 reject_symlink(path)?;
103 }
104 Ok(())
105 }
106}
107
108fn reject_symlink(path: &Path) -> KimetsuResult<()> {
109 if let Ok(metadata) = std::fs::symlink_metadata(path)
110 && metadata.file_type().is_symlink()
111 {
112 return Err(format!(
113 "refusing to use symlinked Kimetsu state path: {}",
114 path.display()
115 )
116 .into());
117 }
118 Ok(())
119}
120
121pub fn discover_repo_root(start: &Path) -> KimetsuResult<PathBuf> {
122 if let Some(root) = git_root(start) {
123 return Ok(root);
124 }
125
126 let start = start.canonicalize()?;
127 if start.is_file() {
128 Ok(start
129 .parent()
130 .ok_or("file path has no parent")?
131 .to_path_buf())
132 } else {
133 Ok(start)
134 }
135}
136
137pub fn git_init_boundary(dir: &Path) -> bool {
150 if std::fs::create_dir_all(dir).is_err() {
151 return false;
152 }
153 Command::new("git")
154 .args(["init", "--quiet"])
155 .current_dir(dir)
156 .output()
157 .map(|o| o.status.success())
158 .unwrap_or(false)
159}
160
161fn git_root(start: &Path) -> Option<PathBuf> {
162 let output = Command::new("git")
163 .args(["rev-parse", "--show-toplevel"])
164 .current_dir(start)
165 .output()
166 .ok()?;
167
168 if !output.status.success() {
169 return None;
170 }
171
172 let stdout = String::from_utf8(output.stdout).ok()?;
173 let root = stdout.trim();
174 if root.is_empty() {
175 return None;
176 }
177
178 PathBuf::from(root).canonicalize().ok()
179}
180
181pub fn user_kimetsu_dir() -> Option<PathBuf> {
196 if let Ok(override_dir) = std::env::var("KIMETSU_USER_BRAIN_DIR") {
197 let trimmed = override_dir.trim();
198 if !trimmed.is_empty() {
199 return Some(PathBuf::from(trimmed));
200 }
201 }
202 let home = if cfg!(windows) {
203 std::env::var("USERPROFILE").ok()
204 } else {
205 std::env::var("HOME").ok()
206 };
207 home.filter(|h| !h.trim().is_empty())
208 .map(|h| PathBuf::from(h).join(".kimetsu"))
209}
210
211pub fn user_brain_db_path() -> Option<PathBuf> {
216 user_kimetsu_dir().map(|dir| dir.join("brain.db"))
217}
218
219pub fn user_brain_enabled() -> bool {
226 user_brain_enabled_with(true)
228}
229
230pub fn user_brain_enabled_with(config_use_user_brain: bool) -> bool {
238 match std::env::var("KIMETSU_USER_BRAIN") {
240 Ok(value) => {
241 let v = value.trim().to_ascii_lowercase();
242 !matches!(v.as_str(), "0" | "false" | "off" | "no")
245 }
246 Err(_) => config_use_user_brain,
248 }
249}
250
251pub fn default_project_id(repo_root: &Path) -> String {
252 repo_root
253 .file_name()
254 .and_then(OsStr::to_str)
255 .map(slug)
256 .filter(|value| !value.is_empty())
257 .unwrap_or_else(|| "kimetsu-project".to_string())
258}
259
260fn slug(value: &str) -> String {
261 value
262 .chars()
263 .map(|ch| {
264 if ch.is_ascii_alphanumeric() {
265 ch.to_ascii_lowercase()
266 } else {
267 '-'
268 }
269 })
270 .collect::<String>()
271 .split('-')
272 .filter(|part| !part.is_empty())
273 .collect::<Vec<_>>()
274 .join("-")
275}
276
277pub fn user_cache_dir_for(repo_root: &Path) -> PathBuf {
290 let hash = default_project_id(repo_root);
291 match user_kimetsu_dir() {
292 Some(home) => home.join("cache").join(&hash),
293 None => std::env::temp_dir().join("kimetsu-cache").join(&hash),
294 }
295}
296
297pub fn display_path(p: &std::path::Path) -> String {
307 let s = p.to_string_lossy();
308 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
309 return format!(r"\\{rest}");
310 }
311 if let Some(rest) = s.strip_prefix(r"\\?\") {
312 return rest.to_string();
313 }
314 s.into_owned()
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use std::path::Path;
321 use std::sync::Mutex;
322
323 fn env_lock() -> &'static Mutex<()> {
327 static LOCK: Mutex<()> = Mutex::new(());
328 &LOCK
329 }
330
331 fn with_brain_dir<R>(dir: &Path, f: impl FnOnce() -> R) -> R {
334 let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
335 let prev = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
336 unsafe {
337 std::env::set_var("KIMETSU_USER_BRAIN_DIR", dir);
338 }
339 let out = f();
340 unsafe {
341 match prev {
342 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
343 None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
344 }
345 }
346 out
347 }
348
349 fn without_brain_dir<R>(f: impl FnOnce() -> R) -> R {
352 let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
353 let prev_override = std::env::var("KIMETSU_USER_BRAIN_DIR").ok();
354 let home_key = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
355 let prev_home = std::env::var(home_key).ok();
356 unsafe {
357 std::env::remove_var("KIMETSU_USER_BRAIN_DIR");
358 std::env::remove_var(home_key);
359 }
360 let out = f();
361 unsafe {
362 match prev_override {
363 Some(v) => std::env::set_var("KIMETSU_USER_BRAIN_DIR", v),
364 None => std::env::remove_var("KIMETSU_USER_BRAIN_DIR"),
365 }
366 match prev_home {
367 Some(v) => std::env::set_var(home_key, v),
368 None => std::env::remove_var(home_key),
369 }
370 }
371 out
372 }
373
374 #[test]
375 fn user_cache_dir_for_lands_under_user_home() {
376 let tmp = std::env::temp_dir().join("kimetsu-test-cache-home");
377 let repo = Path::new("/some/project/my-repo");
378 let result = with_brain_dir(&tmp, || user_cache_dir_for(repo));
379 assert!(
381 result.starts_with(tmp.join("cache")),
382 "expected result under <tmp>/cache, got {result:?}"
383 );
384 assert!(
386 !result.starts_with(repo.join(".kimetsu")),
387 "must not be inside repo .kimetsu, got {result:?}"
388 );
389 let leaf = result.file_name().unwrap().to_str().unwrap();
391 assert_eq!(leaf, "my-repo");
392 }
393
394 #[test]
395 fn user_cache_dir_for_falls_back_to_temp_when_no_home() {
396 let repo = Path::new("/some/project/fallback-repo");
397 let result = without_brain_dir(|| user_cache_dir_for(repo));
398 let tmp = std::env::temp_dir();
400 assert!(
401 result.starts_with(&tmp),
402 "expected result under OS temp dir, got {result:?}"
403 );
404 assert!(
406 result
407 .components()
408 .any(|c| c.as_os_str() == "kimetsu-cache"),
409 "expected 'kimetsu-cache' in path, got {result:?}"
410 );
411 }
412
413 #[test]
414 fn display_path_strips_extended_prefix() {
415 assert_eq!(
417 display_path(Path::new(r"\\?\C:\Users\foo\.kimetsu\brain.db")),
418 r"C:\Users\foo\.kimetsu\brain.db"
419 );
420 assert_eq!(
422 display_path(Path::new(r"\\?\UNC\server\share\path")),
423 r"\\server\share\path"
424 );
425 assert_eq!(
427 display_path(Path::new(r"C:\Users\foo\.kimetsu")),
428 r"C:\Users\foo\.kimetsu"
429 );
430 assert_eq!(
432 display_path(Path::new("/home/user/.kimetsu")),
433 "/home/user/.kimetsu"
434 );
435 }
436
437 #[test]
438 fn slug_is_filesystem_safe() {
439 let id = default_project_id(Path::new("/tmp/my repo with spaces & stuff!"));
441 assert!(
442 id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
443 "slug contains unsafe chars: {id:?}"
444 );
445 assert!(!id.is_empty());
446 }
447
448 #[test]
456 fn validate_state_dir_rejects_symlinked_kimetsu_dir() {
457 let root = temp_root("state_symlink_root");
458 let outside = temp_root("state_symlink_outside");
459 let link = root.join(".kimetsu");
460 if create_dir_symlink(&outside, &link).is_err() {
461 std::fs::remove_dir_all(root).ok();
462 std::fs::remove_dir_all(outside).ok();
463 return;
464 }
465
466 let err = ProjectPaths::at_root(&root)
467 .validate_state_dir()
468 .expect_err("symlinked .kimetsu must be rejected");
469 assert!(
470 format!("{err}").contains("symlinked Kimetsu state dir"),
471 "unexpected error: {err}"
472 );
473
474 std::fs::remove_dir_all(root).ok();
475 std::fs::remove_dir_all(outside).ok();
476 }
477
478 fn temp_root(label: &str) -> PathBuf {
479 let nanos = std::time::SystemTime::now()
480 .duration_since(std::time::UNIX_EPOCH)
481 .unwrap_or_default()
482 .as_nanos();
483 let path = std::env::temp_dir().join(format!("kimetsu_{label}_{nanos}"));
484 std::fs::create_dir_all(&path).expect("create temp root");
485 path
486 }
487
488 #[cfg(unix)]
489 fn create_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
490 std::os::unix::fs::symlink(target, link)
491 }
492
493 #[cfg(windows)]
494 fn create_dir_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
495 std::os::windows::fs::symlink_dir(target, link)
496 }
497
498 #[test]
499 fn pin_discover_to_root_skips_git_climb() {
500 let nested = std::env::temp_dir()
504 .join("kimetsu-pin-test")
505 .join("nested")
506 .join("deep");
507 std::fs::create_dir_all(&nested).expect("create nested dir");
508
509 pin_discover_to_root();
511
512 let paths = ProjectPaths::discover(&nested).expect("discover with pin should not fail");
514
515 let canonical_nested = nested.canonicalize().unwrap_or(nested.clone());
517 let canonical_root = paths
518 .repo_root
519 .canonicalize()
520 .unwrap_or(paths.repo_root.clone());
521 assert_eq!(
522 canonical_root, canonical_nested,
523 "pin_discover_to_root: expected repo_root == nested dir, got {canonical_root:?}"
524 );
525 }
526}