1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use kmp_adapter_embedded::validate_store_layout;
6use kmp_domain::PortError;
7
8pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
10
11const PROJECT_DIR_NAME: &str = ".kernel";
12
13pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ResolvedDataDir {
30 Explicit(PathBuf),
32 Project(PathBuf),
34 UserDefault(PathBuf),
36 UserFallback {
40 path: PathBuf,
41 orphaned_bundle: OrphanedProjectBundle,
42 },
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct OrphanedProjectBundle {
50 pub bundle_path: PathBuf,
51 pub project_store_path: PathBuf,
52 pub selected_store_path: PathBuf,
53 pub reason: String,
54}
55
56impl ResolvedDataDir {
57 pub fn path(&self) -> &Path {
58 match self {
59 Self::Explicit(path) | Self::Project(path) | Self::UserDefault(path) => path,
60 Self::UserFallback { path, .. } => path,
61 }
62 }
63
64 pub fn rule_name(&self) -> &'static str {
65 match self {
66 Self::Explicit(_) => "env",
67 Self::Project(_) => "project",
68 Self::UserDefault(_) => "user",
69 Self::UserFallback { .. } => "user fallback",
70 }
71 }
72
73 pub fn orphaned_bundle(&self) -> Option<&OrphanedProjectBundle> {
74 match self {
75 Self::UserFallback {
76 orphaned_bundle, ..
77 } => Some(orphaned_bundle),
78 Self::Explicit(_) | Self::Project(_) | Self::UserDefault(_) => None,
79 }
80 }
81}
82
83pub fn resolve_data_dir(
87 env_override: Option<&str>,
88 working_dir: &Path,
89 user_data_home: &Path,
90) -> ResolvedDataDir {
91 resolve_with_project_marker(env_override, working_dir, user_data_home, |candidate| {
92 candidate.join(".git").exists()
93 })
94}
95
96fn resolve_with_project_marker(
97 env_override: Option<&str>,
98 working_dir: &Path,
99 user_data_home: &Path,
100 is_project_root: impl Fn(&Path) -> bool,
101) -> ResolvedDataDir {
102 if let Some(explicit) = env_override
103 .map(str::trim)
104 .filter(|value| !value.is_empty())
105 {
106 let path = PathBuf::from(explicit);
107 return ResolvedDataDir::Explicit(if path.is_absolute() {
108 path
109 } else {
110 working_dir.join(path)
111 });
112 }
113
114 let mut current = Some(working_dir);
115 while let Some(candidate) = current {
116 if is_project_root(candidate) {
117 return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
118 }
119 current = candidate.parent();
120 }
121
122 ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
123}
124
125pub fn user_data_home() -> Option<PathBuf> {
132 user_data_home_from(|name| std::env::var_os(name))
133}
134
135fn user_data_home_from(mut read: impl FnMut(&str) -> Option<OsString>) -> Option<PathBuf> {
136 let path = |value: Option<OsString>| {
137 value
138 .map(PathBuf::from)
139 .filter(|candidate| !candidate.as_os_str().is_empty())
140 };
141
142 path(read("XDG_DATA_HOME"))
143 .or_else(|| path(read("HOME")).map(|home| home.join(".local").join("share")))
144 .or_else(|| path(read("LOCALAPPDATA")))
145 .or_else(|| path(read("APPDATA")))
146 .or_else(|| path(read("USERPROFILE")).map(|home| home.join("AppData").join("Local")))
147}
148
149pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
155 match resolved {
156 ResolvedDataDir::Project(path) => path
157 .parent()
158 .map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
159 ResolvedDataDir::Explicit(_)
160 | ResolvedDataDir::UserDefault(_)
161 | ResolvedDataDir::UserFallback { .. } => None,
162 }
163}
164
165pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
169 let resolved = locate_data_dir_from_env()?;
170 prepare_data_dir(&resolved)?;
171 Ok(resolved)
172}
173
174pub fn locate_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
181 let env_override = std::env::var(DATA_DIR_ENV).ok();
182 let working_dir = std::env::current_dir().map_err(|error| {
183 PortError::Unavailable(format!(
184 "embedded kernel could not resolve the working directory: {error}"
185 ))
186 })?;
187 let user_data_home = user_data_home().ok_or_else(|| {
188 PortError::Unavailable(
189 "embedded kernel could not resolve a user data directory \
190 (none of XDG_DATA_HOME, HOME, LOCALAPPDATA, APPDATA, or USERPROFILE is set)"
191 .to_string(),
192 )
193 })?;
194 reject_unexpanded_home_override(env_override.as_deref())?;
195
196 let resolved = resolve_data_dir(env_override.as_deref(), &working_dir, &user_data_home);
197 Ok(fallback_from_unopenable_project(resolved, &user_data_home))
198}
199
200fn fallback_from_unopenable_project(
201 resolved: ResolvedDataDir,
202 user_data_home: &Path,
203) -> ResolvedDataDir {
204 let ResolvedDataDir::Project(project_store_path) = resolved else {
205 return resolved;
206 };
207 let Some(project_root) = project_store_path.parent() else {
208 return ResolvedDataDir::Project(project_store_path);
209 };
210 let bundle_path = project_root.join(PROJECT_BUNDLE_PATH);
211 if !bundle_path.is_file() {
212 return ResolvedDataDir::Project(project_store_path);
213 }
214 let Err(error) = validate_store_layout(&project_store_path) else {
215 return ResolvedDataDir::Project(project_store_path);
216 };
217 let selected_store_path = user_data_home.join("kmp").join("default");
218 ResolvedDataDir::UserFallback {
219 path: selected_store_path.clone(),
220 orphaned_bundle: OrphanedProjectBundle {
221 bundle_path,
222 project_store_path,
223 selected_store_path,
224 reason: error.to_string(),
225 },
226 }
227}
228
229fn reject_unexpanded_home_override(env_override: Option<&str>) -> Result<(), PortError> {
230 let Some(explicit) = env_override
231 .map(str::trim)
232 .filter(|value| !value.is_empty())
233 else {
234 return Ok(());
235 };
236 let path = Path::new(explicit);
237 let starts_with_tilde = path
238 .components()
239 .next()
240 .is_some_and(|component| component.as_os_str() == "~");
241 if !starts_with_tilde {
242 return Ok(());
243 }
244
245 let suggestion = user_home()
246 .and_then(|home| path.strip_prefix("~").ok().map(|suffix| home.join(suffix)))
247 .map(|path| format!("; use `{}`", path.display()))
248 .unwrap_or_else(|| "; use an absolute path instead".to_string());
249 Err(PortError::InvalidState(format!(
250 "{DATA_DIR_ENV} value `{explicit}` starts with `~`, but MCP host configuration does not \
251 expand shell paths{suggestion}"
252 )))
253}
254
255fn user_home() -> Option<PathBuf> {
256 ["HOME", "USERPROFILE"]
257 .into_iter()
258 .find_map(std::env::var_os)
259 .map(PathBuf::from)
260 .filter(|path| !path.as_os_str().is_empty())
261}
262
263fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
264 ensure_data_dir_skeleton(resolved.path())
265}
266
267pub fn ensure_data_dir_skeleton(path: &Path) -> Result<(), PortError> {
274 fs::create_dir_all(path).map_err(|error| {
275 PortError::Unavailable(format!(
276 "embedded kernel could not create data dir `{}`: {error}",
277 path.display()
278 ))
279 })?;
280
281 let gitignore = path.join(".gitignore");
282 if !gitignore.exists() {
283 fs::write(&gitignore, "*\n").map_err(|error| {
284 PortError::Unavailable(format!(
285 "embedded kernel could not write `{}`: {error}",
286 gitignore.display()
287 ))
288 })?;
289 }
290 let logs = path.join("logs");
291 fs::create_dir_all(&logs).map_err(|error| {
292 PortError::Unavailable(format!(
293 "embedded kernel could not create log dir `{}`: {error}",
294 logs.display()
295 ))
296 })?;
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn only_a_project_store_has_a_conventional_bundle_path() {
306 let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
307 assert_eq!(
308 project_bundle_path(&project),
309 Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
310 "the bundle sits beside the store's project root, not inside the store"
311 );
312
313 assert_eq!(
316 project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
317 None
318 );
319 assert_eq!(
320 project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
321 None
322 );
323 }
324
325 #[test]
326 fn env_override_wins_over_everything() {
327 let resolved = resolve_data_dir(
328 Some("/explicit/dir"),
329 Path::new("/some/project"),
330 Path::new("/home/u/.local/share"),
331 );
332 assert_eq!(
333 resolved,
334 ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
335 );
336 assert_eq!(resolved.rule_name(), "env");
337 }
338
339 #[test]
340 fn a_relative_override_is_reported_as_the_path_that_will_actually_open() {
341 let resolved = resolve_data_dir(
342 Some("memory/kmp"),
343 Path::new("/workspace/project"),
344 Path::new("/home/u/.local/share"),
345 );
346 assert_eq!(
347 resolved,
348 ResolvedDataDir::Explicit(PathBuf::from("/workspace/project/memory/kmp"))
349 );
350 }
351
352 #[test]
353 fn blank_env_override_is_ignored() {
354 let resolved = resolve_with_project_marker(
355 Some(" "),
356 Path::new("/anywhere"),
357 Path::new("/data"),
358 |_| false,
359 );
360 assert_eq!(resolved.rule_name(), "user");
361 }
362
363 #[test]
364 fn project_root_is_found_by_walking_up_to_git() {
365 let temp = tempfile::tempdir().expect("tempdir");
366 let nested = temp.path().join("workspace").join("src");
367 std::fs::create_dir_all(&nested).expect("nested dirs");
368 std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
369
370 let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
371 assert_eq!(
372 resolved,
373 ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
374 );
375 }
376
377 #[test]
378 fn no_project_falls_back_to_user_data_dir() {
379 let resolved = resolve_with_project_marker(
380 None,
381 Path::new("/anywhere/nested"),
382 Path::new("/home/u/.local/share"),
383 |_| false,
384 );
385 assert_eq!(
386 resolved,
387 ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
388 );
389 }
390
391 #[test]
392 fn an_unopenable_project_store_with_a_bundle_selects_user_memory_and_keeps_the_loss() {
393 let project = tempfile::tempdir().expect("project");
394 let project_store = project.path().join(".kernel");
395 std::fs::create_dir_all(project_store.join("store")).expect("legacy store dir");
396 std::fs::write(project_store.join("FORMAT_VERSION"), "1\n").expect("legacy stamp");
397 std::fs::write(project_store.join("store/retired-layout.bin"), b"legacy")
398 .expect("legacy store");
399 let bundle = project.path().join(PROJECT_BUNDLE_PATH);
400 std::fs::create_dir_all(bundle.parent().expect("bundle parent")).expect("bundle dir");
401 std::fs::write(&bundle, "maintained memory\n").expect("bundle");
402
403 let selected = fallback_from_unopenable_project(
404 ResolvedDataDir::Project(project_store.clone()),
405 Path::new("/user-data"),
406 );
407
408 assert_eq!(selected.path(), Path::new("/user-data/kmp/default"));
409 assert_eq!(selected.rule_name(), "user fallback");
410 let orphaned = selected.orphaned_bundle().expect("orphaned bundle outcome");
411 assert_eq!(orphaned.bundle_path, bundle);
412 assert_eq!(orphaned.project_store_path, project_store);
413 assert_eq!(orphaned.selected_store_path, selected.path());
414 assert!(
415 orphaned.reason.contains("format version 1"),
416 "{}",
417 orphaned.reason
418 );
419 assert_eq!(project_bundle_path(&selected), None);
420 }
421
422 #[test]
423 fn an_unopenable_project_without_a_bundle_still_fails_closed_in_place() {
424 let project = tempfile::tempdir().expect("project");
425 let project_store = project.path().join(".kernel");
426 std::fs::create_dir_all(&project_store).expect("legacy store dir");
427 std::fs::write(project_store.join("FORMAT_VERSION"), "1\n").expect("legacy stamp");
428
429 let selected = fallback_from_unopenable_project(
430 ResolvedDataDir::Project(project_store.clone()),
431 Path::new("/user-data"),
432 );
433
434 assert_eq!(selected, ResolvedDataDir::Project(project_store));
435 assert!(selected.orphaned_bundle().is_none());
436 }
437
438 #[test]
439 fn user_data_home_keeps_unix_precedence_and_supports_native_windows() {
440 let unix = user_data_home_from(|name| match name {
441 "XDG_DATA_HOME" => Some(OsString::from("/xdg")),
442 "HOME" => Some(OsString::from("/home/user")),
443 "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
444 _ => None,
445 });
446 assert_eq!(unix, Some(PathBuf::from("/xdg")));
447
448 let windows = user_data_home_from(|name| match name {
449 "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
450 "APPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Roaming")),
451 _ => None,
452 });
453 assert_eq!(windows, Some(PathBuf::from(r"C:\Users\user\AppData\Local")));
454
455 let profile = user_data_home_from(|name| match name {
456 "USERPROFILE" => Some(OsString::from(r"C:\Users\user")),
457 _ => None,
458 });
459 assert_eq!(
460 profile,
461 Some(
462 PathBuf::from(r"C:\Users\user")
463 .join("AppData")
464 .join("Local")
465 )
466 );
467 }
468
469 #[test]
470 fn project_dir_preparation_writes_self_ignoring_gitignore() {
471 let temp = tempfile::tempdir().expect("tempdir");
472 let kernel_dir = temp.path().join(".kernel");
473 let resolved = ResolvedDataDir::Project(kernel_dir.clone());
474
475 prepare_data_dir(&resolved).expect("prepare");
476
477 let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
478 assert_eq!(gitignore, "*\n");
479 assert!(kernel_dir.join("logs").is_dir());
480 }
481
482 #[test]
483 fn explicit_dirs_get_the_same_non_destructive_skeleton() {
484 let temp = tempfile::tempdir().expect("tempdir");
485 let data_dir = temp.path().join("destination");
486
487 ensure_data_dir_skeleton(&data_dir).expect("prepare explicit destination");
488 assert_eq!(
489 std::fs::read_to_string(data_dir.join(".gitignore")).expect("gitignore"),
490 "*\n"
491 );
492 assert!(data_dir.join("logs").is_dir());
493
494 std::fs::write(data_dir.join(".gitignore"), "keep-me\n").expect("custom ignore");
495 ensure_data_dir_skeleton(&data_dir).expect("prepare again");
496 assert_eq!(
497 std::fs::read_to_string(data_dir.join(".gitignore")).expect("custom gitignore"),
498 "keep-me\n",
499 "the skeleton never overwrites an operator-owned ignore file"
500 );
501 }
502}