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
72pub 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
102pub fn user_brain_db_path() -> Option<PathBuf> {
107 user_kimetsu_dir().map(|dir| dir.join("brain.db"))
108}
109
110pub 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}