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