lean_ctx/core/
data_dir.rs1use std::path::PathBuf;
2
3pub(crate) const DATA_MARKERS: &[&str] =
11 &["stats.json", "sessions", "vectors", "graphs", "knowledge"];
12
13pub fn lean_ctx_data_dir() -> Result<PathBuf, String> {
26 let path = resolve_data_dir()?;
27 ensure_dir_permissions(&path);
28 Ok(path)
29}
30
31pub(crate) fn resolve_data_dir() -> Result<PathBuf, String> {
34 if let Ok(dir) = std::env::var("LEAN_CTX_DATA_DIR") {
35 let trimmed = dir.trim();
36 if !trimmed.is_empty() {
37 let p = PathBuf::from(trimmed);
38 return Ok(p);
39 }
40 }
41
42 if sandboxed() {
49 return Ok(test_sandbox_dir());
50 }
51 resolve_home_data_dir()
52}
53
54#[cfg(all(not(test), feature = "test-support"))]
56const UNIT_TEST_HARNESSES: &[&str] = &[
57 "lean_ctx_cli-",
58 "lean_ctx_hooks-",
59 "lean_ctx_hive-",
60 "lean_ctx_proxy-",
61 "lean_ctx_server-",
62 "lean_ctx_shell_policy-",
63];
64
65pub fn sandboxed() -> bool {
73 #[cfg(test)]
74 {
75 true
76 }
77 #[cfg(all(not(test), feature = "test-support"))]
78 {
79 static CLI_UNIT_TESTS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
80 *CLI_UNIT_TESTS.get_or_init(|| {
81 std::env::current_exe()
82 .ok()
83 .and_then(|exe| exe.file_name().map(|n| n.to_string_lossy().into_owned()))
84 .is_some_and(|name| UNIT_TEST_HARNESSES.iter().any(|h| name.starts_with(h)))
85 })
86 }
87 #[cfg(all(not(test), not(feature = "test-support")))]
88 {
89 false
90 }
91}
92
93pub(crate) fn test_sandbox_dir() -> PathBuf {
99 static TEST_SANDBOX: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
100 TEST_SANDBOX
101 .get_or_init(|| {
102 let d = std::env::temp_dir().join(format!("lean-ctx-testdata-{}", std::process::id()));
103 let _ = std::fs::create_dir_all(&d);
104 d
105 })
106 .clone()
107}
108
109fn resolve_home_data_dir() -> Result<PathBuf, String> {
120 if let Some(dir) = crate::core::paths::single_dir_override() {
124 return Ok(dir);
125 }
126
127 let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
131 let xdg_data = std::env::var("XDG_DATA_HOME")
132 .ok()
133 .filter(|s| !s.trim().is_empty())
134 .map_or_else(|| home.join(".local").join("share"), PathBuf::from);
135 let data_dir = xdg_data.join("lean-ctx");
136 Ok(data_dir)
137}
138
139pub(crate) fn has_data_files(dir: &std::path::Path) -> bool {
140 DATA_MARKERS.iter().any(|f| marker_has_data(&dir.join(f)))
141}
142
143fn marker_has_data(path: &std::path::Path) -> bool {
148 match std::fs::metadata(path) {
149 Ok(m) if m.is_dir() => std::fs::read_dir(path).is_ok_and(|mut it| it.next().is_some()),
150 Ok(m) => m.len() > 0,
151 Err(_) => false,
152 }
153}
154
155pub fn candidate_data_dirs() -> Vec<PathBuf> {
160 let Some(home) = dirs::home_dir() else {
161 return Vec::new();
162 };
163 let base = |variable: &str, fallback: PathBuf| {
164 std::env::var(variable)
165 .ok()
166 .filter(|value| !value.trim().is_empty())
167 .map_or(fallback, PathBuf::from)
168 .join("lean-ctx")
169 };
170 vec![
171 home.join(".lean-ctx"),
172 base("XDG_CONFIG_HOME", home.join(".config")),
173 base("XDG_DATA_HOME", home.join(".local").join("share")),
174 ]
175}
176
177pub fn all_data_dirs_with_stats() -> Vec<PathBuf> {
185 let mut dirs = Vec::new();
186 if let Some(home) = dirs::home_dir() {
187 let legacy = home.join(".lean-ctx");
188 if legacy.join("stats.json").exists() {
189 dirs.push(legacy);
190 }
191 let xdg_config = std::env::var("XDG_CONFIG_HOME")
192 .ok()
193 .filter(|s| !s.trim().is_empty())
194 .map_or_else(|| home.join(".config"), PathBuf::from)
195 .join("lean-ctx");
196 if xdg_config.join("stats.json").exists() && !dirs.contains(&xdg_config) {
197 dirs.push(xdg_config);
198 }
199 let xdg_data = std::env::var("XDG_DATA_HOME")
200 .ok()
201 .filter(|s| !s.trim().is_empty())
202 .map_or_else(|| home.join(".local").join("share"), PathBuf::from)
203 .join("lean-ctx");
204 if xdg_data.join("stats.json").exists() && !dirs.contains(&xdg_data) {
205 dirs.push(xdg_data);
206 }
207 }
208 dirs
209}
210
211#[cfg(unix)]
214pub(crate) fn ensure_dir_permissions(path: &std::path::Path) {
215 if path.is_dir() {
216 let _ = crate::core::secure_fs::restrict_to_owner(path);
217 }
218}
219
220#[cfg(not(unix))]
221pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {}
222
223pub use lean_ctx_fs::env_lock::{TestEnvGuard, test_env_lock};
226
227#[cfg(any(test, feature = "test-support"))]
228pub(crate) use lean_ctx_fs::env_lock::holds_test_env_lock;
229
230#[cfg(any(test, feature = "test-support"))]
238pub struct IsolatedDataDir {
239 tmp: tempfile::TempDir,
240 _guard: TestEnvGuard,
241}
242
243#[cfg(any(test, feature = "test-support"))]
244impl IsolatedDataDir {
245 pub fn path(&self) -> &std::path::Path {
246 self.tmp.path()
247 }
248}
249
250#[cfg(any(test, feature = "test-support"))]
253const ISOLATED_ENV_VARS: &[&str] = &[
254 "LEAN_CTX_DATA_DIR",
255 "LEAN_CTX_CONFIG_DIR",
256 "LEAN_CTX_STATE_DIR",
257 "LEAN_CTX_CACHE_DIR",
258];
259
260#[cfg(any(test, feature = "test-support"))]
261impl Drop for IsolatedDataDir {
262 fn drop(&mut self) {
263 for var in ISOLATED_ENV_VARS {
266 crate::test_env::remove_var(var);
267 }
268 }
269}
270
271#[cfg(any(test, feature = "test-support"))]
272pub fn isolated_data_dir() -> IsolatedDataDir {
273 let guard = test_env_lock();
274 let tmp = tempfile::tempdir().expect("tempdir for isolated data dir");
275 for var in ISOLATED_ENV_VARS {
276 crate::test_env::set_var(var, tmp.path());
277 }
278 IsolatedDataDir { tmp, _guard: guard }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn has_data_files_empty_dir() {
287 let dir = std::env::temp_dir().join("test_data_dir_empty");
288 let _ = std::fs::remove_dir_all(&dir);
289 let _ = std::fs::create_dir_all(&dir);
290 assert!(!has_data_files(&dir));
291 let _ = std::fs::remove_dir_all(&dir);
292 }
293
294 #[test]
295 fn has_data_files_with_stats() {
296 let dir = std::env::temp_dir().join("test_data_dir_stats");
297 let _ = std::fs::remove_dir_all(&dir);
298 let _ = std::fs::create_dir_all(&dir);
299 std::fs::write(dir.join("stats.json"), "{}").unwrap();
300 assert!(has_data_files(&dir));
301 let _ = std::fs::remove_dir_all(&dir);
302 }
303
304 #[test]
305 fn has_data_files_ignores_config_only() {
306 let dir = std::env::temp_dir().join("test_data_dir_config_only");
310 let _ = std::fs::remove_dir_all(&dir);
311 let _ = std::fs::create_dir_all(&dir);
312 std::fs::write(dir.join("config.toml"), "").unwrap();
313 std::fs::write(dir.join("env.sh"), "").unwrap();
314 assert!(!has_data_files(&dir), "config-only dir is not a data dir");
315 let _ = std::fs::remove_dir_all(&dir);
316 }
317
318 #[test]
319 fn fresh_install_defaults_data_to_xdg_data_home() {
320 let _lock = test_env_lock();
323 let xdg_config = tempfile::tempdir().unwrap();
324 let xdg_data = tempfile::tempdir().unwrap();
325 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
326 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
327 crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
328
329 let result = resolve_home_data_dir().unwrap();
330
331 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
332 crate::test_env::remove_var("XDG_CONFIG_HOME");
333 crate::test_env::remove_var("XDG_DATA_HOME");
334
335 let legacy = dirs::home_dir().unwrap().join(".lean-ctx");
338 if !legacy.exists() {
339 assert_eq!(result, xdg_data.path().join("lean-ctx"));
340 }
341 }
342
343 #[test]
344 fn empty_marker_dir_does_not_count() {
345 let dir = std::env::temp_dir().join("test_data_dir_empty_marker");
349 let _ = std::fs::remove_dir_all(&dir);
350 let _ = std::fs::create_dir_all(dir.join("sessions"));
351 assert!(!has_data_files(&dir), "empty marker dir must not count");
352 let _ = std::fs::remove_dir_all(&dir);
353 }
354
355 #[test]
356 fn empty_marker_file_does_not_count() {
357 let dir = std::env::temp_dir().join("test_data_dir_empty_marker_file");
359 let _ = std::fs::remove_dir_all(&dir);
360 let _ = std::fs::create_dir_all(&dir);
361 std::fs::write(dir.join("stats.json"), "").unwrap();
362 assert!(!has_data_files(&dir), "empty marker file must not count");
363 let _ = std::fs::remove_dir_all(&dir);
364 }
365
366 #[test]
367 fn has_data_files_with_sessions() {
368 let dir = std::env::temp_dir().join("test_data_dir_sessions");
369 let _ = std::fs::remove_dir_all(&dir);
370 let _ = std::fs::create_dir_all(dir.join("sessions"));
371 std::fs::write(dir.join("sessions").join("s1.json"), "{}").unwrap();
373 assert!(has_data_files(&dir));
374 let _ = std::fs::remove_dir_all(&dir);
375 }
376
377 #[test]
378 fn lean_ctx_data_dir_env_override() {
379 let _lock = test_env_lock();
380 let dir = std::env::temp_dir().join("test_data_dir_env");
381 let _ = std::fs::remove_dir_all(&dir);
382 let _ = std::fs::create_dir_all(&dir);
383 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
384 let result = lean_ctx_data_dir().unwrap();
385 assert_eq!(result, dir);
386 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
387 let _ = std::fs::remove_dir_all(&dir);
388 }
389
390 #[test]
391 fn has_data_files_is_false_for_empty_dir() {
392 let dir = std::env::temp_dir().join("test_data_dir_no_data");
393 let _ = std::fs::remove_dir_all(&dir);
394 let _ = std::fs::create_dir_all(&dir);
395 std::fs::write(dir.join("random.txt"), "not a marker").unwrap();
396 assert!(!has_data_files(&dir));
397 let _ = std::fs::remove_dir_all(&dir);
398 }
399
400 #[test]
401 fn xdg_override_with_data_wins() {
402 let _lock = test_env_lock();
403
404 let xdg_base = std::env::temp_dir().join("test_xdg_override_wins");
405 let _ = std::fs::remove_dir_all(&xdg_base);
406 let xdg_dir = xdg_base.join("lean-ctx");
407 let _ = std::fs::create_dir_all(&xdg_dir);
408 std::fs::write(xdg_dir.join("stats.json"), r#"{"total_commands":1}"#).unwrap();
409
410 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
411 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_base.to_str().unwrap());
412 let xdg_data_base = std::env::temp_dir().join("test_xdg_override_wins_data");
415 let _ = std::fs::remove_dir_all(&xdg_data_base);
416 crate::test_env::set_var("XDG_DATA_HOME", xdg_data_base.to_str().unwrap());
417
418 let result = resolve_home_data_dir().unwrap();
421
422 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
423 crate::test_env::remove_var("XDG_CONFIG_HOME");
424 crate::test_env::remove_var("XDG_DATA_HOME");
425
426 let home = dirs::home_dir().unwrap();
427 let legacy = home.join(".lean-ctx");
428 if !has_data_files(&legacy) {
429 assert_eq!(
430 result, xdg_dir,
431 "XDG with data should win when legacy has no data"
432 );
433 }
434
435 let _ = std::fs::remove_dir_all(&xdg_base);
436 let _ = std::fs::remove_dir_all(&xdg_data_base);
437 }
438
439 #[cfg(unix)]
440 fn restore_env(key: &str, val: Option<std::ffi::OsString>) {
441 match val {
442 Some(v) => crate::test_env::set_var(key, v),
443 None => crate::test_env::remove_var(key),
444 }
445 }
446
447 #[cfg(unix)]
448 #[test]
449 fn markerless_legacy_dir_does_not_win() {
450 let _lock = test_env_lock();
455 let tmp = tempfile::tempdir().unwrap();
456 let home = tmp.path().join("home");
457 let xdg_data = tmp.path().join("xdg-data");
458 let legacy = home.join(".lean-ctx");
459 std::fs::create_dir_all(&legacy).unwrap();
460 std::fs::write(legacy.join("daemon.pid"), "123").unwrap();
462
463 let saved_home = std::env::var_os("HOME");
464 let saved_config = std::env::var_os("XDG_CONFIG_HOME");
465 let saved_data = std::env::var_os("XDG_DATA_HOME");
466 crate::test_env::set_var("HOME", &home);
467 crate::test_env::remove_var("XDG_CONFIG_HOME");
468 crate::test_env::set_var("XDG_DATA_HOME", &xdg_data);
469 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
470
471 let result = resolve_home_data_dir().unwrap();
472
473 restore_env("HOME", saved_home);
475 restore_env("XDG_CONFIG_HOME", saved_config);
476 restore_env("XDG_DATA_HOME", saved_data);
477 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
478
479 assert_eq!(
480 result,
481 xdg_data.join("lean-ctx"),
482 "marker-free legacy dir must not be re-adopted as the data dir"
483 );
484 }
485}