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
52pub 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
96pub 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
126pub fn user_brain_db_path() -> Option<PathBuf> {
131 user_kimetsu_dir().map(|dir| dir.join("brain.db"))
132}
133
134pub 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}