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` vs XDG). Split out so the
69/// priority rules stay unit-testable despite the test sandbox above.
70fn resolve_home_data_dir() -> Result<PathBuf, String> {
71    let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
72
73    // 1. Legacy `~/.lean-ctx` holding real data → keep it (back-compat).
74    let legacy = home.join(".lean-ctx");
75    if legacy.exists() && has_data_files(&legacy) {
76        ensure_dir_permissions(&legacy);
77        return Ok(legacy);
78    }
79
80    // 2. Pre-split install that mixed data into `$XDG_CONFIG_HOME/lean-ctx` →
81    //    keep it there. Existing users never get their data silently relocated;
82    //    `lean-ctx doctor --fix` performs the per-category split on demand.
83    let xdg_config = std::env::var("XDG_CONFIG_HOME")
84        .ok()
85        .filter(|s| !s.trim().is_empty())
86        .map_or_else(|| home.join(".config"), PathBuf::from);
87    let mixed_config = xdg_config.join("lean-ctx");
88    if mixed_config.exists() && has_data_files(&mixed_config) {
89        ensure_dir_permissions(&mixed_config);
90        return Ok(mixed_config);
91    }
92
93    // 3. A non-empty legacy dir without data markers (e.g. user-created) keeps
94    //    winning so we don't surprise such setups.
95    if legacy.exists() {
96        ensure_dir_permissions(&legacy);
97        return Ok(legacy);
98    }
99
100    // 4. Fresh install: default DATA to `$XDG_DATA_HOME/lean-ctx` (GH #408) so
101    //    the config dir (`$XDG_CONFIG_HOME`) holds only config and stays
102    //    read-only-sandbox-safe.
103    let xdg_data = std::env::var("XDG_DATA_HOME")
104        .ok()
105        .filter(|s| !s.trim().is_empty())
106        .map_or_else(|| home.join(".local").join("share"), PathBuf::from);
107    let data_dir = xdg_data.join("lean-ctx");
108    ensure_dir_permissions(&data_dir);
109    Ok(data_dir)
110}
111
112pub(crate) fn has_data_files(dir: &std::path::Path) -> bool {
113    DATA_MARKERS.iter().any(|f| dir.join(f).exists())
114}
115
116/// Returns all known data directories that contain stats data.
117/// Used for migration and doctor diagnostics.
118pub fn all_data_dirs_with_stats() -> Vec<PathBuf> {
119    let mut dirs = Vec::new();
120    if let Some(home) = dirs::home_dir() {
121        let legacy = home.join(".lean-ctx");
122        if legacy.join("stats.json").exists() {
123            dirs.push(legacy);
124        }
125        let xdg = std::env::var("XDG_CONFIG_HOME")
126            .ok()
127            .filter(|s| !s.trim().is_empty())
128            .map_or_else(|| home.join(".config"), PathBuf::from)
129            .join("lean-ctx");
130        if xdg.join("stats.json").exists() && !dirs.contains(&xdg) {
131            dirs.push(xdg);
132        }
133    }
134    dirs
135}
136
137/// Detect and repair a data directory split.
138/// Returns the number of tokens migrated, or None if no split detected.
139pub fn migrate_if_split() -> Option<u64> {
140    let dirs = all_data_dirs_with_stats();
141    if dirs.len() < 2 {
142        return None;
143    }
144
145    let primary = lean_ctx_data_dir().ok()?;
146    let secondary = dirs.iter().find(|d| **d != primary)?;
147
148    let sec_content = std::fs::read_to_string(secondary.join("stats.json")).ok()?;
149    let sec_store: serde_json::Value = serde_json::from_str(&sec_content).ok()?;
150    let sec_commands = sec_store["total_commands"].as_u64().unwrap_or(0);
151    if sec_commands == 0 {
152        return None;
153    }
154
155    let primary_path = primary.join("stats.json");
156    if !primary_path.exists() {
157        let _ = std::fs::create_dir_all(&primary);
158        let _ = std::fs::copy(secondary.join("stats.json"), &primary_path);
159        let _ = std::fs::remove_file(secondary.join("stats.json"));
160        let tokens = sec_store["total_input_tokens"]
161            .as_u64()
162            .unwrap_or(0)
163            .saturating_sub(sec_store["total_output_tokens"].as_u64().unwrap_or(0));
164        return Some(tokens);
165    }
166
167    None
168}
169
170#[cfg(unix)]
171pub(crate) fn ensure_dir_permissions(path: &std::path::Path) {
172    use std::os::unix::fs::PermissionsExt;
173    if path.is_dir() {
174        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
175    }
176}
177
178#[cfg(not(unix))]
179pub(crate) fn ensure_dir_permissions(_path: &std::path::Path) {}
180
181pub fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
182    use std::sync::{Mutex, OnceLock};
183    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
184    let mutex = LOCK.get_or_init(|| Mutex::new(()));
185    mutex
186        .lock()
187        .unwrap_or_else(std::sync::PoisonError::into_inner)
188}
189
190/// RAII data-dir isolation for tests (GL #556): holds `test_env_lock` for
191/// the guard's lifetime, points `LEAN_CTX_DATA_DIR` at a fresh temp dir and
192/// restores the env on drop — even on panic, so a failing test cannot leak
193/// the override into others. Use this instead of hand-rolled
194/// `set_var`/`remove_var` pairs whenever a test needs an empty, private
195/// data dir (the shared per-process sandbox is NOT empty: parallel tests
196/// write stores like feedback, bandit and sessions into it).
197#[cfg(test)]
198pub struct IsolatedDataDir {
199    tmp: tempfile::TempDir,
200    _guard: std::sync::MutexGuard<'static, ()>,
201}
202
203#[cfg(test)]
204impl IsolatedDataDir {
205    pub fn path(&self) -> &std::path::Path {
206        self.tmp.path()
207    }
208}
209
210/// Category env vars pointed at the isolated temp dir so all four XDG
211/// categories (config/data/state/cache) collapse onto it in tests (GH #408).
212#[cfg(test)]
213const ISOLATED_ENV_VARS: &[&str] = &[
214    "LEAN_CTX_DATA_DIR",
215    "LEAN_CTX_CONFIG_DIR",
216    "LEAN_CTX_STATE_DIR",
217    "LEAN_CTX_CACHE_DIR",
218];
219
220#[cfg(test)]
221impl Drop for IsolatedDataDir {
222    fn drop(&mut self) {
223        // Struct Drop runs before field drops, so the env is restored while
224        // the lock is still held.
225        for var in ISOLATED_ENV_VARS {
226            std::env::remove_var(var);
227        }
228    }
229}
230
231#[cfg(test)]
232pub fn isolated_data_dir() -> IsolatedDataDir {
233    let guard = test_env_lock();
234    let tmp = tempfile::tempdir().expect("tempdir for isolated data dir");
235    for var in ISOLATED_ENV_VARS {
236        std::env::set_var(var, tmp.path());
237    }
238    IsolatedDataDir { tmp, _guard: guard }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    #[test]
246    fn has_data_files_empty_dir() {
247        let dir = std::env::temp_dir().join("test_data_dir_empty");
248        let _ = std::fs::remove_dir_all(&dir);
249        let _ = std::fs::create_dir_all(&dir);
250        assert!(!has_data_files(&dir));
251        let _ = std::fs::remove_dir_all(&dir);
252    }
253
254    #[test]
255    fn has_data_files_with_stats() {
256        let dir = std::env::temp_dir().join("test_data_dir_stats");
257        let _ = std::fs::remove_dir_all(&dir);
258        let _ = std::fs::create_dir_all(&dir);
259        std::fs::write(dir.join("stats.json"), "{}").unwrap();
260        assert!(has_data_files(&dir));
261        let _ = std::fs::remove_dir_all(&dir);
262    }
263
264    #[test]
265    fn has_data_files_ignores_config_only() {
266        // GH #408: config.toml (+ hooks) alone must NOT mark a dir as "has data",
267        // otherwise a clean post-split config dir would re-collapse the four-dir
268        // layout back onto itself via single_dir_override.
269        let dir = std::env::temp_dir().join("test_data_dir_config_only");
270        let _ = std::fs::remove_dir_all(&dir);
271        let _ = std::fs::create_dir_all(&dir);
272        std::fs::write(dir.join("config.toml"), "").unwrap();
273        std::fs::write(dir.join("env.sh"), "").unwrap();
274        assert!(!has_data_files(&dir), "config-only dir is not a data dir");
275        let _ = std::fs::remove_dir_all(&dir);
276    }
277
278    #[test]
279    fn fresh_install_defaults_data_to_xdg_data_home() {
280        // GH #408 flip: with no legacy/mixed data, a fresh install resolves DATA
281        // to $XDG_DATA_HOME/lean-ctx (not the config dir).
282        let _lock = test_env_lock();
283        let xdg_config = tempfile::tempdir().unwrap();
284        let xdg_data = tempfile::tempdir().unwrap();
285        std::env::set_var("LEAN_CTX_DATA_DIR", "");
286        std::env::set_var("XDG_CONFIG_HOME", xdg_config.path());
287        std::env::set_var("XDG_DATA_HOME", xdg_data.path());
288
289        let result = resolve_home_data_dir().unwrap();
290
291        std::env::remove_var("LEAN_CTX_DATA_DIR");
292        std::env::remove_var("XDG_CONFIG_HOME");
293        std::env::remove_var("XDG_DATA_HOME");
294
295        // A real `~/.lean-ctx` (legacy) would correctly take precedence; only
296        // assert the fresh default when it is absent (always true on CI).
297        let legacy = dirs::home_dir().unwrap().join(".lean-ctx");
298        if !legacy.exists() {
299            assert_eq!(result, xdg_data.path().join("lean-ctx"));
300        }
301    }
302
303    #[test]
304    fn has_data_files_with_sessions() {
305        let dir = std::env::temp_dir().join("test_data_dir_sessions");
306        let _ = std::fs::remove_dir_all(&dir);
307        let _ = std::fs::create_dir_all(&dir);
308        let _ = std::fs::create_dir_all(dir.join("sessions"));
309        assert!(has_data_files(&dir));
310        let _ = std::fs::remove_dir_all(&dir);
311    }
312
313    #[test]
314    fn lean_ctx_data_dir_env_override() {
315        let _lock = test_env_lock();
316        let dir = std::env::temp_dir().join("test_data_dir_env");
317        let _ = std::fs::remove_dir_all(&dir);
318        let _ = std::fs::create_dir_all(&dir);
319        std::env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
320        let result = lean_ctx_data_dir().unwrap();
321        assert_eq!(result, dir);
322        std::env::remove_var("LEAN_CTX_DATA_DIR");
323        let _ = std::fs::remove_dir_all(&dir);
324    }
325
326    #[test]
327    fn has_data_files_is_false_for_empty_dir() {
328        let dir = std::env::temp_dir().join("test_data_dir_no_data");
329        let _ = std::fs::remove_dir_all(&dir);
330        let _ = std::fs::create_dir_all(&dir);
331        std::fs::write(dir.join("random.txt"), "not a marker").unwrap();
332        assert!(!has_data_files(&dir));
333        let _ = std::fs::remove_dir_all(&dir);
334    }
335
336    #[test]
337    fn xdg_override_with_data_wins() {
338        let _lock = test_env_lock();
339
340        let xdg_base = std::env::temp_dir().join("test_xdg_override_wins");
341        let _ = std::fs::remove_dir_all(&xdg_base);
342        let xdg_dir = xdg_base.join("lean-ctx");
343        let _ = std::fs::create_dir_all(&xdg_dir);
344        std::fs::write(xdg_dir.join("stats.json"), r#"{"total_commands":1}"#).unwrap();
345
346        std::env::set_var("LEAN_CTX_DATA_DIR", "");
347        std::env::set_var("XDG_CONFIG_HOME", xdg_base.to_str().unwrap());
348
349        // Calls the home resolver directly: lean_ctx_data_dir() is sandboxed
350        // under cfg(test) (GL #512) and would short-circuit before XDG logic.
351        let result = resolve_home_data_dir().unwrap();
352
353        std::env::remove_var("LEAN_CTX_DATA_DIR");
354        std::env::remove_var("XDG_CONFIG_HOME");
355
356        let home = dirs::home_dir().unwrap();
357        let legacy = home.join(".lean-ctx");
358        if !has_data_files(&legacy) {
359            assert_eq!(
360                result, xdg_dir,
361                "XDG with data should win when legacy has no data"
362            );
363        }
364
365        let _ = std::fs::remove_dir_all(&xdg_base);
366    }
367}