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