use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use kmp_adapter_embedded::validate_store_layout;
use kmp_domain::PortError;
pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
const PROJECT_DIR_NAME: &str = ".kernel";
pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedDataDir {
Explicit(PathBuf),
Project(PathBuf),
UserDefault(PathBuf),
UserFallback {
path: PathBuf,
orphaned_bundle: OrphanedProjectBundle,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrphanedProjectBundle {
pub bundle_path: PathBuf,
pub project_store_path: PathBuf,
pub selected_store_path: PathBuf,
pub reason: String,
}
impl ResolvedDataDir {
pub fn path(&self) -> &Path {
match self {
Self::Explicit(path) | Self::Project(path) | Self::UserDefault(path) => path,
Self::UserFallback { path, .. } => path,
}
}
pub fn rule_name(&self) -> &'static str {
match self {
Self::Explicit(_) => "env",
Self::Project(_) => "project",
Self::UserDefault(_) => "user",
Self::UserFallback { .. } => "user fallback",
}
}
pub fn orphaned_bundle(&self) -> Option<&OrphanedProjectBundle> {
match self {
Self::UserFallback {
orphaned_bundle, ..
} => Some(orphaned_bundle),
Self::Explicit(_) | Self::Project(_) | Self::UserDefault(_) => None,
}
}
}
pub fn resolve_data_dir(
env_override: Option<&str>,
working_dir: &Path,
user_data_home: &Path,
) -> ResolvedDataDir {
resolve_with_project_marker(env_override, working_dir, user_data_home, |candidate| {
candidate.join(".git").exists()
})
}
fn resolve_with_project_marker(
env_override: Option<&str>,
working_dir: &Path,
user_data_home: &Path,
is_project_root: impl Fn(&Path) -> bool,
) -> ResolvedDataDir {
if let Some(explicit) = env_override
.map(str::trim)
.filter(|value| !value.is_empty())
{
let path = PathBuf::from(explicit);
return ResolvedDataDir::Explicit(if path.is_absolute() {
path
} else {
working_dir.join(path)
});
}
let mut current = Some(working_dir);
while let Some(candidate) = current {
if is_project_root(candidate) {
return ResolvedDataDir::Project(candidate.join(PROJECT_DIR_NAME));
}
current = candidate.parent();
}
ResolvedDataDir::UserDefault(user_data_home.join("kmp").join("default"))
}
pub fn user_data_home() -> Option<PathBuf> {
user_data_home_from(|name| std::env::var_os(name))
}
fn user_data_home_from(mut read: impl FnMut(&str) -> Option<OsString>) -> Option<PathBuf> {
let path = |value: Option<OsString>| {
value
.map(PathBuf::from)
.filter(|candidate| !candidate.as_os_str().is_empty())
};
path(read("XDG_DATA_HOME"))
.or_else(|| path(read("HOME")).map(|home| home.join(".local").join("share")))
.or_else(|| path(read("LOCALAPPDATA")))
.or_else(|| path(read("APPDATA")))
.or_else(|| path(read("USERPROFILE")).map(|home| home.join("AppData").join("Local")))
}
pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
match resolved {
ResolvedDataDir::Project(path) => path
.parent()
.map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
ResolvedDataDir::Explicit(_)
| ResolvedDataDir::UserDefault(_)
| ResolvedDataDir::UserFallback { .. } => None,
}
}
pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
let resolved = locate_data_dir_from_env()?;
prepare_data_dir(&resolved)?;
Ok(resolved)
}
pub fn locate_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
let env_override = std::env::var(DATA_DIR_ENV).ok();
let working_dir = std::env::current_dir().map_err(|error| {
PortError::Unavailable(format!(
"embedded kernel could not resolve the working directory: {error}"
))
})?;
let user_data_home = user_data_home().ok_or_else(|| {
PortError::Unavailable(
"embedded kernel could not resolve a user data directory \
(none of XDG_DATA_HOME, HOME, LOCALAPPDATA, APPDATA, or USERPROFILE is set)"
.to_string(),
)
})?;
reject_unexpanded_home_override(env_override.as_deref())?;
let resolved = resolve_data_dir(env_override.as_deref(), &working_dir, &user_data_home);
Ok(fallback_from_unopenable_project(resolved, &user_data_home))
}
fn fallback_from_unopenable_project(
resolved: ResolvedDataDir,
user_data_home: &Path,
) -> ResolvedDataDir {
let ResolvedDataDir::Project(project_store_path) = resolved else {
return resolved;
};
let Some(project_root) = project_store_path.parent() else {
return ResolvedDataDir::Project(project_store_path);
};
let bundle_path = project_root.join(PROJECT_BUNDLE_PATH);
if !bundle_path.is_file() {
return ResolvedDataDir::Project(project_store_path);
}
let Err(error) = validate_store_layout(&project_store_path) else {
return ResolvedDataDir::Project(project_store_path);
};
let selected_store_path = user_data_home.join("kmp").join("default");
ResolvedDataDir::UserFallback {
path: selected_store_path.clone(),
orphaned_bundle: OrphanedProjectBundle {
bundle_path,
project_store_path,
selected_store_path,
reason: error.to_string(),
},
}
}
fn reject_unexpanded_home_override(env_override: Option<&str>) -> Result<(), PortError> {
let Some(explicit) = env_override
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(());
};
let path = Path::new(explicit);
let starts_with_tilde = path
.components()
.next()
.is_some_and(|component| component.as_os_str() == "~");
if !starts_with_tilde {
return Ok(());
}
let suggestion = user_home()
.and_then(|home| path.strip_prefix("~").ok().map(|suffix| home.join(suffix)))
.map(|path| format!("; use `{}`", path.display()))
.unwrap_or_else(|| "; use an absolute path instead".to_string());
Err(PortError::InvalidState(format!(
"{DATA_DIR_ENV} value `{explicit}` starts with `~`, but MCP host configuration does not \
expand shell paths{suggestion}"
)))
}
fn user_home() -> Option<PathBuf> {
["HOME", "USERPROFILE"]
.into_iter()
.find_map(std::env::var_os)
.map(PathBuf::from)
.filter(|path| !path.as_os_str().is_empty())
}
fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
ensure_data_dir_skeleton(resolved.path())
}
pub fn ensure_data_dir_skeleton(path: &Path) -> Result<(), PortError> {
fs::create_dir_all(path).map_err(|error| {
PortError::Unavailable(format!(
"embedded kernel could not create data dir `{}`: {error}",
path.display()
))
})?;
let gitignore = path.join(".gitignore");
if !gitignore.exists() {
fs::write(&gitignore, "*\n").map_err(|error| {
PortError::Unavailable(format!(
"embedded kernel could not write `{}`: {error}",
gitignore.display()
))
})?;
}
let logs = path.join("logs");
fs::create_dir_all(&logs).map_err(|error| {
PortError::Unavailable(format!(
"embedded kernel could not create log dir `{}`: {error}",
logs.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_a_project_store_has_a_conventional_bundle_path() {
let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
assert_eq!(
project_bundle_path(&project),
Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
"the bundle sits beside the store's project root, not inside the store"
);
assert_eq!(
project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
None
);
assert_eq!(
project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
None
);
}
#[test]
fn env_override_wins_over_everything() {
let resolved = resolve_data_dir(
Some("/explicit/dir"),
Path::new("/some/project"),
Path::new("/home/u/.local/share"),
);
assert_eq!(
resolved,
ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
);
assert_eq!(resolved.rule_name(), "env");
}
#[test]
fn a_relative_override_is_reported_as_the_path_that_will_actually_open() {
let resolved = resolve_data_dir(
Some("memory/kmp"),
Path::new("/workspace/project"),
Path::new("/home/u/.local/share"),
);
assert_eq!(
resolved,
ResolvedDataDir::Explicit(PathBuf::from("/workspace/project/memory/kmp"))
);
}
#[test]
fn blank_env_override_is_ignored() {
let resolved = resolve_with_project_marker(
Some(" "),
Path::new("/anywhere"),
Path::new("/data"),
|_| false,
);
assert_eq!(resolved.rule_name(), "user");
}
#[test]
fn project_root_is_found_by_walking_up_to_git() {
let temp = tempfile::tempdir().expect("tempdir");
let nested = temp.path().join("workspace").join("src");
std::fs::create_dir_all(&nested).expect("nested dirs");
std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
assert_eq!(
resolved,
ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
);
}
#[test]
fn no_project_falls_back_to_user_data_dir() {
let resolved = resolve_with_project_marker(
None,
Path::new("/anywhere/nested"),
Path::new("/home/u/.local/share"),
|_| false,
);
assert_eq!(
resolved,
ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
);
}
#[test]
fn an_unopenable_project_store_with_a_bundle_selects_user_memory_and_keeps_the_loss() {
let project = tempfile::tempdir().expect("project");
let project_store = project.path().join(".kernel");
std::fs::create_dir_all(project_store.join("store")).expect("legacy store dir");
std::fs::write(project_store.join("FORMAT_VERSION"), "1\n").expect("legacy stamp");
std::fs::write(project_store.join("store/retired-layout.bin"), b"legacy")
.expect("legacy store");
let bundle = project.path().join(PROJECT_BUNDLE_PATH);
std::fs::create_dir_all(bundle.parent().expect("bundle parent")).expect("bundle dir");
std::fs::write(&bundle, "maintained memory\n").expect("bundle");
let selected = fallback_from_unopenable_project(
ResolvedDataDir::Project(project_store.clone()),
Path::new("/user-data"),
);
assert_eq!(selected.path(), Path::new("/user-data/kmp/default"));
assert_eq!(selected.rule_name(), "user fallback");
let orphaned = selected.orphaned_bundle().expect("orphaned bundle outcome");
assert_eq!(orphaned.bundle_path, bundle);
assert_eq!(orphaned.project_store_path, project_store);
assert_eq!(orphaned.selected_store_path, selected.path());
assert!(
orphaned.reason.contains("format version 1"),
"{}",
orphaned.reason
);
assert_eq!(project_bundle_path(&selected), None);
}
#[test]
fn an_unopenable_project_without_a_bundle_still_fails_closed_in_place() {
let project = tempfile::tempdir().expect("project");
let project_store = project.path().join(".kernel");
std::fs::create_dir_all(&project_store).expect("legacy store dir");
std::fs::write(project_store.join("FORMAT_VERSION"), "1\n").expect("legacy stamp");
let selected = fallback_from_unopenable_project(
ResolvedDataDir::Project(project_store.clone()),
Path::new("/user-data"),
);
assert_eq!(selected, ResolvedDataDir::Project(project_store));
assert!(selected.orphaned_bundle().is_none());
}
#[test]
fn user_data_home_keeps_unix_precedence_and_supports_native_windows() {
let unix = user_data_home_from(|name| match name {
"XDG_DATA_HOME" => Some(OsString::from("/xdg")),
"HOME" => Some(OsString::from("/home/user")),
"LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
_ => None,
});
assert_eq!(unix, Some(PathBuf::from("/xdg")));
let windows = user_data_home_from(|name| match name {
"LOCALAPPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Local")),
"APPDATA" => Some(OsString::from(r"C:\Users\user\AppData\Roaming")),
_ => None,
});
assert_eq!(windows, Some(PathBuf::from(r"C:\Users\user\AppData\Local")));
let profile = user_data_home_from(|name| match name {
"USERPROFILE" => Some(OsString::from(r"C:\Users\user")),
_ => None,
});
assert_eq!(
profile,
Some(
PathBuf::from(r"C:\Users\user")
.join("AppData")
.join("Local")
)
);
}
#[test]
fn project_dir_preparation_writes_self_ignoring_gitignore() {
let temp = tempfile::tempdir().expect("tempdir");
let kernel_dir = temp.path().join(".kernel");
let resolved = ResolvedDataDir::Project(kernel_dir.clone());
prepare_data_dir(&resolved).expect("prepare");
let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
assert_eq!(gitignore, "*\n");
assert!(kernel_dir.join("logs").is_dir());
}
#[test]
fn explicit_dirs_get_the_same_non_destructive_skeleton() {
let temp = tempfile::tempdir().expect("tempdir");
let data_dir = temp.path().join("destination");
ensure_data_dir_skeleton(&data_dir).expect("prepare explicit destination");
assert_eq!(
std::fs::read_to_string(data_dir.join(".gitignore")).expect("gitignore"),
"*\n"
);
assert!(data_dir.join("logs").is_dir());
std::fs::write(data_dir.join(".gitignore"), "keep-me\n").expect("custom ignore");
ensure_data_dir_skeleton(&data_dir).expect("prepare again");
assert_eq!(
std::fs::read_to_string(data_dir.join(".gitignore")).expect("custom gitignore"),
"keep-me\n",
"the skeleton never overwrites an operator-owned ignore file"
);
}
}