Skip to main content

lean_ctx/core/
data_dir.rs

1use std::path::PathBuf;
2
3/// Markers that identify a legacy / pre-split install whose categories must
4/// stay collapsed onto one directory (GH #408).
5///
6/// Deliberately excludes `config.toml`: after the XDG split it legitimately
7/// lives alone in the config dir, so treating it as a data marker would
8/// re-collapse a clean four-dir install back onto the config dir. These are all
9/// real data/state artifacts that only exist in a pre-split (mixed) install.
10pub(crate) const DATA_MARKERS: &[&str] =
11    &["stats.json", "sessions", "vectors", "graphs", "knowledge"];
12
13/// Resolve the lean-ctx data directory.
14///
15/// Priority order (backward-compatible XDG split, GH #408):
16/// 1. `LEAN_CTX_DATA_DIR` env var (explicit override)
17/// 2. `~/.lean-ctx` if it has actual data (legacy installs)
18/// 3. `$XDG_CONFIG_HOME/lean-ctx` if it has actual data (pre-split installs that
19///    mixed data into the config dir — kept in place, never silently moved)
20/// 4. `$XDG_DATA_HOME/lean-ctx` (default `~/.local/share/lean-ctx`) for fresh
21///    installs, so the config dir holds only config and stays RO-sandbox-safe.
22///
23/// An empty `~/.lean-ctx/` directory does NOT trigger legacy mode — this prevents
24/// data directory splits when setup creates the dir before MCP writes stats.
25pub fn lean_ctx_data_dir() -> Result<PathBuf, String> {
26    let path = resolve_data_dir()?;
27    ensure_dir_permissions(&path);
28    Ok(path)
29}
30
31/// Resolve the existing data-directory taxonomy without changing permissions.
32/// Security-sensitive stores must validate paths before any filesystem mutation.
33pub(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    // Test sandbox (GL #512): without this, any unit test that triggers a
43    // store write (stats, savings ledger, context ledger, heatmap, ...)
44    // silently pollutes the developer's real ~/.lean-ctx — bounce events from
45    // test fixtures showed up as "today 61%" on the user dashboard. Tests that
46    // set LEAN_CTX_DATA_DIR keep full control (handled above); everyone else
47    // lands in a per-process temp dir and physically cannot touch real data.
48    if sandboxed() {
49        return Ok(test_sandbox_dir());
50    }
51    resolve_home_data_dir()
52}
53
54/// Unit-test executables of the crates above the engine.
55#[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
65/// Whether this process is a test harness that must stay inside the
66/// per-process data sandbox (GL #512). The engine's own unit tests are
67/// compiled with `cfg(test)`; the composition root (`lean_ctx_cli`) runs its
68/// unit tests against an engine with the additive `test-support` feature, and
69/// there the sandbox applies only to that unit-test harness (executable
70/// `lean_ctx_cli-<hash>`), never to the `lean-ctx` binary that integration
71/// tests spawn.
72pub 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
93/// Per-process temp sandbox used as the default data dir under `#[cfg(test)]`,
94/// so any store write from a test fixture lands in a throwaway dir instead of
95/// the developer's real `~/.lean-ctx` (GL #512). Tests that need an *empty*,
96/// private dir should use [`isolated_data_dir`] instead (the shared sandbox is
97/// not empty: parallel tests write into it).
98pub(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
109/// Home-based resolution (legacy `~/.lean-ctx` / mixed vs XDG). Split out so the
110/// priority rules stay unit-testable despite the test sandbox above.
111///
112/// The legacy/mixed back-compat decision lives in exactly ONE place,
113/// [`crate::core::paths::single_dir_override`], so the data dir can never
114/// disagree with config/state/cache (which all resolve through it): a legacy
115/// `~/.lean-ctx` or mixed `$XDG_CONFIG_HOME/lean-ctx` install wins only while it
116/// still holds data markers. Once `doctor --fix` has split it out, every
117/// category — data included — flips to its typed XDG dir, and a leftover
118/// (marker-free) `~/.lean-ctx` is no longer silently re-adopted (GH #436).
119fn resolve_home_data_dir() -> Result<PathBuf, String> {
120    // 1./2. Legacy or mixed single-dir install that still holds data → keep it
121    //       in place (back-compat). `LEAN_CTX_DATA_DIR` is handled by the caller,
122    //       and `single_dir_override` honors it too.
123    if let Some(dir) = crate::core::paths::single_dir_override() {
124        return Ok(dir);
125    }
126
127    // 3. Fresh / fully-split install: default DATA to `$XDG_DATA_HOME/lean-ctx`
128    //    (GH #408) so the config dir (`$XDG_CONFIG_HOME`) holds only config and
129    //    stays read-only-sandbox-safe.
130    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
143/// A marker counts only when it actually carries data: a non-empty file, or a
144/// directory with at least one entry. An empty `sessions/` (a stray `mkdir`, a
145/// half-removed residue, a backup-restore artifact) must NOT collapse the whole
146/// layout onto a directory that holds no real data (GL #623 / #625).
147fn 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
155/// Every location a LeanCTX data directory can resolve to for this user, in
156/// resolution priority order (legacy → mixed config → XDG data), whether or
157/// not it exists. Used to keep one telemetry identity per installation when
158/// processes resolve different directories.
159pub 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
177/// Returns all known data directories that contain stats data, in resolution
178/// priority order (legacy → mixed config → XDG data). Used by the dual-dir
179/// consolidation (`crate::core::data_consolidate`) and doctor diagnostics.
180///
181/// `$XDG_DATA_HOME/lean-ctx` is included (GH #414): after the #408 default flip
182/// a fresh install writes stats there, so a user who *also* has a legacy/mixed
183/// tree has the split that the consolidation must detect and merge.
184pub 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/// Restricts the directory at `path` to its owner (0700). Runs on every data
212/// dir lookup, so it chmods only when the mode differs (GH #2227).
213#[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
223// The process-wide test environment lock lives in `lean-ctx-fs`, so the
224// engine and its foundation crates share one lock.
225pub 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/// RAII data-dir isolation for tests (GL #556): holds `test_env_lock` for
231/// the guard's lifetime, points `LEAN_CTX_DATA_DIR` at a fresh temp dir and
232/// restores the env on drop — even on panic, so a failing test cannot leak
233/// the override into others. Use this instead of hand-rolled
234/// `set_var`/`remove_var` pairs whenever a test needs an empty, private
235/// data dir (the shared per-process sandbox is NOT empty: parallel tests
236/// write stores like feedback, bandit and sessions into it).
237#[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/// Category env vars pointed at the isolated temp dir so all four XDG
251/// categories (config/data/state/cache) collapse onto it in tests (GH #408).
252#[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        // Struct Drop runs before field drops, so the env is restored while
264        // the lock is still held.
265        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        // GH #408: config.toml (+ hooks) alone must NOT mark a dir as "has data",
307        // otherwise a clean post-split config dir would re-collapse the four-dir
308        // layout back onto itself via single_dir_override.
309        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        // GH #408 flip: with no legacy/mixed data, a fresh install resolves DATA
321        // to $XDG_DATA_HOME/lean-ctx (not the config dir).
322        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        // A real `~/.lean-ctx` (legacy) would correctly take precedence; only
336        // assert the fresh default when it is absent (always true on CI).
337        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        // GL #623/#625: an empty `sessions/` (a stray mkdir, a half-removed
346        // residue) must not flip the whole layout — only a marker carrying real
347        // data counts.
348        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        // A zero-byte `stats.json` is not real data either (GL #625).
358        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        // A non-empty sessions/ is real data (GL #625: empty dirs no longer count).
372        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        // Isolate XDG_DATA_HOME to an empty base so the mixed config dir (not a
413        // pre-existing XDG data tree) is what wins here.
414        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        // Calls the home resolver directly: lean_ctx_data_dir() is sandboxed
419        // under cfg(test) (GL #512) and would short-circuit before XDG logic.
420        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        // GH #436: after `doctor --fix` moves data to XDG, `~/.lean-ctx` lingers
451        // (runtime leftovers) but holds no data markers. It must NOT keep being
452        // re-adopted as the data dir — data must flip to $XDG_DATA_HOME/lean-ctx,
453        // exactly like config/state/cache already do via single_dir_override.
454        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        // A runtime leftover (daemon.pid) is not a data marker.
461        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 before asserting so a failure can't leak env into other tests.
474        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}