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.
10const DATA_MARKERS: &[&str] = &["stats.json", "sessions", "vectors", "graphs", "knowledge"];
11
12/// Resolve the lean-ctx data directory.
13///
14/// Priority order (backward-compatible XDG split, GH #408):
15/// 1. `LEAN_CTX_DATA_DIR` env var (explicit override)
16/// 2. `~/.lean-ctx` if it has actual data (legacy installs)
17/// 3. `$XDG_CONFIG_HOME/lean-ctx` if it has actual data (pre-split installs that
18///    mixed data into the config dir — kept in place, never silently moved)
19/// 4. `$XDG_DATA_HOME/lean-ctx` (default `~/.local/share/lean-ctx`) for fresh
20///    installs, so the config dir holds only config and stays RO-sandbox-safe.
21///
22/// An empty `~/.lean-ctx/` directory does NOT trigger legacy mode — this prevents
23/// data directory splits when setup creates the dir before MCP writes stats.
24pub 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    // Test sandbox (GL #512): without this, any unit test that triggers a
35    // store write (stats, savings ledger, context ledger, heatmap, ...)
36    // silently pollutes the developer's real ~/.lean-ctx — bounce events from
37    // test fixtures showed up as "today 61%" on the user dashboard. Tests that
38    // set LEAN_CTX_DATA_DIR keep full control (handled above); everyone else
39    // lands in a per-process temp dir and physically cannot touch real data.
40    #[cfg(test)]
41    {
42        Ok(test_sandbox_dir())
43    }
44
45    #[cfg(not(test))]
46    {
47        resolve_home_data_dir()
48    }
49}
50
51/// Per-process temp sandbox used as the default data dir under `#[cfg(test)]`,
52/// so any store write from a test fixture lands in a throwaway dir instead of
53/// the developer's real `~/.lean-ctx` (GL #512). Tests that need an *empty*,
54/// private dir should use [`isolated_data_dir`] instead (the shared sandbox is
55/// not empty: parallel tests write into it).
56#[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
68/// Home-based resolution (legacy `~/.lean-ctx` / mixed vs XDG). Split out so the
69/// priority rules stay unit-testable despite the test sandbox above.
70///
71/// The legacy/mixed back-compat decision lives in exactly ONE place,
72/// [`crate::core::paths::single_dir_override`], so the data dir can never
73/// disagree with config/state/cache (which all resolve through it): a legacy
74/// `~/.lean-ctx` or mixed `$XDG_CONFIG_HOME/lean-ctx` install wins only while it
75/// still holds data markers. Once `doctor --fix` has split it out, every
76/// category — data included — flips to its typed XDG dir, and a leftover
77/// (marker-free) `~/.lean-ctx` is no longer silently re-adopted (GH #436).
78fn resolve_home_data_dir() -> Result<PathBuf, String> {
79    // 1./2. Legacy or mixed single-dir install that still holds data → keep it
80    //       in place (back-compat). `LEAN_CTX_DATA_DIR` is handled by the caller,
81    //       and `single_dir_override` honors it too.
82    if let Some(dir) = crate::core::paths::single_dir_override() {
83        ensure_dir_permissions(&dir);
84        return Ok(dir);
85    }
86
87    // 3. Fresh / fully-split install: default DATA to `$XDG_DATA_HOME/lean-ctx`
88    //    (GH #408) so the config dir (`$XDG_CONFIG_HOME`) holds only config and
89    //    stays read-only-sandbox-safe.
90    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
104/// A marker counts only when it actually carries data: a non-empty file, or a
105/// directory with at least one entry. An empty `sessions/` (a stray `mkdir`, a
106/// half-removed residue, a backup-restore artifact) must NOT collapse the whole
107/// layout onto a directory that holds no real data (GL #623 / #625).
108fn 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
116/// Returns all known data directories that contain stats data, in resolution
117/// priority order (legacy → mixed config → XDG data). Used by the dual-dir
118/// consolidation ([`crate::core::data_consolidate`]) and doctor diagnostics.
119///
120/// `$XDG_DATA_HOME/lean-ctx` is included (GH #414): after the #408 default flip
121/// a fresh install writes stats there, so a user who *also* has a legacy/mixed
122/// tree has the split that the consolidation must detect and merge.
123pub 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    /// Re-entrancy depth for the current thread. >0 means this thread already
165    /// holds the lock, so a nested acquire is a no-op bump instead of a
166    /// self-deadlock.
167    static TEST_ENV_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
168    /// The real guard, held only while depth transitions 0→…→0.
169    static TEST_ENV_HELD: std::cell::RefCell<Option<std::sync::MutexGuard<'static, ()>>> =
170        const { std::cell::RefCell::new(None) };
171}
172
173/// Reentrant test env lock. Serializes env-mutating tests across threads (the
174/// point — `std::env::set_var` is not thread-safe) while letting the SAME
175/// thread re-acquire without deadlocking. A test and a `setup()` helper (or
176/// `isolated_data_dir`) can both take it; only the outermost acquisition holds
177/// the underlying mutex, nested ones just bump a per-thread depth. Other
178/// threads still block, so cross-test serialization is unchanged.
179pub 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
193/// RAII guard for [`test_env_lock`]. Dropping the outermost one releases the
194/// underlying mutex; nested guards just decrement the depth.
195pub 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/// RAII data-dir isolation for tests (GL #556): holds `test_env_lock` for
212/// the guard's lifetime, points `LEAN_CTX_DATA_DIR` at a fresh temp dir and
213/// restores the env on drop — even on panic, so a failing test cannot leak
214/// the override into others. Use this instead of hand-rolled
215/// `set_var`/`remove_var` pairs whenever a test needs an empty, private
216/// data dir (the shared per-process sandbox is NOT empty: parallel tests
217/// write stores like feedback, bandit and sessions into it).
218#[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/// Category env vars pointed at the isolated temp dir so all four XDG
232/// categories (config/data/state/cache) collapse onto it in tests (GH #408).
233#[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        // Struct Drop runs before field drops, so the env is restored while
245        // the lock is still held.
246        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        // GH #408: config.toml (+ hooks) alone must NOT mark a dir as "has data",
288        // otherwise a clean post-split config dir would re-collapse the four-dir
289        // layout back onto itself via single_dir_override.
290        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        // GH #408 flip: with no legacy/mixed data, a fresh install resolves DATA
302        // to $XDG_DATA_HOME/lean-ctx (not the config dir).
303        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        // A real `~/.lean-ctx` (legacy) would correctly take precedence; only
317        // assert the fresh default when it is absent (always true on CI).
318        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        // GL #623/#625: an empty `sessions/` (a stray mkdir, a half-removed
327        // residue) must not flip the whole layout — only a marker carrying real
328        // data counts.
329        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        // A zero-byte `stats.json` is not real data either (GL #625).
339        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        // A non-empty sessions/ is real data (GL #625: empty dirs no longer count).
353        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        // Isolate XDG_DATA_HOME to an empty base so the mixed config dir (not a
394        // pre-existing XDG data tree) is what wins here.
395        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        // Calls the home resolver directly: lean_ctx_data_dir() is sandboxed
400        // under cfg(test) (GL #512) and would short-circuit before XDG logic.
401        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        // GH #436: after `doctor --fix` moves data to XDG, `~/.lean-ctx` lingers
432        // (runtime leftovers) but holds no data markers. It must NOT keep being
433        // re-adopted as the data dir — data must flip to $XDG_DATA_HOME/lean-ctx,
434        // exactly like config/state/cache already do via single_dir_override.
435        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        // A runtime leftover (daemon.pid) is not a data marker.
442        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 before asserting so a failure can't leak env into other tests.
455        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}