Skip to main content

kmp_embedded/
data_dir.rs

1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use kmp_domain::PortError;
6
7/// Explicit data directory override (ADR-012 rule 1).
8pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
9
10const PROJECT_DIR_NAME: &str = ".kernel";
11
12/// Where a project keeps the committed copy of its memory, relative to the
13/// project root.
14///
15/// The store itself (`.kernel/`) is machine state and is auto-gitignored. A
16/// bundle is the event log in one text file, which is a different thing: it
17/// belongs to the repository the same way a migration or a fixture does, so
18/// memory branches, reviews and reverts with the code that produced it.
19///
20/// The path is a convention rather than a setting so that `export` and
21/// `import` with no argument mean the same thing in every checkout, and so a
22/// reviewer knows where to look.
23pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
24
25/// Where the data directory came from — logged at startup so the winning
26/// resolution rule is always visible.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ResolvedDataDir {
29    /// `KMP_MCP_DATA_DIR` was set.
30    Explicit(PathBuf),
31    /// `<project-root>/.kernel/`, project root found by walking up to `.git`.
32    Project(PathBuf),
33    /// Per-user fallback under the platform data dir.
34    UserDefault(PathBuf),
35}
36
37impl ResolvedDataDir {
38    pub fn path(&self) -> &Path {
39        match self {
40            Self::Explicit(path) | Self::Project(path) | Self::UserDefault(path) => path,
41        }
42    }
43
44    pub fn rule_name(&self) -> &'static str {
45        match self {
46            Self::Explicit(_) => "env",
47            Self::Project(_) => "project",
48            Self::UserDefault(_) => "user",
49        }
50    }
51}
52
53/// ADR-012 resolution: env override > project `.kernel/` > per-user default.
54/// Pure function for testability; `resolve_data_dir_from_env` feeds it from
55/// the process environment.
56pub fn resolve_data_dir(
57    env_override: Option<&str>,
58    working_dir: &Path,
59    user_data_home: &Path,
60) -> ResolvedDataDir {
61    resolve_with_project_marker(env_override, working_dir, user_data_home, |candidate| {
62        candidate.join(".git").exists()
63    })
64}
65
66fn resolve_with_project_marker(
67    env_override: Option<&str>,
68    working_dir: &Path,
69    user_data_home: &Path,
70    is_project_root: impl Fn(&Path) -> bool,
71) -> ResolvedDataDir {
72    if let Some(explicit) = env_override
73        .map(str::trim)
74        .filter(|value| !value.is_empty())
75    {
76        let path = PathBuf::from(explicit);
77        return ResolvedDataDir::Explicit(if path.is_absolute() {
78            path
79        } else {
80            working_dir.join(path)
81        });
82    }
83
84    let mut current = Some(working_dir);
85    while let Some(candidate) = current {
86        if is_project_root(candidate) {
87            return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
88        }
89        current = candidate.parent();
90    }
91
92    ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
93}
94
95/// Where user-scope memory lives: the Unix data home when available, then
96/// the native Windows local-data directories.
97///
98/// Exposed because it is also where anything that wants to enumerate the
99/// machine's memories has to look, and a second copy of this rule would be a
100/// second answer to the same question.
101pub fn user_data_home() -> Option<PathBuf> {
102    user_data_home_from(|name| std::env::var_os(name))
103}
104
105fn user_data_home_from(mut read: impl FnMut(&str) -> Option<OsString>) -> Option<PathBuf> {
106    let path = |value: Option<OsString>| {
107        value
108            .map(PathBuf::from)
109            .filter(|candidate| !candidate.as_os_str().is_empty())
110    };
111
112    path(read("XDG_DATA_HOME"))
113        .or_else(|| path(read("HOME")).map(|home| home.join(".local").join("share")))
114        .or_else(|| path(read("LOCALAPPDATA")))
115        .or_else(|| path(read("APPDATA")))
116        .or_else(|| path(read("USERPROFILE")).map(|home| home.join("AppData").join("Local")))
117}
118
119/// The conventional bundle path for the project `data_dir` belongs to.
120///
121/// Only a project-scoped store has one: an explicit `KMP_MCP_DATA_DIR` or the
122/// per-user default has no repository to be committed to, and guessing one
123/// would put memory somewhere the operator did not choose.
124pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
125    match resolved {
126        ResolvedDataDir::Project(path) => path
127            .parent()
128            .map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
129        ResolvedDataDir::Explicit(_) | ResolvedDataDir::UserDefault(_) => None,
130    }
131}
132
133/// Resolves from the process environment and prepares the directory. Every
134/// data directory gets the same safety skeleton, regardless of whether it was
135/// discovered from a project, supplied explicitly, or created by migration.
136pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
137    let resolved = locate_data_dir_from_env()?;
138    prepare_data_dir(&resolved)?;
139    Ok(resolved)
140}
141
142/// Resolves from the process environment and touches nothing.
143///
144/// Reporting where memory *would* live must not bring it into being:
145/// `kmp-mcp info` and `kmp-mcp doctor` run wherever a user happens to be
146/// standing, and a diagnostic that leaves a `.kernel/` behind in an unrelated
147/// repository has answered a question by changing the answer.
148pub fn locate_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
149    let env_override = std::env::var(DATA_DIR_ENV).ok();
150    let working_dir = std::env::current_dir().map_err(|error| {
151        PortError::Unavailable(format!(
152            "embedded kernel could not resolve the working directory: {error}"
153        ))
154    })?;
155    let user_data_home = user_data_home().ok_or_else(|| {
156        PortError::Unavailable(
157            "embedded kernel could not resolve a user data directory \
158             (none of XDG_DATA_HOME, HOME, LOCALAPPDATA, APPDATA, or USERPROFILE is set)"
159                .to_string(),
160        )
161    })?;
162    reject_unexpanded_home_override(env_override.as_deref())?;
163
164    Ok(resolve_data_dir(
165        env_override.as_deref(),
166        &working_dir,
167        &user_data_home,
168    ))
169}
170
171fn reject_unexpanded_home_override(env_override: Option<&str>) -> Result<(), PortError> {
172    let Some(explicit) = env_override
173        .map(str::trim)
174        .filter(|value| !value.is_empty())
175    else {
176        return Ok(());
177    };
178    let path = Path::new(explicit);
179    let starts_with_tilde = path
180        .components()
181        .next()
182        .is_some_and(|component| component.as_os_str() == "~");
183    if !starts_with_tilde {
184        return Ok(());
185    }
186
187    let suggestion = user_home()
188        .and_then(|home| path.strip_prefix("~").ok().map(|suffix| home.join(suffix)))
189        .map(|path| format!("; use `{}`", path.display()))
190        .unwrap_or_else(|| "; use an absolute path instead".to_string());
191    Err(PortError::InvalidState(format!(
192        "{DATA_DIR_ENV} value `{explicit}` starts with `~`, but MCP host configuration does not \
193         expand shell paths{suggestion}"
194    )))
195}
196
197fn user_home() -> Option<PathBuf> {
198    ["HOME", "USERPROFILE"]
199        .into_iter()
200        .find_map(std::env::var_os)
201        .map(PathBuf::from)
202        .filter(|path| !path.as_os_str().is_empty())
203}
204
205fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
206    ensure_data_dir_skeleton(resolved.path())
207}
208
209/// Creates the non-store part of a KMP data directory.
210///
211/// Fresh startup and `migrate` both call this function. The
212/// self-ignore file is deliberately installed even for an explicit path: an
213/// operator can put such a path inside a repository, and the store must not
214/// start appearing in `git status` merely because it arrived through a
215/// migration rather than first startup. Existing files are never replaced.
216pub fn ensure_data_dir_skeleton(path: &Path) -> Result<(), PortError> {
217    fs::create_dir_all(path).map_err(|error| {
218        PortError::Unavailable(format!(
219            "embedded kernel could not create data dir `{}`: {error}",
220            path.display()
221        ))
222    })?;
223
224    let gitignore = path.join(".gitignore");
225    if !gitignore.exists() {
226        fs::write(&gitignore, "*\n").map_err(|error| {
227            PortError::Unavailable(format!(
228                "embedded kernel could not write `{}`: {error}",
229                gitignore.display()
230            ))
231        })?;
232    }
233    let logs = path.join("logs");
234    fs::create_dir_all(&logs).map_err(|error| {
235        PortError::Unavailable(format!(
236            "embedded kernel could not create log dir `{}`: {error}",
237            logs.display()
238        ))
239    })?;
240    Ok(())
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn only_a_project_store_has_a_conventional_bundle_path() {
249        let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
250        assert_eq!(
251            project_bundle_path(&project),
252            Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
253            "the bundle sits beside the store's project root, not inside the store"
254        );
255
256        // Neither of these belongs to a repository, and picking one for them
257        // would write memory somewhere nobody chose.
258        assert_eq!(
259            project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
260            None
261        );
262        assert_eq!(
263            project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
264            None
265        );
266    }
267
268    #[test]
269    fn env_override_wins_over_everything() {
270        let resolved = resolve_data_dir(
271            Some("/explicit/dir"),
272            Path::new("/some/project"),
273            Path::new("/home/u/.local/share"),
274        );
275        assert_eq!(
276            resolved,
277            ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
278        );
279        assert_eq!(resolved.rule_name(), "env");
280    }
281
282    #[test]
283    fn a_relative_override_is_reported_as_the_path_that_will_actually_open() {
284        let resolved = resolve_data_dir(
285            Some("memory/kmp"),
286            Path::new("/workspace/project"),
287            Path::new("/home/u/.local/share"),
288        );
289        assert_eq!(
290            resolved,
291            ResolvedDataDir::Explicit(PathBuf::from("/workspace/project/memory/kmp"))
292        );
293    }
294
295    #[test]
296    fn blank_env_override_is_ignored() {
297        let resolved = resolve_with_project_marker(
298            Some("  "),
299            Path::new("/anywhere"),
300            Path::new("/data"),
301            |_| false,
302        );
303        assert_eq!(resolved.rule_name(), "user");
304    }
305
306    #[test]
307    fn project_root_is_found_by_walking_up_to_git() {
308        let temp = tempfile::tempdir().expect("tempdir");
309        let nested = temp.path().join("workspace").join("src");
310        std::fs::create_dir_all(&nested).expect("nested dirs");
311        std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
312
313        let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
314        assert_eq!(
315            resolved,
316            ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
317        );
318    }
319
320    #[test]
321    fn no_project_falls_back_to_user_data_dir() {
322        let resolved = resolve_with_project_marker(
323            None,
324            Path::new("/anywhere/nested"),
325            Path::new("/home/u/.local/share"),
326            |_| false,
327        );
328        assert_eq!(
329            resolved,
330            ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
331        );
332    }
333
334    #[test]
335    fn user_data_home_keeps_unix_precedence_and_supports_native_windows() {
336        let unix = user_data_home_from(|name| match name {
337            "XDG_DATA_HOME" => Some(OsString::from("/xdg")),
338            "HOME" => Some(OsString::from("/home/user")),
339            "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
340            _ => None,
341        });
342        assert_eq!(unix, Some(PathBuf::from("/xdg")));
343
344        let windows = user_data_home_from(|name| match name {
345            "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
346            "APPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Roaming")),
347            _ => None,
348        });
349        assert_eq!(windows, Some(PathBuf::from(r"C:\Users\user\AppData\Local")));
350
351        let profile = user_data_home_from(|name| match name {
352            "USERPROFILE" => Some(OsString::from(r"C:\Users\user")),
353            _ => None,
354        });
355        assert_eq!(
356            profile,
357            Some(
358                PathBuf::from(r"C:\Users\user")
359                    .join("AppData")
360                    .join("Local")
361            )
362        );
363    }
364
365    #[test]
366    fn project_dir_preparation_writes_self_ignoring_gitignore() {
367        let temp = tempfile::tempdir().expect("tempdir");
368        let kernel_dir = temp.path().join(".kernel");
369        let resolved = ResolvedDataDir::Project(kernel_dir.clone());
370
371        prepare_data_dir(&resolved).expect("prepare");
372
373        let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
374        assert_eq!(gitignore, "*\n");
375        assert!(kernel_dir.join("logs").is_dir());
376    }
377
378    #[test]
379    fn explicit_and_migrated_dirs_get_the_same_non_destructive_skeleton() {
380        let temp = tempfile::tempdir().expect("tempdir");
381        let data_dir = temp.path().join("destination");
382
383        ensure_data_dir_skeleton(&data_dir).expect("prepare explicit destination");
384        assert_eq!(
385            std::fs::read_to_string(data_dir.join(".gitignore")).expect("gitignore"),
386            "*\n"
387        );
388        assert!(data_dir.join("logs").is_dir());
389
390        std::fs::write(data_dir.join(".gitignore"), "keep-me\n").expect("custom ignore");
391        ensure_data_dir_skeleton(&data_dir).expect("prepare again");
392        assert_eq!(
393            std::fs::read_to_string(data_dir.join(".gitignore")).expect("custom gitignore"),
394            "keep-me\n",
395            "the skeleton never overwrites an operator-owned ignore file"
396        );
397    }
398}