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        return ResolvedDataDir::Explicit(PathBuf::from(explicit));
77    }
78
79    let mut current = Some(working_dir);
80    while let Some(candidate) = current {
81        if is_project_root(candidate) {
82            return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
83        }
84        current = candidate.parent();
85    }
86
87    ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
88}
89
90/// Where user-scope memory lives: the Unix data home when available, then
91/// the native Windows local-data directories.
92///
93/// Exposed because it is also where anything that wants to enumerate the
94/// machine's memories has to look, and a second copy of this rule would be a
95/// second answer to the same question.
96pub fn user_data_home() -> Option<PathBuf> {
97    user_data_home_from(|name| std::env::var_os(name))
98}
99
100fn user_data_home_from(mut read: impl FnMut(&str) -> Option<OsString>) -> Option<PathBuf> {
101    let path = |value: Option<OsString>| {
102        value
103            .map(PathBuf::from)
104            .filter(|candidate| !candidate.as_os_str().is_empty())
105    };
106
107    path(read("XDG_DATA_HOME"))
108        .or_else(|| path(read("HOME")).map(|home| home.join(".local").join("share")))
109        .or_else(|| path(read("LOCALAPPDATA")))
110        .or_else(|| path(read("APPDATA")))
111        .or_else(|| path(read("USERPROFILE")).map(|home| home.join("AppData").join("Local")))
112}
113
114/// The conventional bundle path for the project `data_dir` belongs to.
115///
116/// Only a project-scoped store has one: an explicit `KMP_MCP_DATA_DIR` or the
117/// per-user default has no repository to be committed to, and guessing one
118/// would put memory somewhere the operator did not choose.
119pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
120    match resolved {
121        ResolvedDataDir::Project(path) => path
122            .parent()
123            .map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
124        ResolvedDataDir::Explicit(_) | ResolvedDataDir::UserDefault(_) => None,
125    }
126}
127
128/// Resolves from the process environment and prepares the directory. Every
129/// data directory gets the same safety skeleton, regardless of whether it was
130/// discovered from a project, supplied explicitly, or created by migration.
131pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
132    let resolved = locate_data_dir_from_env()?;
133    prepare_data_dir(&resolved)?;
134    Ok(resolved)
135}
136
137/// Resolves from the process environment and touches nothing.
138///
139/// Reporting where memory *would* live must not bring it into being:
140/// `kmp-mcp info` and `kmp-mcp doctor` run wherever a user happens to be
141/// standing, and a diagnostic that leaves a `.kernel/` behind in an unrelated
142/// repository has answered a question by changing the answer.
143pub fn locate_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
144    let env_override = std::env::var(DATA_DIR_ENV).ok();
145    let working_dir = std::env::current_dir().map_err(|error| {
146        PortError::Unavailable(format!(
147            "embedded kernel could not resolve the working directory: {error}"
148        ))
149    })?;
150    let user_data_home = user_data_home().ok_or_else(|| {
151        PortError::Unavailable(
152            "embedded kernel could not resolve a user data directory \
153             (none of XDG_DATA_HOME, HOME, LOCALAPPDATA, APPDATA, or USERPROFILE is set)"
154                .to_string(),
155        )
156    })?;
157
158    Ok(resolve_data_dir(
159        env_override.as_deref(),
160        &working_dir,
161        &user_data_home,
162    ))
163}
164
165fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
166    ensure_data_dir_skeleton(resolved.path())
167}
168
169/// Creates the non-store part of a KMP data directory.
170///
171/// Fresh startup, `migrate`, and `share-memory` all call this function. The
172/// self-ignore file is deliberately installed even for an explicit path: an
173/// operator can put such a path inside a repository, and the store must not
174/// start appearing in `git status` merely because it arrived through a
175/// migration rather than first startup. Existing files are never replaced.
176pub fn ensure_data_dir_skeleton(path: &Path) -> Result<(), PortError> {
177    fs::create_dir_all(path).map_err(|error| {
178        PortError::Unavailable(format!(
179            "embedded kernel could not create data dir `{}`: {error}",
180            path.display()
181        ))
182    })?;
183
184    let gitignore = path.join(".gitignore");
185    if !gitignore.exists() {
186        fs::write(&gitignore, "*\n").map_err(|error| {
187            PortError::Unavailable(format!(
188                "embedded kernel could not write `{}`: {error}",
189                gitignore.display()
190            ))
191        })?;
192    }
193    let logs = path.join("logs");
194    fs::create_dir_all(&logs).map_err(|error| {
195        PortError::Unavailable(format!(
196            "embedded kernel could not create log dir `{}`: {error}",
197            logs.display()
198        ))
199    })?;
200    Ok(())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn only_a_project_store_has_a_conventional_bundle_path() {
209        let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
210        assert_eq!(
211            project_bundle_path(&project),
212            Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
213            "the bundle sits beside the store's project root, not inside the store"
214        );
215
216        // Neither of these belongs to a repository, and picking one for them
217        // would write memory somewhere nobody chose.
218        assert_eq!(
219            project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
220            None
221        );
222        assert_eq!(
223            project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
224            None
225        );
226    }
227
228    #[test]
229    fn env_override_wins_over_everything() {
230        let resolved = resolve_data_dir(
231            Some("/explicit/dir"),
232            Path::new("/some/project"),
233            Path::new("/home/u/.local/share"),
234        );
235        assert_eq!(
236            resolved,
237            ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
238        );
239        assert_eq!(resolved.rule_name(), "env");
240    }
241
242    #[test]
243    fn blank_env_override_is_ignored() {
244        let resolved = resolve_with_project_marker(
245            Some("  "),
246            Path::new("/anywhere"),
247            Path::new("/data"),
248            |_| false,
249        );
250        assert_eq!(resolved.rule_name(), "user");
251    }
252
253    #[test]
254    fn project_root_is_found_by_walking_up_to_git() {
255        let temp = tempfile::tempdir().expect("tempdir");
256        let nested = temp.path().join("workspace").join("src");
257        std::fs::create_dir_all(&nested).expect("nested dirs");
258        std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
259
260        let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
261        assert_eq!(
262            resolved,
263            ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
264        );
265    }
266
267    #[test]
268    fn no_project_falls_back_to_user_data_dir() {
269        let resolved = resolve_with_project_marker(
270            None,
271            Path::new("/anywhere/nested"),
272            Path::new("/home/u/.local/share"),
273            |_| false,
274        );
275        assert_eq!(
276            resolved,
277            ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
278        );
279    }
280
281    #[test]
282    fn user_data_home_keeps_unix_precedence_and_supports_native_windows() {
283        let unix = user_data_home_from(|name| match name {
284            "XDG_DATA_HOME" => Some(OsString::from("/xdg")),
285            "HOME" => Some(OsString::from("/home/user")),
286            "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
287            _ => None,
288        });
289        assert_eq!(unix, Some(PathBuf::from("/xdg")));
290
291        let windows = user_data_home_from(|name| match name {
292            "LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
293            "APPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Roaming")),
294            _ => None,
295        });
296        assert_eq!(windows, Some(PathBuf::from(r"C:\Users\user\AppData\Local")));
297
298        let profile = user_data_home_from(|name| match name {
299            "USERPROFILE" => Some(OsString::from(r"C:\Users\user")),
300            _ => None,
301        });
302        assert_eq!(
303            profile,
304            Some(
305                PathBuf::from(r"C:\Users\user")
306                    .join("AppData")
307                    .join("Local")
308            )
309        );
310    }
311
312    #[test]
313    fn project_dir_preparation_writes_self_ignoring_gitignore() {
314        let temp = tempfile::tempdir().expect("tempdir");
315        let kernel_dir = temp.path().join(".kernel");
316        let resolved = ResolvedDataDir::Project(kernel_dir.clone());
317
318        prepare_data_dir(&resolved).expect("prepare");
319
320        let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
321        assert_eq!(gitignore, "*\n");
322        assert!(kernel_dir.join("logs").is_dir());
323    }
324
325    #[test]
326    fn explicit_and_migrated_dirs_get_the_same_non_destructive_skeleton() {
327        let temp = tempfile::tempdir().expect("tempdir");
328        let data_dir = temp.path().join("destination");
329
330        ensure_data_dir_skeleton(&data_dir).expect("prepare explicit destination");
331        assert_eq!(
332            std::fs::read_to_string(data_dir.join(".gitignore")).expect("gitignore"),
333            "*\n"
334        );
335        assert!(data_dir.join("logs").is_dir());
336
337        std::fs::write(data_dir.join(".gitignore"), "keep-me\n").expect("custom ignore");
338        ensure_data_dir_skeleton(&data_dir).expect("prepare again");
339        assert_eq!(
340            std::fs::read_to_string(data_dir.join(".gitignore")).expect("custom gitignore"),
341            "keep-me\n",
342            "the skeleton never overwrites an operator-owned ignore file"
343        );
344    }
345}