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 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
59pub 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
103pub 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
133pub fn user_brain_db_path() -> Option<PathBuf> {
138 user_kimetsu_dir().map(|dir| dir.join("brain.db"))
139}
140
141pub 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}