Skip to main content

kimetsu_core/
paths.rs

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
10/// Pin discovery to at_root semantics process-wide (remote server calls once
11/// at startup): every [`ProjectPaths::discover`] call thereafter behaves like
12/// [`ProjectPaths::at_root`] — no git subprocess, never climbs to an
13/// enclosing repo. Idempotent.
14pub 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    /// Build the paths anchored at an explicit `repo_root`, WITHOUT
43    /// climbing to an enclosing git repository. Use this when a command
44    /// is told exactly which directory to operate on (e.g. the install
45    /// wizard's `--workspace`), so it never writes into a parent repo.
46    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    /// Validate that project state paths remain physically under the project
61    /// root and are not redirected through `.kimetsu` symlinks/junction-style
62    /// final components.
63    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
137/// v0.8: make `dir` a standalone git repository (best-effort) so
138/// [`discover_repo_root`] resolves to `dir` itself instead of climbing
139/// to an enclosing repo. Two callers:
140///   * the benchmark harness, for throwaway fixture repos — without
141///     this, a fixture created under the system temp dir on a machine
142///     whose `$HOME` (or any ancestor) is a git repo would init its
143///     brain at that ancestor and leak fixture memories into it;
144///   * tests that create isolated project roots under the temp dir.
145///
146/// Creates `dir` if needed. Returns true when git reported success; a
147/// failure (e.g. git not installed) just means the caller doesn't get
148/// isolation, which is the prior behaviour.
149pub 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
181/// v0.4.1: return the user-scope kimetsu directory (`~/.kimetsu/`).
182///
183/// Resolution order:
184///   1. `$KIMETSU_USER_BRAIN_DIR` if set and non-empty. Used by tests
185///      to point the user brain at a temp dir without touching the
186///      real `$HOME`, and by power users who want the brain to live
187///      somewhere other than home (encrypted volume, network share,
188///      etc.).
189///   2. `$HOME` on Unix / `$USERPROFILE` on Windows, joined with
190///      `.kimetsu`.
191///
192/// Returns `None` only when neither env var is set — in practice we
193/// always have a home dir, so this almost never returns None outside
194/// of stripped CI environments.
195pub 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
211/// v0.4.1: full path to the user-scope brain.db.
212///
213/// Convenience wrapper over [`user_kimetsu_dir`] that appends
214/// `brain.db`. Returns None when no home directory is resolvable.
215pub fn user_brain_db_path() -> Option<PathBuf> {
216    user_kimetsu_dir().map(|dir| dir.join("brain.db"))
217}
218
219/// v0.4.1: returns true when the user brain is enabled.
220///
221/// `KIMETSU_USER_BRAIN=0` / `false` / `off` / `no` disables it
222/// (case-insensitive). Anything else, including unset, leaves it
223/// enabled by default — the "brain follows you between projects"
224/// pitch only works if the user opts OUT, not opts IN.
225pub fn user_brain_enabled() -> bool {
226    // Delegate to the config-aware variant with the default (true).
227    user_brain_enabled_with(true)
228}
229
230/// W3.3: config-aware user-brain gate. Resolution precedence:
231///   1. `KIMETSU_USER_BRAIN` env is explicitly set → its value wins.
232///   2. Env is unset → `config_use_user_brain` governs.
233///
234/// Callers with a `ProjectConfig` should pass
235/// `config.kimetsu.use_user_brain`; back-compat callers can use
236/// `user_brain_enabled()`.
237pub fn user_brain_enabled_with(config_use_user_brain: bool) -> bool {
238    // Precedence: env override > config > default.
239    match std::env::var("KIMETSU_USER_BRAIN") {
240        Ok(value) => {
241            let v = value.trim().to_ascii_lowercase();
242            // Env is set — respect it (disable values → false, anything
243            // else including empty → treat as "on").
244            !matches!(v.as_str(), "0" | "false" | "off" | "no")
245        }
246        // Env unset → config governs.
247        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
277/// W2: per-project cache root for transient, non-brain artifacts
278/// (proactive hook state, chat REPL output, benchmark output).
279///
280/// Kept OUT of the project's `.kimetsu/` so a brain-only install's
281/// `.kimetsu/` stays lean. Lives under the user kimetsu home
282/// (`~/.kimetsu/cache/<project-id>/`), honouring
283/// `KIMETSU_USER_BRAIN_DIR`. Falls back to the OS temp dir when no
284/// home resolves, so it NEVER lands back inside `.kimetsu/`.
285///
286/// The `<project-id>` component is the same slug produced by
287/// [`default_project_id`], which is filesystem-safe (ASCII
288/// alphanumeric + hyphens only, non-empty).
289pub 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
297/// Strip the Windows `\\?\` extended-path prefix from a path string for
298/// display purposes only. The stored/internal path is never modified.
299///
300/// Conversions:
301///   `\\?\UNC\server\share` → `\\server\share`
302///   `\\?\C:\foo`           → `C:\foo`
303///   anything else          → unchanged
304///
305/// On non-Windows this is a no-op; the prefix never appears there.
306pub 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    /// Process-wide mutex for env-mutating path tests.  Any test that
324    /// temporarily modifies `KIMETSU_USER_BRAIN_DIR`, `HOME`, or
325    /// `USERPROFILE` must hold this guard for the duration.
326    fn env_lock() -> &'static Mutex<()> {
327        static LOCK: Mutex<()> = Mutex::new(());
328        &LOCK
329    }
330
331    /// Run `f` with `KIMETSU_USER_BRAIN_DIR` set to `dir`, restoring the
332    /// previous value under the shared env lock.
333    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    /// Run `f` with both `KIMETSU_USER_BRAIN_DIR` and the platform home
350    /// env var cleared, so `user_kimetsu_dir()` returns `None`.
351    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        // Must be under <tmp>/cache/<slug>/
380        assert!(
381            result.starts_with(tmp.join("cache")),
382            "expected result under <tmp>/cache, got {result:?}"
383        );
384        // Must not be inside .kimetsu of the repo.
385        assert!(
386            !result.starts_with(repo.join(".kimetsu")),
387            "must not be inside repo .kimetsu, got {result:?}"
388        );
389        // The leaf component is the slug of the repo name.
390        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        // Must be under the OS temp dir, not under ~/.kimetsu.
399        let tmp = std::env::temp_dir();
400        assert!(
401            result.starts_with(&tmp),
402            "expected result under OS temp dir, got {result:?}"
403        );
404        // Must contain "kimetsu-cache".
405        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        // \\?\C:\foo -> C:\foo
416        assert_eq!(
417            display_path(Path::new(r"\\?\C:\Users\foo\.kimetsu\brain.db")),
418            r"C:\Users\foo\.kimetsu\brain.db"
419        );
420        // \\?\UNC\server\share -> \\server\share
421        assert_eq!(
422            display_path(Path::new(r"\\?\UNC\server\share\path")),
423            r"\\server\share\path"
424        );
425        // Already clean — unchanged.
426        assert_eq!(
427            display_path(Path::new(r"C:\Users\foo\.kimetsu")),
428            r"C:\Users\foo\.kimetsu"
429        );
430        // Unix-style — unchanged.
431        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        // No env mutation — no lock needed.
440        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    /// pin_discover_to_root — once set, ProjectPaths::discover(nested_dir)
449    /// must return paths rooted AT that dir, NOT at any git ancestor.
450    ///
451    /// IMPORTANT: OnceLock cannot be reset, so this pin is process-wide and
452    /// permanent once set. This test is intentionally kept minimal and
453    /// self-contained. Primary coverage of the no-git seam lives in the
454    /// kimetsu-brain project.rs `*_at_root` tests which do NOT need the pin.
455    #[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        // Create a temp dir nested inside the current git repo (E:\Kimetsu is
501        // a git repo, so any child dir without its own .git would normally
502        // climb to E:\Kimetsu). We use a deeply nested path to be sure.
503        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        // Set the pin (process-global, irreversible — that's by design).
510        pin_discover_to_root();
511
512        // discover(nested) must return nested itself, not a git ancestor.
513        let paths = ProjectPaths::discover(&nested).expect("discover with pin should not fail");
514
515        // The repo_root must be exactly `nested` (or its canonical form).
516        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}