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
11#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ResolvedDataDir {
15 Explicit(PathBuf),
17 Project(PathBuf),
19 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
39pub 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
76pub 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}