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 the data directory came from — logged at startup so the winning
12/// resolution rule is always visible.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ResolvedDataDir {
15    /// `KMP_MCP_DATA_DIR` was set.
16    Explicit(PathBuf),
17    /// `<project-root>/.kernel/`, project root found by walking up to `.git`.
18    Project(PathBuf),
19    /// Per-user fallback under the platform data dir.
20    UserDefault(PathBuf),
21}
22
23impl ResolvedDataDir {
24    pub fn path(&self) -> &Path {
25        match self {
26            Self::Explicit(path) | Self::Project(path) | Self::UserDefault(path) => path,
27        }
28    }
29
30    pub fn rule_name(&self) -> &'static str {
31        match self {
32            Self::Explicit(_) => "env",
33            Self::Project(_) => "project",
34            Self::UserDefault(_) => "user",
35        }
36    }
37}
38
39/// ADR-012 resolution: env override > project `.kernel/` > per-user default.
40/// Pure function for testability; `resolve_data_dir_from_env` feeds it from
41/// the process environment.
42pub fn resolve_data_dir(
43    env_override: Option<&str>,
44    working_dir: &Path,
45    user_data_home: &Path,
46) -> ResolvedDataDir {
47    resolve_with_project_marker(env_override, working_dir, user_data_home, |candidate| {
48        candidate.join(".git").exists()
49    })
50}
51
52fn resolve_with_project_marker(
53    env_override: Option<&str>,
54    working_dir: &Path,
55    user_data_home: &Path,
56    is_project_root: impl Fn(&Path) -> bool,
57) -> ResolvedDataDir {
58    if let Some(explicit) = env_override
59        .map(str::trim)
60        .filter(|value| !value.is_empty())
61    {
62        return ResolvedDataDir::Explicit(PathBuf::from(explicit));
63    }
64
65    let mut current = Some(working_dir);
66    while let Some(candidate) = current {
67        if is_project_root(candidate) {
68            return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
69        }
70        current = candidate.parent();
71    }
72
73    ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
74}
75
76/// Resolves from the process environment and prepares the directory: creates
77/// it and, for project-scoped dirs, drops a self-ignoring `.gitignore` so
78/// local memory never enters version control by accident.
79pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
80    let env_override = std::env::var(DATA_DIR_ENV).ok();
81    let working_dir = std::env::current_dir().map_err(|error| {
82        PortError::Unavailable(format!(
83            "embedded kernel could not resolve the working directory: {error}"
84        ))
85    })?;
86    let user_data_home = std::env::var("XDG_DATA_HOME")
87        .map(PathBuf::from)
88        .ok()
89        .filter(|path| !path.as_os_str().is_empty())
90        .or_else(|| {
91            std::env::var("HOME")
92                .ok()
93                .map(|home| PathBuf::from(home).join(".local").join("share"))
94        })
95        .ok_or_else(|| {
96            PortError::Unavailable(
97                "embedded kernel could not resolve a user data directory \
98                 (neither XDG_DATA_HOME nor HOME is set)"
99                    .to_string(),
100            )
101        })?;
102
103    let resolved = resolve_data_dir(env_override.as_deref(), &working_dir, &user_data_home);
104    prepare_data_dir(&resolved)?;
105    Ok(resolved)
106}
107
108fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
109    fs::create_dir_all(resolved.path()).map_err(|error| {
110        PortError::Unavailable(format!(
111            "embedded kernel could not create data dir `{}`: {error}",
112            resolved.path().display()
113        ))
114    })?;
115    if let ResolvedDataDir::Project(path) = resolved {
116        let gitignore = path.join(".gitignore");
117        if !gitignore.exists() {
118            fs::write(&gitignore, "*\n").map_err(|error| {
119                PortError::Unavailable(format!(
120                    "embedded kernel could not write `{}`: {error}",
121                    gitignore.display()
122                ))
123            })?;
124        }
125    }
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn env_override_wins_over_everything() {
135        let resolved = resolve_data_dir(
136            Some("/explicit/dir"),
137            Path::new("/some/project"),
138            Path::new("/home/u/.local/share"),
139        );
140        assert_eq!(
141            resolved,
142            ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
143        );
144        assert_eq!(resolved.rule_name(), "env");
145    }
146
147    #[test]
148    fn blank_env_override_is_ignored() {
149        let resolved = resolve_with_project_marker(
150            Some("  "),
151            Path::new("/anywhere"),
152            Path::new("/data"),
153            |_| false,
154        );
155        assert_eq!(resolved.rule_name(), "user");
156    }
157
158    #[test]
159    fn project_root_is_found_by_walking_up_to_git() {
160        let temp = tempfile::tempdir().expect("tempdir");
161        let nested = temp.path().join("workspace").join("src");
162        std::fs::create_dir_all(&nested).expect("nested dirs");
163        std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
164
165        let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
166        assert_eq!(
167            resolved,
168            ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
169        );
170    }
171
172    #[test]
173    fn no_project_falls_back_to_user_data_dir() {
174        let resolved = resolve_with_project_marker(
175            None,
176            Path::new("/anywhere/nested"),
177            Path::new("/home/u/.local/share"),
178            |_| false,
179        );
180        assert_eq!(
181            resolved,
182            ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
183        );
184    }
185
186    #[test]
187    fn project_dir_preparation_writes_self_ignoring_gitignore() {
188        let temp = tempfile::tempdir().expect("tempdir");
189        let kernel_dir = temp.path().join(".kernel");
190        let resolved = ResolvedDataDir::Project(kernel_dir.clone());
191
192        prepare_data_dir(&resolved).expect("prepare");
193
194        let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
195        assert_eq!(gitignore, "*\n");
196    }
197}