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
8use crate::memory_selection::{self, SelectedMemory};
9use crate::memory_selection_refusal::SelectionRefusal;
10
11pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
13
14const PROJECT_DIR_NAME: &str = ".kernel";
15
16pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
28
29#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ResolvedDataDir {
33 Explicit(PathBuf),
35 Saved(PathBuf),
37 Project(PathBuf),
39 UserDefault(PathBuf),
41 UserFallback {
45 path: PathBuf,
46 orphaned_bundle: OrphanedProjectBundle,
47 },
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct OrphanedProjectBundle {
55 pub bundle_path: PathBuf,
56 pub project_store_path: PathBuf,
57 pub selected_store_path: PathBuf,
58 pub reason: String,
59}
60
61impl ResolvedDataDir {
62 pub fn path(&self) -> &Path {
63 match self {
64 Self::Explicit(path)
65 | Self::Saved(path)
66 | Self::Project(path)
67 | Self::UserDefault(path) => path,
68 Self::UserFallback { path, .. } => path,
69 }
70 }
71
72 pub fn rule_name(&self) -> &'static str {
73 match self {
74 Self::Explicit(_) => "env",
75 Self::Saved(_) => "saved",
76 Self::Project(_) => "project",
77 Self::UserDefault(_) => "user",
78 Self::UserFallback { .. } => "user fallback",
79 }
80 }
81
82 pub fn rule_sentence(&self) -> &'static str {
84 match self {
85 Self::Explicit(_) => {
86 "the KMP_MCP_DATA_DIR environment variable, which overrides everything for this \
87 process"
88 }
89 Self::Saved(_) => "the selection saved in the user config file",
90 Self::Project(_) => "the nearest project root above the working directory",
91 Self::UserDefault(_) => "the per-user default, because nothing more specific applied",
92 Self::UserFallback { .. } => {
93 "the per-user default, because the project store beside a committed bundle could \
94 not be opened"
95 }
96 }
97 }
98
99 pub fn orphaned_bundle(&self) -> Option<&OrphanedProjectBundle> {
100 match self {
101 Self::UserFallback {
102 orphaned_bundle, ..
103 } => Some(orphaned_bundle),
104 Self::Explicit(_) | Self::Saved(_) | Self::Project(_) | Self::UserDefault(_) => None,
105 }
106 }
107}
108
109pub fn resolve_data_dir(
122 env_override: Option<&str>,
123 saved: Option<&SelectedMemory>,
124 working_dir: &Path,
125 user_data_home: &Path,
126) -> ResolvedDataDir {
127 resolve_with_project_marker(
128 env_override,
129 saved,
130 working_dir,
131 user_data_home,
132 |candidate| candidate.join(".git").exists(),
133 )
134}
135
136fn resolve_with_project_marker(
137 env_override: Option<&str>,
138 saved: Option<&SelectedMemory>,
139 working_dir: &Path,
140 user_data_home: &Path,
141 is_project_root: impl Fn(&Path) -> bool,
142) -> ResolvedDataDir {
143 if let Some(explicit) = env_override
144 .map(str::trim)
145 .filter(|value| !value.is_empty())
146 {
147 let path = PathBuf::from(explicit);
148 return ResolvedDataDir::Explicit(if path.is_absolute() {
149 path
150 } else {
151 working_dir.join(path)
152 });
153 }
154
155 if let Some(saved) = saved {
156 return ResolvedDataDir::Saved(saved.path().to_path_buf());
157 }
158
159 let mut current = Some(working_dir);
160 while let Some(candidate) = current {
161 if is_project_root(candidate) {
162 return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
163 }
164 current = candidate.parent();
165 }
166
167 ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
168}
169
170pub fn user_data_home() -> Option<PathBuf> {
177 user_data_home_from(|name| std::env::var_os(name))
178}
179
180fn user_data_home_from(mut read: impl FnMut(&str) -> Option<OsString>) -> Option<PathBuf> {
181 let path = |value: Option<OsString>| {
182 value
183 .map(PathBuf::from)
184 .filter(|candidate| !candidate.as_os_str().is_empty())
185 };
186
187 path(read("XDG_DATA_HOME"))
188 .or_else(|| path(read("HOME")).map(|home| home.join(".local").join("share")))
189 .or_else(|| path(read("LOCALAPPDATA")))
190 .or_else(|| path(read("APPDATA")))
191 .or_else(|| path(read("USERPROFILE")).map(|home| home.join("AppData").join("Local")))
192}
193
194pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
200 match resolved {
201 ResolvedDataDir::Project(path) => path
202 .parent()
203 .map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
204 ResolvedDataDir::Explicit(_)
205 | ResolvedDataDir::Saved(_)
206 | ResolvedDataDir::UserDefault(_)
207 | ResolvedDataDir::UserFallback { .. } => None,
208 }
209}
210
211pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
215 let resolved = locate_data_dir_from_env()?;
216 prepare_data_dir(&resolved)?;
217 Ok(resolved)
218}
219
220pub fn locate_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
227 let env_override = std::env::var(DATA_DIR_ENV).ok();
228 let working_dir = std::env::current_dir().map_err(|error| {
229 PortError::Unavailable(format!(
230 "embedded kernel could not resolve the working directory: {error}"
231 ))
232 })?;
233 let user_data_home = user_data_home().ok_or_else(|| {
234 PortError::Unavailable(
235 "embedded kernel could not resolve a user data directory \
236 (none of XDG_DATA_HOME, HOME, LOCALAPPDATA, APPDATA, or USERPROFILE is set)"
237 .to_string(),
238 )
239 })?;
240 reject_unexpanded_home_override(env_override.as_deref())?;
241 let saved = if env_override
245 .as_deref()
246 .is_some_and(|value| !value.trim().is_empty())
247 {
248 None
249 } else {
250 memory_selection::saved_selection().map_err(|error| {
251 PortError::InvalidState(format!(
252 "the saved user memory selection is unusable: {error}"
253 ))
254 })?
255 };
256
257 let resolved = resolve_data_dir(
258 env_override.as_deref(),
259 saved.as_ref(),
260 &working_dir,
261 &user_data_home,
262 );
263 Ok(fallback_from_unopenable_project(resolved, &user_data_home))
264}
265
266fn fallback_from_unopenable_project(
267 resolved: ResolvedDataDir,
268 user_data_home: &Path,
269) -> ResolvedDataDir {
270 let ResolvedDataDir::Project(project_store_path) = resolved else {
271 return resolved;
272 };
273 let Some(project_root) = project_store_path.parent() else {
274 return ResolvedDataDir::Project(project_store_path);
275 };
276 let bundle_path = project_root.join(PROJECT_BUNDLE_PATH);
277 if !bundle_path.is_file() {
278 return ResolvedDataDir::Project(project_store_path);
279 }
280 let Err(error) = validate_store_layout(&project_store_path) else {
281 return ResolvedDataDir::Project(project_store_path);
282 };
283 let selected_store_path = user_data_home.join("kmp").join("default");
284 ResolvedDataDir::UserFallback {
285 path: selected_store_path.clone(),
286 orphaned_bundle: OrphanedProjectBundle {
287 bundle_path,
288 project_store_path,
289 selected_store_path,
290 reason: error.to_string(),
291 },
292 }
293}
294
295fn reject_unexpanded_home_override(env_override: Option<&str>) -> Result<(), PortError> {
296 let Some(explicit) = env_override
297 .map(str::trim)
298 .filter(|value| !value.is_empty())
299 else {
300 return Ok(());
301 };
302 let path = Path::new(explicit);
303 let starts_with_tilde = path
304 .components()
305 .next()
306 .is_some_and(|component| component.as_os_str() == "~");
307 if !starts_with_tilde {
308 return Ok(());
309 }
310
311 Err(PortError::InvalidState(
312 SelectionRefusal::unexpanded(format!("{DATA_DIR_ENV} value"), explicit).to_string(),
313 ))
314}
315
316fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
317 ensure_data_dir_skeleton(resolved.path())
318}
319
320pub fn ensure_data_dir_skeleton(path: &Path) -> Result<(), PortError> {
327 fs::create_dir_all(path).map_err(|error| {
328 PortError::Unavailable(format!(
329 "embedded kernel could not create data dir `{}`: {error}",
330 path.display()
331 ))
332 })?;
333
334 let gitignore = path.join(".gitignore");
335 if !gitignore.exists() {
336 fs::write(&gitignore, "*\n").map_err(|error| {
337 PortError::Unavailable(format!(
338 "embedded kernel could not write `{}`: {error}",
339 gitignore.display()
340 ))
341 })?;
342 }
343 let logs = path.join("logs");
344 fs::create_dir_all(&logs).map_err(|error| {
345 PortError::Unavailable(format!(
346 "embedded kernel could not create log dir `{}`: {error}",
347 logs.display()
348 ))
349 })?;
350 Ok(())
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn only_a_project_store_has_a_conventional_bundle_path() {
359 let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
360 assert_eq!(
361 project_bundle_path(&project),
362 Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
363 "the bundle sits beside the store's project root, not inside the store"
364 );
365
366 assert_eq!(
369 project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
370 None
371 );
372 assert_eq!(
373 project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
374 None
375 );
376 }
377
378 fn saved(path: &str) -> SelectedMemory {
379 SelectedMemory::parse(path).expect("an absolute test selection")
380 }
381
382 #[test]
383 fn env_override_wins_over_everything() {
384 let resolved = resolve_data_dir(
385 Some("/explicit/dir"),
386 Some(&saved("/saved/dir")),
387 Path::new("/some/project"),
388 Path::new("/home/u/.local/share"),
389 );
390 assert_eq!(
391 resolved,
392 ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
393 );
394 assert_eq!(resolved.rule_name(), "env");
395 assert!(resolved.rule_sentence().contains("KMP_MCP_DATA_DIR"));
396 }
397
398 #[test]
401 fn every_rule_wins_exactly_over_the_ones_below_it() {
402 let selection = saved("/saved/dir");
403 let in_a_project = |env, saved| {
404 resolve_with_project_marker(
405 env,
406 saved,
407 Path::new("/workspace/project"),
408 Path::new("/home/u/.local/share"),
409 |candidate| candidate == Path::new("/workspace/project"),
410 )
411 };
412
413 assert_eq!(
414 in_a_project(Some("/explicit/dir"), Some(&selection)),
415 ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
416 );
417 assert_eq!(
418 in_a_project(None, Some(&selection)),
419 ResolvedDataDir::Saved(PathBuf::from("/saved/dir")),
420 "a saved selection beats project discovery, which is the point of saving one"
421 );
422 assert_eq!(
423 in_a_project(None, None),
424 ResolvedDataDir::Project(PathBuf::from("/workspace/project/.kernel"))
425 );
426
427 let outside_a_project = |saved| {
430 resolve_with_project_marker(
431 None,
432 saved,
433 Path::new("/workspace/not-a-repository"),
434 Path::new("/home/u/.local/share"),
435 |_| false,
436 )
437 };
438 let chosen = outside_a_project(Some(&selection));
439 assert_eq!(chosen, ResolvedDataDir::Saved(PathBuf::from("/saved/dir")));
440 assert_eq!(chosen.rule_name(), "saved");
441 assert!(chosen.rule_sentence().contains("saved in the user config"));
442 assert_eq!(
443 outside_a_project(None),
444 ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
445 );
446 }
447
448 #[test]
449 fn a_relative_override_is_reported_as_the_path_that_will_actually_open() {
450 let resolved = resolve_data_dir(
451 Some("memory/kmp"),
452 None,
453 Path::new("/workspace/project"),
454 Path::new("/home/u/.local/share"),
455 );
456 assert_eq!(
457 resolved,
458 ResolvedDataDir::Explicit(PathBuf::from("/workspace/project/memory/kmp"))
459 );
460 }
461
462 #[test]
463 fn blank_env_override_is_ignored() {
464 let resolved = resolve_with_project_marker(
465 Some(" "),
466 None,
467 Path::new("/anywhere"),
468 Path::new("/data"),
469 |_| false,
470 );
471 assert_eq!(resolved.rule_name(), "user");
472
473 let with_selection = resolve_with_project_marker(
474 Some(" "),
475 Some(&saved("/saved/dir")),
476 Path::new("/anywhere"),
477 Path::new("/data"),
478 |_| false,
479 );
480 assert_eq!(with_selection.rule_name(), "saved");
481 }
482
483 #[test]
484 fn project_root_is_found_by_walking_up_to_git() {
485 let temp = tempfile::tempdir().expect("tempdir");
486 let nested = temp.path().join("workspace").join("src");
487 std::fs::create_dir_all(&nested).expect("nested dirs");
488 std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
489
490 let resolved = resolve_data_dir(None, None, &nested, Path::new("/data"));
491 assert_eq!(
492 resolved,
493 ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
494 );
495 }
496
497 #[test]
498 fn no_project_falls_back_to_user_data_dir() {
499 let resolved = resolve_with_project_marker(
500 None,
501 None,
502 Path::new("/anywhere/nested"),
503 Path::new("/home/u/.local/share"),
504 |_| false,
505 );
506 assert_eq!(
507 resolved,
508 ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
509 );
510 }
511
512 #[test]
516 fn a_saved_selection_has_no_conventional_bundle() {
517 let selected = ResolvedDataDir::Saved(PathBuf::from("/saved/dir"));
518 assert_eq!(project_bundle_path(&selected), None);
519 assert_eq!(selected.orphaned_bundle(), None);
520 }
521
522 #[test]
523 fn an_unopenable_project_store_with_a_bundle_selects_user_memory_and_keeps_the_loss() {
524 let project = tempfile::tempdir().expect("project");
525 let project_store = project.path().join(".kernel");
526 std::fs::create_dir_all(project_store.join("store")).expect("legacy store dir");
527 std::fs::write(project_store.join("FORMAT_VERSION"), "1\n").expect("legacy stamp");
528 std::fs::write(project_store.join("store/retired-layout.bin"), b"legacy")
529 .expect("legacy store");
530 let bundle = project.path().join(PROJECT_BUNDLE_PATH);
531 std::fs::create_dir_all(bundle.parent().expect("bundle parent")).expect("bundle dir");
532 std::fs::write(&bundle, "maintained memory\n").expect("bundle");
533
534 let selected = fallback_from_unopenable_project(
535 ResolvedDataDir::Project(project_store.clone()),
536 Path::new("/user-data"),
537 );
538
539 assert_eq!(selected.path(), Path::new("/user-data/kmp/default"));
540 assert_eq!(selected.rule_name(), "user fallback");
541 let orphaned = selected.orphaned_bundle().expect("orphaned bundle outcome");
542 assert_eq!(orphaned.bundle_path, bundle);
543 assert_eq!(orphaned.project_store_path, project_store);
544 assert_eq!(orphaned.selected_store_path, selected.path());
545 assert!(
546 orphaned.reason.contains("format version 1"),
547 "{}",
548 orphaned.reason
549 );
550 assert_eq!(project_bundle_path(&selected), None);
551 }
552
553 #[test]
554 fn an_unopenable_project_without_a_bundle_still_fails_closed_in_place() {
555 let project = tempfile::tempdir().expect("project");
556 let project_store = project.path().join(".kernel");
557 std::fs::create_dir_all(&project_store).expect("legacy store dir");
558 std::fs::write(project_store.join("FORMAT_VERSION"), "1\n").expect("legacy stamp");
559
560 let selected = fallback_from_unopenable_project(
561 ResolvedDataDir::Project(project_store.clone()),
562 Path::new("/user-data"),
563 );
564
565 assert_eq!(selected, ResolvedDataDir::Project(project_store));
566 assert!(selected.orphaned_bundle().is_none());
567 }
568
569 #[test]
570 fn user_data_home_keeps_unix_precedence_and_supports_native_windows() {
571 let unix = user_data_home_from(|name| match name {
572 "XDG_DATA_HOME" => Some(OsString::from("/xdg")),
573 "HOME" => Some(OsString::from("/home/user")),
574 "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
575 _ => None,
576 });
577 assert_eq!(unix, Some(PathBuf::from("/xdg")));
578
579 let windows = user_data_home_from(|name| match name {
580 "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
581 "APPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Roaming")),
582 _ => None,
583 });
584 assert_eq!(windows, Some(PathBuf::from(r"C:\Users\user\AppData\Local")));
585
586 let profile = user_data_home_from(|name| match name {
587 "USERPROFILE" => Some(OsString::from(r"C:\Users\user")),
588 _ => None,
589 });
590 assert_eq!(
591 profile,
592 Some(
593 PathBuf::from(r"C:\Users\user")
594 .join("AppData")
595 .join("Local")
596 )
597 );
598 }
599
600 #[test]
601 fn project_dir_preparation_writes_self_ignoring_gitignore() {
602 let temp = tempfile::tempdir().expect("tempdir");
603 let kernel_dir = temp.path().join(".kernel");
604 let resolved = ResolvedDataDir::Project(kernel_dir.clone());
605
606 prepare_data_dir(&resolved).expect("prepare");
607
608 let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
609 assert_eq!(gitignore, "*\n");
610 assert!(kernel_dir.join("logs").is_dir());
611 }
612
613 #[test]
614 fn explicit_dirs_get_the_same_non_destructive_skeleton() {
615 let temp = tempfile::tempdir().expect("tempdir");
616 let data_dir = temp.path().join("destination");
617
618 ensure_data_dir_skeleton(&data_dir).expect("prepare explicit destination");
619 assert_eq!(
620 std::fs::read_to_string(data_dir.join(".gitignore")).expect("gitignore"),
621 "*\n"
622 );
623 assert!(data_dir.join("logs").is_dir());
624
625 std::fs::write(data_dir.join(".gitignore"), "keep-me\n").expect("custom ignore");
626 ensure_data_dir_skeleton(&data_dir).expect("prepare again");
627 assert_eq!(
628 std::fs::read_to_string(data_dir.join(".gitignore")).expect("custom gitignore"),
629 "keep-me\n",
630 "the skeleton never overwrites an operator-owned ignore file"
631 );
632 }
633}