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 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
95pub 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
119pub 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
133pub 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
142pub 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
209pub 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 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}