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
52fn git_root(start: &Path) -> Option<PathBuf> {
53    let output = Command::new("git")
54        .args(["rev-parse", "--show-toplevel"])
55        .current_dir(start)
56        .output()
57        .ok()?;
58
59    if !output.status.success() {
60        return None;
61    }
62
63    let stdout = String::from_utf8(output.stdout).ok()?;
64    let root = stdout.trim();
65    if root.is_empty() {
66        return None;
67    }
68
69    PathBuf::from(root).canonicalize().ok()
70}
71
72/// v0.4.1: return the user-scope kimetsu directory (`~/.kimetsu/`).
73///
74/// Resolution order:
75///   1. `$KIMETSU_USER_BRAIN_DIR` if set and non-empty. Used by tests
76///      to point the user brain at a temp dir without touching the
77///      real `$HOME`, and by power users who want the brain to live
78///      somewhere other than home (encrypted volume, network share,
79///      etc.).
80///   2. `$HOME` on Unix / `$USERPROFILE` on Windows, joined with
81///      `.kimetsu`.
82///
83/// Returns `None` only when neither env var is set — in practice we
84/// always have a home dir, so this almost never returns None outside
85/// of stripped CI environments.
86pub fn user_kimetsu_dir() -> Option<PathBuf> {
87    if let Ok(override_dir) = std::env::var("KIMETSU_USER_BRAIN_DIR") {
88        let trimmed = override_dir.trim();
89        if !trimmed.is_empty() {
90            return Some(PathBuf::from(trimmed));
91        }
92    }
93    let home = if cfg!(windows) {
94        std::env::var("USERPROFILE").ok()
95    } else {
96        std::env::var("HOME").ok()
97    };
98    home.filter(|h| !h.trim().is_empty())
99        .map(|h| PathBuf::from(h).join(".kimetsu"))
100}
101
102/// v0.4.1: full path to the user-scope brain.db.
103///
104/// Convenience wrapper over [`user_kimetsu_dir`] that appends
105/// `brain.db`. Returns None when no home directory is resolvable.
106pub fn user_brain_db_path() -> Option<PathBuf> {
107    user_kimetsu_dir().map(|dir| dir.join("brain.db"))
108}
109
110/// v0.4.1: returns true when the user brain is enabled.
111///
112/// `KIMETSU_USER_BRAIN=0` / `false` / `off` / `no` disables it
113/// (case-insensitive). Anything else, including unset, leaves it
114/// enabled by default — the "brain follows you between projects"
115/// pitch only works if the user opts OUT, not opts IN.
116pub fn user_brain_enabled() -> bool {
117    let value = match std::env::var("KIMETSU_USER_BRAIN") {
118        Ok(v) => v,
119        Err(_) => return true,
120    };
121    let v = value.trim().to_ascii_lowercase();
122    !matches!(v.as_str(), "0" | "false" | "off" | "no")
123}
124
125pub fn default_project_id(repo_root: &Path) -> String {
126    repo_root
127        .file_name()
128        .and_then(OsStr::to_str)
129        .map(slug)
130        .filter(|value| !value.is_empty())
131        .unwrap_or_else(|| "kimetsu-project".to_string())
132}
133
134fn slug(value: &str) -> String {
135    value
136        .chars()
137        .map(|ch| {
138            if ch.is_ascii_alphanumeric() {
139                ch.to_ascii_lowercase()
140            } else {
141                '-'
142            }
143        })
144        .collect::<String>()
145        .split('-')
146        .filter(|part| !part.is_empty())
147        .collect::<Vec<_>>()
148        .join("-")
149}