Skip to main content

kmp_embedded/
data_dir.rs

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