Skip to main content

kmp_embedded/
data_dir.rs

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
11/// Explicit data directory override (ADR-012 rule 1).
12pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
13
14const PROJECT_DIR_NAME: &str = ".kernel";
15
16/// Where a project keeps the committed copy of its memory, relative to the
17/// project root.
18///
19/// The store itself (`.kernel/`) is machine state and is auto-gitignored. A
20/// bundle is the event log in one text file, which is a different thing: it
21/// belongs to the repository the same way a migration or a fixture does, so
22/// memory branches, reviews and reverts with the code that produced it.
23///
24/// The path is a convention rather than a setting so that `export` and
25/// `import` with no argument mean the same thing in every checkout, and so a
26/// reviewer knows where to look.
27pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
28
29/// Where the data directory came from — logged at startup so the winning
30/// resolution rule is always visible.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ResolvedDataDir {
33    /// `KMP_MCP_DATA_DIR` was set.
34    Explicit(PathBuf),
35    /// The operator saved this selection in the user config file.
36    Saved(PathBuf),
37    /// `<project-root>/.kernel/`, project root found by walking up to `.git`.
38    Project(PathBuf),
39    /// Per-user fallback under the platform data dir.
40    UserDefault(PathBuf),
41    /// A project store was present but could not be opened, so the live
42    /// session selected the user store and must surface that the repository
43    /// bundle beside the rejected store is no longer maintained.
44    UserFallback {
45        path: PathBuf,
46        orphaned_bundle: OrphanedProjectBundle,
47    },
48}
49
50/// The durability contract lost when an unopenable project store falls back
51/// to the shared user store. Selection owns this fact because it is the only
52/// layer that knows both paths and the rejected layout reason.
53#[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    /// The rule in the words a person reads, naming what actually decided.
83    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
109/// Resolution order: the environment override, then the saved selection,
110/// then the project `.kernel/`, then the per-user default.
111///
112/// `KMP_MCP_DATA_DIR` stays first because it is the most explicit and most
113/// local thing anyone can say, and every test, baseline and reproduction
114/// script depends on that override meaning exactly one process. A saved
115/// selection then beats automatic discovery, which is the whole point of
116/// saving one: a workspace with no project marker must reach the memory the
117/// operator chose, not whatever the per-user default happens to hold.
118///
119/// Pure function for testability; `resolve_data_dir_from_env` feeds it from
120/// the process environment and the user config file.
121pub 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
170/// Where user-scope memory lives: the Unix data home when available, then
171/// the native Windows local-data directories.
172///
173/// Exposed because it is also where anything that wants to enumerate the
174/// machine's memories has to look, and a second copy of this rule would be a
175/// second answer to the same question.
176pub 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
194/// The conventional bundle path for the project `data_dir` belongs to.
195///
196/// Only a project-scoped store has one: an explicit `KMP_MCP_DATA_DIR` or the
197/// per-user default has no repository to be committed to, and guessing one
198/// would put memory somewhere the operator did not choose.
199pub 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
211/// Resolves from the process environment and prepares the directory. Every
212/// data directory gets the same safety skeleton, regardless of whether it was
213/// discovered from a project, supplied explicitly, or created by migration.
214pub 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
220/// Resolves from the process environment and touches nothing.
221///
222/// Reporting where memory *would* live must not bring it into being:
223/// `kmp-mcp info` and `kmp-mcp doctor` run wherever a user happens to be
224/// standing, and a diagnostic that leaves a `.kernel/` behind in an unrelated
225/// repository has answered a question by changing the answer.
226pub 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    // An explicit override wins without depending on a lower-priority
242    // setting being readable. Without it, refuse a broken saved selection
243    // rather than silently falling through to automatic discovery.
244    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
320/// Creates the non-store part of a KMP data directory.
321///
322/// Fresh startup calls this function. The self-ignore file is deliberately
323/// installed even for an explicit path: an
324/// operator can put such a path inside a repository, and the store must not
325/// start appearing in `git status`. Existing files are never replaced.
326pub 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        // Neither of these belongs to a repository, and picking one for them
367        // would write memory somewhere nobody chose.
368        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    /// The whole precedence, one rule removed at a time: environment, then
399    /// the saved selection, then the project, then the per-user default.
400    #[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        // The reproduction in #680: a workspace with no project marker and a
428        // saved selection must reach the chosen memory, not the old default.
429        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    /// A saved selection is not a project store: it has no repository to be
513    /// committed to, so guessing a bundle path for it would put memory
514    /// somewhere nobody chose.
515    #[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}