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
8/// Explicit data directory override (ADR-012 rule 1).
9pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
10
11const PROJECT_DIR_NAME: &str = ".kernel";
12
13/// Where a project keeps the committed copy of its memory, relative to the
14/// project root.
15///
16/// The store itself (`.kernel/`) is machine state and is auto-gitignored. A
17/// bundle is the event log in one text file, which is a different thing: it
18/// belongs to the repository the same way a migration or a fixture does, so
19/// memory branches, reviews and reverts with the code that produced it.
20///
21/// The path is a convention rather than a setting so that `export` and
22/// `import` with no argument mean the same thing in every checkout, and so a
23/// reviewer knows where to look.
24pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
25
26/// Where the data directory came from — logged at startup so the winning
27/// resolution rule is always visible.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ResolvedDataDir {
30    /// `KMP_MCP_DATA_DIR` was set.
31    Explicit(PathBuf),
32    /// `<project-root>/.kernel/`, project root found by walking up to `.git`.
33    Project(PathBuf),
34    /// Per-user fallback under the platform data dir.
35    UserDefault(PathBuf),
36    /// A project store was present but could not be opened, so the live
37    /// session selected the user store and must surface that the repository
38    /// bundle beside the rejected store is no longer maintained.
39    UserFallback {
40        path: PathBuf,
41        orphaned_bundle: OrphanedProjectBundle,
42    },
43}
44
45/// The durability contract lost when an unopenable project store falls back
46/// to the shared user store. Selection owns this fact because it is the only
47/// layer that knows both paths and the rejected layout reason.
48#[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
83/// ADR-012 resolution: env override > project `.kernel/` > per-user default.
84/// Pure function for testability; `resolve_data_dir_from_env` feeds it from
85/// the process environment.
86pub 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
125/// Where user-scope memory lives: the Unix data home when available, then
126/// the native Windows local-data directories.
127///
128/// Exposed because it is also where anything that wants to enumerate the
129/// machine's memories has to look, and a second copy of this rule would be a
130/// second answer to the same question.
131pub 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
149/// The conventional bundle path for the project `data_dir` belongs to.
150///
151/// Only a project-scoped store has one: an explicit `KMP_MCP_DATA_DIR` or the
152/// per-user default has no repository to be committed to, and guessing one
153/// would put memory somewhere the operator did not choose.
154pub 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
165/// Resolves from the process environment and prepares the directory. Every
166/// data directory gets the same safety skeleton, regardless of whether it was
167/// discovered from a project, supplied explicitly, or created by migration.
168pub 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
174/// Resolves from the process environment and touches nothing.
175///
176/// Reporting where memory *would* live must not bring it into being:
177/// `kmp-mcp info` and `kmp-mcp doctor` run wherever a user happens to be
178/// standing, and a diagnostic that leaves a `.kernel/` behind in an unrelated
179/// repository has answered a question by changing the answer.
180pub 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
267/// Creates the non-store part of a KMP data directory.
268///
269/// Fresh startup calls this function. The self-ignore file is deliberately
270/// installed even for an explicit path: an
271/// operator can put such a path inside a repository, and the store must not
272/// start appearing in `git status`. Existing files are never replaced.
273pub 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        // Neither of these belongs to a repository, and picking one for them
314        // would write memory somewhere nobody chose.
315        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}