1use std::ffi::OsString;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use kmp_domain::PortError;
6
7pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
9
10const PROJECT_DIR_NAME: &str = ".kernel";
11
12pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ResolvedDataDir {
29 Explicit(PathBuf),
31 Project(PathBuf),
33 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
53pub 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
90pub 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
114pub 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
128pub 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
137pub 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
169pub 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 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}