1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kmp_domain::PortError;
5
6pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
8
9const PROJECT_DIR_NAME: &str = ".kernel";
10
11pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ResolvedDataDir {
28 Explicit(PathBuf),
30 Project(PathBuf),
32 UserDefault(PathBuf),
34}
35
36impl ResolvedDataDir {
37 pub fn path(&self) -> &Path {
38 match self {
39 Self::Explicit(path) | Self::Project(path) | Self::UserDefault(path) => path,
40 }
41 }
42
43 pub fn rule_name(&self) -> &'static str {
44 match self {
45 Self::Explicit(_) => "env",
46 Self::Project(_) => "project",
47 Self::UserDefault(_) => "user",
48 }
49 }
50}
51
52pub fn resolve_data_dir(
56 env_override: Option<&str>,
57 working_dir: &Path,
58 user_data_home: &Path,
59) -> ResolvedDataDir {
60 resolve_with_project_marker(env_override, working_dir, user_data_home, |candidate| {
61 candidate.join(".git").exists()
62 })
63}
64
65fn resolve_with_project_marker(
66 env_override: Option<&str>,
67 working_dir: &Path,
68 user_data_home: &Path,
69 is_project_root: impl Fn(&Path) -> bool,
70) -> ResolvedDataDir {
71 if let Some(explicit) = env_override
72 .map(str::trim)
73 .filter(|value| !value.is_empty())
74 {
75 return ResolvedDataDir::Explicit(PathBuf::from(explicit));
76 }
77
78 let mut current = Some(working_dir);
79 while let Some(candidate) = current {
80 if is_project_root(candidate) {
81 return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
82 }
83 current = candidate.parent();
84 }
85
86 ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
87}
88
89pub fn user_data_home() -> Option<PathBuf> {
95 std::env::var("XDG_DATA_HOME")
96 .map(PathBuf::from)
97 .ok()
98 .filter(|path| !path.as_os_str().is_empty())
99 .or_else(|| {
100 std::env::var("HOME")
101 .ok()
102 .map(|home| PathBuf::from(home).join(".local").join("share"))
103 })
104}
105
106pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
112 match resolved {
113 ResolvedDataDir::Project(path) => path
114 .parent()
115 .map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
116 ResolvedDataDir::Explicit(_) | ResolvedDataDir::UserDefault(_) => None,
117 }
118}
119
120pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
124 let resolved = locate_data_dir_from_env()?;
125 prepare_data_dir(&resolved)?;
126 Ok(resolved)
127}
128
129pub fn locate_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
136 let env_override = std::env::var(DATA_DIR_ENV).ok();
137 let working_dir = std::env::current_dir().map_err(|error| {
138 PortError::Unavailable(format!(
139 "embedded kernel could not resolve the working directory: {error}"
140 ))
141 })?;
142 let user_data_home = user_data_home().ok_or_else(|| {
143 PortError::Unavailable(
144 "embedded kernel could not resolve a user data directory \
145 (neither XDG_DATA_HOME nor HOME is set)"
146 .to_string(),
147 )
148 })?;
149
150 Ok(resolve_data_dir(
151 env_override.as_deref(),
152 &working_dir,
153 &user_data_home,
154 ))
155}
156
157fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
158 ensure_data_dir_skeleton(resolved.path())
159}
160
161pub fn ensure_data_dir_skeleton(path: &Path) -> Result<(), PortError> {
169 fs::create_dir_all(path).map_err(|error| {
170 PortError::Unavailable(format!(
171 "embedded kernel could not create data dir `{}`: {error}",
172 path.display()
173 ))
174 })?;
175
176 let gitignore = path.join(".gitignore");
177 if !gitignore.exists() {
178 fs::write(&gitignore, "*\n").map_err(|error| {
179 PortError::Unavailable(format!(
180 "embedded kernel could not write `{}`: {error}",
181 gitignore.display()
182 ))
183 })?;
184 }
185 let logs = path.join("logs");
186 fs::create_dir_all(&logs).map_err(|error| {
187 PortError::Unavailable(format!(
188 "embedded kernel could not create log dir `{}`: {error}",
189 logs.display()
190 ))
191 })?;
192 Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn only_a_project_store_has_a_conventional_bundle_path() {
201 let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
202 assert_eq!(
203 project_bundle_path(&project),
204 Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
205 "the bundle sits beside the store's project root, not inside the store"
206 );
207
208 assert_eq!(
211 project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
212 None
213 );
214 assert_eq!(
215 project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
216 None
217 );
218 }
219
220 #[test]
221 fn env_override_wins_over_everything() {
222 let resolved = resolve_data_dir(
223 Some("/explicit/dir"),
224 Path::new("/some/project"),
225 Path::new("/home/u/.local/share"),
226 );
227 assert_eq!(
228 resolved,
229 ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
230 );
231 assert_eq!(resolved.rule_name(), "env");
232 }
233
234 #[test]
235 fn blank_env_override_is_ignored() {
236 let resolved = resolve_with_project_marker(
237 Some(" "),
238 Path::new("/anywhere"),
239 Path::new("/data"),
240 |_| false,
241 );
242 assert_eq!(resolved.rule_name(), "user");
243 }
244
245 #[test]
246 fn project_root_is_found_by_walking_up_to_git() {
247 let temp = tempfile::tempdir().expect("tempdir");
248 let nested = temp.path().join("workspace").join("src");
249 std::fs::create_dir_all(&nested).expect("nested dirs");
250 std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
251
252 let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
253 assert_eq!(
254 resolved,
255 ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
256 );
257 }
258
259 #[test]
260 fn no_project_falls_back_to_user_data_dir() {
261 let resolved = resolve_with_project_marker(
262 None,
263 Path::new("/anywhere/nested"),
264 Path::new("/home/u/.local/share"),
265 |_| false,
266 );
267 assert_eq!(
268 resolved,
269 ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
270 );
271 }
272
273 #[test]
274 fn project_dir_preparation_writes_self_ignoring_gitignore() {
275 let temp = tempfile::tempdir().expect("tempdir");
276 let kernel_dir = temp.path().join(".kernel");
277 let resolved = ResolvedDataDir::Project(kernel_dir.clone());
278
279 prepare_data_dir(&resolved).expect("prepare");
280
281 let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
282 assert_eq!(gitignore, "*\n");
283 assert!(kernel_dir.join("logs").is_dir());
284 }
285
286 #[test]
287 fn explicit_and_migrated_dirs_get_the_same_non_destructive_skeleton() {
288 let temp = tempfile::tempdir().expect("tempdir");
289 let data_dir = temp.path().join("destination");
290
291 ensure_data_dir_skeleton(&data_dir).expect("prepare explicit destination");
292 assert_eq!(
293 std::fs::read_to_string(data_dir.join(".gitignore")).expect("gitignore"),
294 "*\n"
295 );
296 assert!(data_dir.join("logs").is_dir());
297
298 std::fs::write(data_dir.join(".gitignore"), "keep-me\n").expect("custom ignore");
299 ensure_data_dir_skeleton(&data_dir).expect("prepare again");
300 assert_eq!(
301 std::fs::read_to_string(data_dir.join(".gitignore")).expect("custom gitignore"),
302 "keep-me\n",
303 "the skeleton never overwrites an operator-owned ignore file"
304 );
305 }
306}