lean_ctx/core/
data_dir.rs1use std::path::PathBuf;
2
3const DATA_MARKERS: &[&str] = &["stats.json", "sessions", "vectors", "graphs", "knowledge"];
11
12pub fn lean_ctx_data_dir() -> Result<PathBuf, String> {
25 if let Ok(dir) = std::env::var("LEAN_CTX_DATA_DIR") {
26 let trimmed = dir.trim();
27 if !trimmed.is_empty() {
28 let p = PathBuf::from(trimmed);
29 ensure_dir_permissions(&p);
30 return Ok(p);
31 }
32 }
33
34 #[cfg(test)]
41 {
42 Ok(test_sandbox_dir())
43 }
44
45 #[cfg(not(test))]
46 {
47 resolve_home_data_dir()
48 }
49}
50
51#[cfg(test)]
57pub(crate) fn test_sandbox_dir() -> PathBuf {
58 static TEST_SANDBOX: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
59 TEST_SANDBOX
60 .get_or_init(|| {
61 let d = std::env::temp_dir().join(format!("lean-ctx-testdata-{}", std::process::id()));
62 let _ = std::fs::create_dir_all(&d);
63 d
64 })
65 .clone()
66}
67
68fn resolve_home_data_dir() -> Result<PathBuf, String> {
79 if let Some(dir) = crate::core::paths::single_dir_override() {
83 ensure_dir_permissions(&dir);
84 return Ok(dir);
85 }
86
87 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
91 let xdg_data = std::env::var("XDG_DATA_HOME")
92 .ok()
93 .filter(|s| !s.trim().is_empty())
94 .map_or_else(|| home.join(".local").join("share"), PathBuf::from);
95 let data_dir = xdg_data.join("lean-ctx");
96 ensure_dir_permissions(&data_dir);
97 Ok(data_dir)
98}
99
100pub(crate) fn has_data_files(dir: &std::path::Path) -> bool {
101 DATA_MARKERS.iter().any(|f| dir.join(f).exists())
102}
103
104pub fn all_data_dirs_with_stats() -> Vec<PathBuf> {
112 let mut dirs = Vec::new();
113 if let Some(home) = dirs::home_dir() {
114 let legacy = home.join(".lean-ctx");
115 if legacy.join("stats.json").exists() {
116 dirs.push(legacy);
117 }
118 let xdg_config = std::env::var("XDG_CONFIG_HOME")
119 .ok()
120 .filter(|s| !s.trim().is_empty())
121 .map_or_else(|| home.join(".config"), PathBuf::from)
122 .join("lean-ctx");
123 if xdg_config.join("stats.json").exists() && !dirs.contains(&xdg_config) {
124 dirs.push(xdg_config);
125 }
126 let xdg_data = std::env::var("XDG_DATA_HOME")
127 .ok()
128 .filter(|s| !s.trim().is_empty())
129 .map_or_else(|| home.join(".local").join("share"), PathBuf::from)
130 .join("lean-ctx");
131 if xdg_data.join("stats.json").exists() && !dirs.contains(&xdg_data) {
132 dirs.push(xdg_data);
133 }
134 }
135 dirs
136}
137
138#[cfg(unix)]
139pub(crate) fn ensure_dir_permissions(path: &std::path::Path) {
140 use std::os::unix::fs::PermissionsExt;
141 if path.is_dir() {
142 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
143 }
144}
145
146#[cfg(not(unix))]
147pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {}
148
149pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
150 use std::sync::{Mutex, OnceLock};
151 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
152 let mutex = LOCK.get_or_init(|| Mutex::new(()));
153 mutex
154 .lock()
155 .unwrap_or_else(std::sync::PoisonError::into_inner)
156}
157
158#[cfg(test)]
166pub struct IsolatedDataDir {
167 tmp: tempfile::TempDir,
168 _guard: std::sync::MutexGuard<'static, ()>,
169}
170
171#[cfg(test)]
172impl IsolatedDataDir {
173 pub fn path(&self) -> &std::path::Path {
174 self.tmp.path()
175 }
176}
177
178#[cfg(test)]
181const ISOLATED_ENV_VARS: &[&str] = &[
182 "LEAN_CTX_DATA_DIR",
183 "LEAN_CTX_CONFIG_DIR",
184 "LEAN_CTX_STATE_DIR",
185 "LEAN_CTX_CACHE_DIR",
186];
187
188#[cfg(test)]
189impl Drop for IsolatedDataDir {
190 fn drop(&mut self) {
191 for var in ISOLATED_ENV_VARS {
194 crate::test_env::remove_var(var);
195 }
196 }
197}
198
199#[cfg(test)]
200pub fn isolated_data_dir() -> IsolatedDataDir {
201 let guard = test_env_lock();
202 let tmp = tempfile::tempdir().expect("tempdir for isolated data dir");
203 for var in ISOLATED_ENV_VARS {
204 crate::test_env::set_var(var, tmp.path());
205 }
206 IsolatedDataDir { tmp, _guard: guard }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn has_data_files_empty_dir() {
215 let dir = std::env::temp_dir().join("test_data_dir_empty");
216 let _ = std::fs::remove_dir_all(&dir);
217 let _ = std::fs::create_dir_all(&dir);
218 assert!(!has_data_files(&dir));
219 let _ = std::fs::remove_dir_all(&dir);
220 }
221
222 #[test]
223 fn has_data_files_with_stats() {
224 let dir = std::env::temp_dir().join("test_data_dir_stats");
225 let _ = std::fs::remove_dir_all(&dir);
226 let _ = std::fs::create_dir_all(&dir);
227 std::fs::write(dir.join("stats.json"), "{}").unwrap();
228 assert!(has_data_files(&dir));
229 let _ = std::fs::remove_dir_all(&dir);
230 }
231
232 #[test]
233 fn has_data_files_ignores_config_only() {
234 let dir = std::env::temp_dir().join("test_data_dir_config_only");
238 let _ = std::fs::remove_dir_all(&dir);
239 let _ = std::fs::create_dir_all(&dir);
240 std::fs::write(dir.join("config.toml"), "").unwrap();
241 std::fs::write(dir.join("env.sh"), "").unwrap();
242 assert!(!has_data_files(&dir), "config-only dir is not a data dir");
243 let _ = std::fs::remove_dir_all(&dir);
244 }
245
246 #[test]
247 fn fresh_install_defaults_data_to_xdg_data_home() {
248 let _lock = test_env_lock();
251 let xdg_config = tempfile::tempdir().unwrap();
252 let xdg_data = tempfile::tempdir().unwrap();
253 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
254 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
255 crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
256
257 let result = resolve_home_data_dir().unwrap();
258
259 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
260 crate::test_env::remove_var("XDG_CONFIG_HOME");
261 crate::test_env::remove_var("XDG_DATA_HOME");
262
263 let legacy = dirs::home_dir().unwrap().join(".lean-ctx");
266 if !legacy.exists() {
267 assert_eq!(result, xdg_data.path().join("lean-ctx"));
268 }
269 }
270
271 #[test]
272 fn has_data_files_with_sessions() {
273 let dir = std::env::temp_dir().join("test_data_dir_sessions");
274 let _ = std::fs::remove_dir_all(&dir);
275 let _ = std::fs::create_dir_all(&dir);
276 let _ = std::fs::create_dir_all(dir.join("sessions"));
277 assert!(has_data_files(&dir));
278 let _ = std::fs::remove_dir_all(&dir);
279 }
280
281 #[test]
282 fn lean_ctx_data_dir_env_override() {
283 let _lock = test_env_lock();
284 let dir = std::env::temp_dir().join("test_data_dir_env");
285 let _ = std::fs::remove_dir_all(&dir);
286 let _ = std::fs::create_dir_all(&dir);
287 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
288 let result = lean_ctx_data_dir().unwrap();
289 assert_eq!(result, dir);
290 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
291 let _ = std::fs::remove_dir_all(&dir);
292 }
293
294 #[test]
295 fn has_data_files_is_false_for_empty_dir() {
296 let dir = std::env::temp_dir().join("test_data_dir_no_data");
297 let _ = std::fs::remove_dir_all(&dir);
298 let _ = std::fs::create_dir_all(&dir);
299 std::fs::write(dir.join("random.txt"), "not a marker").unwrap();
300 assert!(!has_data_files(&dir));
301 let _ = std::fs::remove_dir_all(&dir);
302 }
303
304 #[test]
305 fn xdg_override_with_data_wins() {
306 let _lock = test_env_lock();
307
308 let xdg_base = std::env::temp_dir().join("test_xdg_override_wins");
309 let _ = std::fs::remove_dir_all(&xdg_base);
310 let xdg_dir = xdg_base.join("lean-ctx");
311 let _ = std::fs::create_dir_all(&xdg_dir);
312 std::fs::write(xdg_dir.join("stats.json"), r#"{"total_commands":1}"#).unwrap();
313
314 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
315 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_base.to_str().unwrap());
316
317 let result = resolve_home_data_dir().unwrap();
320
321 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
322 crate::test_env::remove_var("XDG_CONFIG_HOME");
323
324 let home = dirs::home_dir().unwrap();
325 let legacy = home.join(".lean-ctx");
326 if !has_data_files(&legacy) {
327 assert_eq!(
328 result, xdg_dir,
329 "XDG with data should win when legacy has no data"
330 );
331 }
332
333 let _ = std::fs::remove_dir_all(&xdg_base);
334 }
335
336 #[cfg(unix)]
337 fn restore_env(key: &str, val: Option<std::ffi::OsString>) {
338 match val {
339 Some(v) => crate::test_env::set_var(key, v),
340 None => crate::test_env::remove_var(key),
341 }
342 }
343
344 #[cfg(unix)]
345 #[test]
346 fn markerless_legacy_dir_does_not_win() {
347 let _lock = test_env_lock();
352 let tmp = tempfile::tempdir().unwrap();
353 let home = tmp.path().join("home");
354 let xdg_data = tmp.path().join("xdg-data");
355 let legacy = home.join(".lean-ctx");
356 std::fs::create_dir_all(&legacy).unwrap();
357 std::fs::write(legacy.join("daemon.pid"), "123").unwrap();
359
360 let saved_home = std::env::var_os("HOME");
361 let saved_config = std::env::var_os("XDG_CONFIG_HOME");
362 let saved_data = std::env::var_os("XDG_DATA_HOME");
363 crate::test_env::set_var("HOME", &home);
364 crate::test_env::remove_var("XDG_CONFIG_HOME");
365 crate::test_env::set_var("XDG_DATA_HOME", &xdg_data);
366 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
367
368 let result = resolve_home_data_dir().unwrap();
369
370 restore_env("HOME", saved_home);
372 restore_env("XDG_CONFIG_HOME", saved_config);
373 restore_env("XDG_DATA_HOME", saved_data);
374 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
375
376 assert_eq!(
377 result,
378 xdg_data.join("lean-ctx"),
379 "marker-free legacy dir must not be re-adopted as the data dir"
380 );
381 }
382}