Skip to main content

kimetsu_core/
paths.rs

1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::KimetsuResult;
6
7#[derive(Debug, Clone)]
8pub struct ProjectPaths {
9    pub repo_root: PathBuf,
10    pub kimetsu_dir: PathBuf,
11    pub project_toml: PathBuf,
12    pub brain_db: PathBuf,
13    pub project_log: PathBuf,
14    pub runs_dir: PathBuf,
15    pub lock_file: PathBuf,
16}
17
18impl ProjectPaths {
19    pub fn discover(start: impl AsRef<Path>) -> KimetsuResult<Self> {
20        let start = start.as_ref();
21        let repo_root = discover_repo_root(start)?;
22        let kimetsu_dir = repo_root.join(".kimetsu");
23
24        Ok(Self {
25            repo_root,
26            project_toml: kimetsu_dir.join("project.toml"),
27            brain_db: kimetsu_dir.join("brain.db"),
28            project_log: kimetsu_dir.join("kimetsu.log"),
29            runs_dir: kimetsu_dir.join("runs"),
30            lock_file: kimetsu_dir.join("project.lock"),
31            kimetsu_dir,
32        })
33    }
34}
35
36pub fn discover_repo_root(start: &Path) -> KimetsuResult<PathBuf> {
37    if let Some(root) = git_root(start) {
38        return Ok(root);
39    }
40
41    let start = start.canonicalize()?;
42    if start.is_file() {
43        Ok(start
44            .parent()
45            .ok_or("file path has no parent")?
46            .to_path_buf())
47    } else {
48        Ok(start)
49    }
50}
51
52/// v0.8: make `dir` a standalone git repository (best-effort) so
53/// [`discover_repo_root`] resolves to `dir` itself instead of climbing
54/// to an enclosing repo. Two callers:
55///   * the benchmark harness, for throwaway fixture repos — without
56///     this, a fixture created under the system temp dir on a machine
57///     whose `$HOME` (or any ancestor) is a git repo would init its
58///     brain at that ancestor and leak fixture memories into it;
59///   * tests that create isolated project roots under the temp dir.
60///
61/// Creates `dir` if needed. Returns true when git reported success; a
62/// failure (e.g. git not installed) just means the caller doesn't get
63/// isolation, which is the prior behaviour.
64pub fn git_init_boundary(dir: &Path) -> bool {
65    if std::fs::create_dir_all(dir).is_err() {
66        return false;
67    }
68    Command::new("git")
69        .args(["init", "--quiet"])
70        .current_dir(dir)
71        .output()
72        .map(|o| o.status.success())
73        .unwrap_or(false)
74}
75
76fn git_root(start: &Path) -> Option<PathBuf> {
77    let output = Command::new("git")
78        .args(["rev-parse", "--show-toplevel"])
79        .current_dir(start)
80        .output()
81        .ok()?;
82
83    if !output.status.success() {
84        return None;
85    }
86
87    let stdout = String::from_utf8(output.stdout).ok()?;
88    let root = stdout.trim();
89    if root.is_empty() {
90        return None;
91    }
92
93    PathBuf::from(root).canonicalize().ok()
94}
95
96/// v0.4.1: return the user-scope kimetsu directory (`~/.kimetsu/`).
97///
98/// Resolution order:
99///   1. `$KIMETSU_USER_BRAIN_DIR` if set and non-empty. Used by tests
100///      to point the user brain at a temp dir without touching the
101///      real `$HOME`, and by power users who want the brain to live
102///      somewhere other than home (encrypted volume, network share,
103///      etc.).
104///   2. `$HOME` on Unix / `$USERPROFILE` on Windows, joined with
105///      `.kimetsu`.
106///
107/// Returns `None` only when neither env var is set — in practice we
108/// always have a home dir, so this almost never returns None outside
109/// of stripped CI environments.
110pub fn user_kimetsu_dir() -> Option<PathBuf> {
111    if let Ok(override_dir) = std::env::var("KIMETSU_USER_BRAIN_DIR") {
112        let trimmed = override_dir.trim();
113        if !trimmed.is_empty() {
114            return Some(PathBuf::from(trimmed));
115        }
116    }
117    let home = if cfg!(windows) {
118        std::env::var("USERPROFILE").ok()
119    } else {
120        std::env::var("HOME").ok()
121    };
122    home.filter(|h| !h.trim().is_empty())
123        .map(|h| PathBuf::from(h).join(".kimetsu"))
124}
125
126/// v0.4.1: full path to the user-scope brain.db.
127///
128/// Convenience wrapper over [`user_kimetsu_dir`] that appends
129/// `brain.db`. Returns None when no home directory is resolvable.
130pub fn user_brain_db_path() -> Option<PathBuf> {
131    user_kimetsu_dir().map(|dir| dir.join("brain.db"))
132}
133
134/// v0.4.1: returns true when the user brain is enabled.
135///
136/// `KIMETSU_USER_BRAIN=0` / `false` / `off` / `no` disables it
137/// (case-insensitive). Anything else, including unset, leaves it
138/// enabled by default — the "brain follows you between projects"
139/// pitch only works if the user opts OUT, not opts IN.
140pub fn user_brain_enabled() -> bool {
141    let value = match std::env::var("KIMETSU_USER_BRAIN") {
142        Ok(v) => v,
143        Err(_) => return true,
144    };
145    let v = value.trim().to_ascii_lowercase();
146    !matches!(v.as_str(), "0" | "false" | "off" | "no")
147}
148
149pub fn default_project_id(repo_root: &Path) -> String {
150    repo_root
151        .file_name()
152        .and_then(OsStr::to_str)
153        .map(slug)
154        .filter(|value| !value.is_empty())
155        .unwrap_or_else(|| "kimetsu-project".to_string())
156}
157
158fn slug(value: &str) -> String {
159    value
160        .chars()
161        .map(|ch| {
162            if ch.is_ascii_alphanumeric() {
163                ch.to_ascii_lowercase()
164            } else {
165                '-'
166            }
167        })
168        .collect::<String>()
169        .split('-')
170        .filter(|part| !part.is_empty())
171        .collect::<Vec<_>>()
172        .join("-")
173}