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| marker_has_data(&dir.join(f)))
102}
103
104fn marker_has_data(path: &std::path::Path) -> bool {
109 match std::fs::metadata(path) {
110 Ok(m) if m.is_dir() => std::fs::read_dir(path).is_ok_and(|mut it| it.next().is_some()),
111 Ok(m) => m.len() > 0,
112 Err(_) => false,
113 }
114}
115
116pub fn all_data_dirs_with_stats() -> Vec<PathBuf> {
124 let mut dirs = Vec::new();
125 if let Some(home) = dirs::home_dir() {
126 let legacy = home.join(".lean-ctx");
127 if legacy.join("stats.json").exists() {
128 dirs.push(legacy);
129 }
130 let xdg_config = std::env::var("XDG_CONFIG_HOME")
131 .ok()
132 .filter(|s| !s.trim().is_empty())
133 .map_or_else(|| home.join(".config"), PathBuf::from)
134 .join("lean-ctx");
135 if xdg_config.join("stats.json").exists() && !dirs.contains(&xdg_config) {
136 dirs.push(xdg_config);
137 }
138 let xdg_data = std::env::var("XDG_DATA_HOME")
139 .ok()
140 .filter(|s| !s.trim().is_empty())
141 .map_or_else(|| home.join(".local").join("share"), PathBuf::from)
142 .join("lean-ctx");
143 if xdg_data.join("stats.json").exists() && !dirs.contains(&xdg_data) {
144 dirs.push(xdg_data);
145 }
146 }
147 dirs
148}
149
150#[cfg(unix)]
151pub(crate) fn ensure_dir_permissions(path: &std::path::Path) {
152 use std::os::unix::fs::PermissionsExt;
153 if path.is_dir() {
154 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
155 }
156}
157
158#[cfg(not(unix))]
159pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {}
160
161static TEST_ENV_MUTEX: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
162
163thread_local! {
164 static TEST_ENV_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
168 static TEST_ENV_HELD: std::cell::RefCell<Option<std::sync::MutexGuard<'static, ()>>> =
170 const { std::cell::RefCell::new(None) };
171}
172
173pub fn test_env_lock() -> TestEnvGuard {
180 let mutex = TEST_ENV_MUTEX.get_or_init(|| std::sync::Mutex::new(()));
181 TEST_ENV_DEPTH.with(|depth| {
182 if depth.get() == 0 {
183 let guard = mutex
184 .lock()
185 .unwrap_or_else(std::sync::PoisonError::into_inner);
186 TEST_ENV_HELD.with(|held| *held.borrow_mut() = Some(guard));
187 }
188 depth.set(depth.get() + 1);
189 });
190 TestEnvGuard { _private: () }
191}
192
193pub struct TestEnvGuard {
196 _private: (),
197}
198
199impl Drop for TestEnvGuard {
200 fn drop(&mut self) {
201 TEST_ENV_DEPTH.with(|depth| {
202 let next = depth.get().saturating_sub(1);
203 depth.set(next);
204 if next == 0 {
205 TEST_ENV_HELD.with(|held| *held.borrow_mut() = None);
206 }
207 });
208 }
209}
210
211#[cfg(test)]
219pub struct IsolatedDataDir {
220 tmp: tempfile::TempDir,
221 _guard: TestEnvGuard,
222}
223
224#[cfg(test)]
225impl IsolatedDataDir {
226 pub fn path(&self) -> &std::path::Path {
227 self.tmp.path()
228 }
229}
230
231#[cfg(test)]
234const ISOLATED_ENV_VARS: &[&str] = &[
235 "LEAN_CTX_DATA_DIR",
236 "LEAN_CTX_CONFIG_DIR",
237 "LEAN_CTX_STATE_DIR",
238 "LEAN_CTX_CACHE_DIR",
239];
240
241#[cfg(test)]
242impl Drop for IsolatedDataDir {
243 fn drop(&mut self) {
244 for var in ISOLATED_ENV_VARS {
247 crate::test_env::remove_var(var);
248 }
249 }
250}
251
252#[cfg(test)]
253pub fn isolated_data_dir() -> IsolatedDataDir {
254 let guard = test_env_lock();
255 let tmp = tempfile::tempdir().expect("tempdir for isolated data dir");
256 for var in ISOLATED_ENV_VARS {
257 crate::test_env::set_var(var, tmp.path());
258 }
259 IsolatedDataDir { tmp, _guard: guard }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn has_data_files_empty_dir() {
268 let dir = std::env::temp_dir().join("test_data_dir_empty");
269 let _ = std::fs::remove_dir_all(&dir);
270 let _ = std::fs::create_dir_all(&dir);
271 assert!(!has_data_files(&dir));
272 let _ = std::fs::remove_dir_all(&dir);
273 }
274
275 #[test]
276 fn has_data_files_with_stats() {
277 let dir = std::env::temp_dir().join("test_data_dir_stats");
278 let _ = std::fs::remove_dir_all(&dir);
279 let _ = std::fs::create_dir_all(&dir);
280 std::fs::write(dir.join("stats.json"), "{}").unwrap();
281 assert!(has_data_files(&dir));
282 let _ = std::fs::remove_dir_all(&dir);
283 }
284
285 #[test]
286 fn has_data_files_ignores_config_only() {
287 let dir = std::env::temp_dir().join("test_data_dir_config_only");
291 let _ = std::fs::remove_dir_all(&dir);
292 let _ = std::fs::create_dir_all(&dir);
293 std::fs::write(dir.join("config.toml"), "").unwrap();
294 std::fs::write(dir.join("env.sh"), "").unwrap();
295 assert!(!has_data_files(&dir), "config-only dir is not a data dir");
296 let _ = std::fs::remove_dir_all(&dir);
297 }
298
299 #[test]
300 fn fresh_install_defaults_data_to_xdg_data_home() {
301 let _lock = test_env_lock();
304 let xdg_config = tempfile::tempdir().unwrap();
305 let xdg_data = tempfile::tempdir().unwrap();
306 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
307 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
308 crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
309
310 let result = resolve_home_data_dir().unwrap();
311
312 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
313 crate::test_env::remove_var("XDG_CONFIG_HOME");
314 crate::test_env::remove_var("XDG_DATA_HOME");
315
316 let legacy = dirs::home_dir().unwrap().join(".lean-ctx");
319 if !legacy.exists() {
320 assert_eq!(result, xdg_data.path().join("lean-ctx"));
321 }
322 }
323
324 #[test]
325 fn empty_marker_dir_does_not_count() {
326 let dir = std::env::temp_dir().join("test_data_dir_empty_marker");
330 let _ = std::fs::remove_dir_all(&dir);
331 let _ = std::fs::create_dir_all(dir.join("sessions"));
332 assert!(!has_data_files(&dir), "empty marker dir must not count");
333 let _ = std::fs::remove_dir_all(&dir);
334 }
335
336 #[test]
337 fn empty_marker_file_does_not_count() {
338 let dir = std::env::temp_dir().join("test_data_dir_empty_marker_file");
340 let _ = std::fs::remove_dir_all(&dir);
341 let _ = std::fs::create_dir_all(&dir);
342 std::fs::write(dir.join("stats.json"), "").unwrap();
343 assert!(!has_data_files(&dir), "empty marker file must not count");
344 let _ = std::fs::remove_dir_all(&dir);
345 }
346
347 #[test]
348 fn has_data_files_with_sessions() {
349 let dir = std::env::temp_dir().join("test_data_dir_sessions");
350 let _ = std::fs::remove_dir_all(&dir);
351 let _ = std::fs::create_dir_all(dir.join("sessions"));
352 std::fs::write(dir.join("sessions").join("s1.json"), "{}").unwrap();
354 assert!(has_data_files(&dir));
355 let _ = std::fs::remove_dir_all(&dir);
356 }
357
358 #[test]
359 fn lean_ctx_data_dir_env_override() {
360 let _lock = test_env_lock();
361 let dir = std::env::temp_dir().join("test_data_dir_env");
362 let _ = std::fs::remove_dir_all(&dir);
363 let _ = std::fs::create_dir_all(&dir);
364 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
365 let result = lean_ctx_data_dir().unwrap();
366 assert_eq!(result, dir);
367 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
368 let _ = std::fs::remove_dir_all(&dir);
369 }
370
371 #[test]
372 fn has_data_files_is_false_for_empty_dir() {
373 let dir = std::env::temp_dir().join("test_data_dir_no_data");
374 let _ = std::fs::remove_dir_all(&dir);
375 let _ = std::fs::create_dir_all(&dir);
376 std::fs::write(dir.join("random.txt"), "not a marker").unwrap();
377 assert!(!has_data_files(&dir));
378 let _ = std::fs::remove_dir_all(&dir);
379 }
380
381 #[test]
382 fn xdg_override_with_data_wins() {
383 let _lock = test_env_lock();
384
385 let xdg_base = std::env::temp_dir().join("test_xdg_override_wins");
386 let _ = std::fs::remove_dir_all(&xdg_base);
387 let xdg_dir = xdg_base.join("lean-ctx");
388 let _ = std::fs::create_dir_all(&xdg_dir);
389 std::fs::write(xdg_dir.join("stats.json"), r#"{"total_commands":1}"#).unwrap();
390
391 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
392 crate::test_env::set_var("XDG_CONFIG_HOME", xdg_base.to_str().unwrap());
393 let xdg_data_base = std::env::temp_dir().join("test_xdg_override_wins_data");
396 let _ = std::fs::remove_dir_all(&xdg_data_base);
397 crate::test_env::set_var("XDG_DATA_HOME", xdg_data_base.to_str().unwrap());
398
399 let result = resolve_home_data_dir().unwrap();
402
403 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
404 crate::test_env::remove_var("XDG_CONFIG_HOME");
405 crate::test_env::remove_var("XDG_DATA_HOME");
406
407 let home = dirs::home_dir().unwrap();
408 let legacy = home.join(".lean-ctx");
409 if !has_data_files(&legacy) {
410 assert_eq!(
411 result, xdg_dir,
412 "XDG with data should win when legacy has no data"
413 );
414 }
415
416 let _ = std::fs::remove_dir_all(&xdg_base);
417 let _ = std::fs::remove_dir_all(&xdg_data_base);
418 }
419
420 #[cfg(unix)]
421 fn restore_env(key: &str, val: Option<std::ffi::OsString>) {
422 match val {
423 Some(v) => crate::test_env::set_var(key, v),
424 None => crate::test_env::remove_var(key),
425 }
426 }
427
428 #[cfg(unix)]
429 #[test]
430 fn markerless_legacy_dir_does_not_win() {
431 let _lock = test_env_lock();
436 let tmp = tempfile::tempdir().unwrap();
437 let home = tmp.path().join("home");
438 let xdg_data = tmp.path().join("xdg-data");
439 let legacy = home.join(".lean-ctx");
440 std::fs::create_dir_all(&legacy).unwrap();
441 std::fs::write(legacy.join("daemon.pid"), "123").unwrap();
443
444 let saved_home = std::env::var_os("HOME");
445 let saved_config = std::env::var_os("XDG_CONFIG_HOME");
446 let saved_data = std::env::var_os("XDG_DATA_HOME");
447 crate::test_env::set_var("HOME", &home);
448 crate::test_env::remove_var("XDG_CONFIG_HOME");
449 crate::test_env::set_var("XDG_DATA_HOME", &xdg_data);
450 crate::test_env::set_var("LEAN_CTX_DATA_DIR", "");
451
452 let result = resolve_home_data_dir().unwrap();
453
454 restore_env("HOME", saved_home);
456 restore_env("XDG_CONFIG_HOME", saved_config);
457 restore_env("XDG_DATA_HOME", saved_data);
458 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
459
460 assert_eq!(
461 result,
462 xdg_data.join("lean-ctx"),
463 "marker-free legacy dir must not be re-adopted as the data dir"
464 );
465 }
466}