1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kmp_domain::PortError;
5
6pub const DATA_DIR_ENV: &str = "KMP_MCP_DATA_DIR";
8
9const PROJECT_DIR_NAME: &str = ".kernel";
10
11pub const PROJECT_BUNDLE_PATH: &str = ".kmp/memory.jsonl";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ResolvedDataDir {
28 Explicit(PathBuf),
30 Project(PathBuf),
32 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
52pub 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
89pub fn project_bundle_path(resolved: &ResolvedDataDir) -> Option<PathBuf> {
95 match resolved {
96 ResolvedDataDir::Project(path) => path
97 .parent()
98 .map(|project_root| project_root.join(PROJECT_BUNDLE_PATH)),
99 ResolvedDataDir::Explicit(_) | ResolvedDataDir::UserDefault(_) => None,
100 }
101}
102
103pub fn resolve_data_dir_from_env() -> Result<ResolvedDataDir, PortError> {
107 let env_override = std::env::var(DATA_DIR_ENV).ok();
108 let working_dir = std::env::current_dir().map_err(|error| {
109 PortError::Unavailable(format!(
110 "embedded kernel could not resolve the working directory: {error}"
111 ))
112 })?;
113 let user_data_home = std::env::var("XDG_DATA_HOME")
114 .map(PathBuf::from)
115 .ok()
116 .filter(|path| !path.as_os_str().is_empty())
117 .or_else(|| {
118 std::env::var("HOME")
119 .ok()
120 .map(|home| PathBuf::from(home).join(".local").join("share"))
121 })
122 .ok_or_else(|| {
123 PortError::Unavailable(
124 "embedded kernel could not resolve a user data directory \
125 (neither XDG_DATA_HOME nor HOME is set)"
126 .to_string(),
127 )
128 })?;
129
130 let resolved = resolve_data_dir(env_override.as_deref(), &working_dir, &user_data_home);
131 prepare_data_dir(&resolved)?;
132 Ok(resolved)
133}
134
135fn prepare_data_dir(resolved: &ResolvedDataDir) -> Result<(), PortError> {
136 fs::create_dir_all(resolved.path()).map_err(|error| {
137 PortError::Unavailable(format!(
138 "embedded kernel could not create data dir `{}`: {error}",
139 resolved.path().display()
140 ))
141 })?;
142 if let ResolvedDataDir::Project(path) = resolved {
143 let gitignore = path.join(".gitignore");
144 if !gitignore.exists() {
145 fs::write(&gitignore, "*\n").map_err(|error| {
146 PortError::Unavailable(format!(
147 "embedded kernel could not write `{}`: {error}",
148 gitignore.display()
149 ))
150 })?;
151 }
152 }
153 Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn only_a_project_store_has_a_conventional_bundle_path() {
162 let project = ResolvedDataDir::Project(PathBuf::from("/repo/.kernel"));
163 assert_eq!(
164 project_bundle_path(&project),
165 Some(PathBuf::from("/repo/.kmp/memory.jsonl")),
166 "the bundle sits beside the store's project root, not inside the store"
167 );
168
169 assert_eq!(
172 project_bundle_path(&ResolvedDataDir::Explicit(PathBuf::from("/tmp/dir"))),
173 None
174 );
175 assert_eq!(
176 project_bundle_path(&ResolvedDataDir::UserDefault(PathBuf::from("/home/u/kmp"))),
177 None
178 );
179 }
180
181 #[test]
182 fn env_override_wins_over_everything() {
183 let resolved = resolve_data_dir(
184 Some("/explicit/dir"),
185 Path::new("/some/project"),
186 Path::new("/home/u/.local/share"),
187 );
188 assert_eq!(
189 resolved,
190 ResolvedDataDir::Explicit(PathBuf::from("/explicit/dir"))
191 );
192 assert_eq!(resolved.rule_name(), "env");
193 }
194
195 #[test]
196 fn blank_env_override_is_ignored() {
197 let resolved = resolve_with_project_marker(
198 Some(" "),
199 Path::new("/anywhere"),
200 Path::new("/data"),
201 |_| false,
202 );
203 assert_eq!(resolved.rule_name(), "user");
204 }
205
206 #[test]
207 fn project_root_is_found_by_walking_up_to_git() {
208 let temp = tempfile::tempdir().expect("tempdir");
209 let nested = temp.path().join("workspace").join("src");
210 std::fs::create_dir_all(&nested).expect("nested dirs");
211 std::fs::create_dir_all(temp.path().join("workspace").join(".git")).expect("git dir");
212
213 let resolved = resolve_data_dir(None, &nested, Path::new("/data"));
214 assert_eq!(
215 resolved,
216 ResolvedDataDir::Project(temp.path().join("workspace").join(".kernel"))
217 );
218 }
219
220 #[test]
221 fn no_project_falls_back_to_user_data_dir() {
222 let resolved = resolve_with_project_marker(
223 None,
224 Path::new("/anywhere/nested"),
225 Path::new("/home/u/.local/share"),
226 |_| false,
227 );
228 assert_eq!(
229 resolved,
230 ResolvedDataDir::UserDefault(PathBuf::from("/home/u/.local/share/kmp/default"))
231 );
232 }
233
234 #[test]
235 fn project_dir_preparation_writes_self_ignoring_gitignore() {
236 let temp = tempfile::tempdir().expect("tempdir");
237 let kernel_dir = temp.path().join(".kernel");
238 let resolved = ResolvedDataDir::Project(kernel_dir.clone());
239
240 prepare_data_dir(&resolved).expect("prepare");
241
242 let gitignore = std::fs::read_to_string(kernel_dir.join(".gitignore")).expect("gitignore");
243 assert_eq!(gitignore, "*\n");
244 }
245}