Skip to main content

lean_ctx/core/
paths.rs

1//! Typed XDG base-directory resolvers for lean-ctx (GH #408 / GL #602).
2//!
3//! Historically every lean-ctx file joined onto a single [`lean_ctx_data_dir`]
4//! rooted at `$XDG_CONFIG_HOME/lean-ctx`, mixing `config.toml` with 30+ runtime
5//! data files (sessions, vectors, graphs, events, logs, caches). That violates
6//! the XDG Base Directory Spec and makes a read-only config sandbox impossible.
7//!
8//! This module introduces one typed resolver per XDG category so call-sites can
9//! migrate to the correct base over the following phases (GL #603/#604/#606/#607).
10//!
11//! ## Backward compatibility (single-dir mode)
12//!
13//! Existing installs MUST NOT split silently. `single_dir_override` returns
14//! `Some(dir)` when `LEAN_CTX_DATA_DIR` is set or a legacy/mixed install with
15//! data exists; in that case every category resolves to that one directory —
16//! byte-for-byte today's behavior. The real per-category split only applies to
17//! fresh installs (and, later, on-demand via `lean-ctx doctor --fix`).
18//!
19//! ## `data_dir()` and the fresh-install flip
20//!
21//! [`data_dir`] delegates to [`lean_ctx_data_dir`]. Config (`config.toml` +
22//! hooks) and the runtime STATE/CACHE files were migrated onto [`config_dir`],
23//! [`state_dir`] and [`cache_dir`] (GL #603/#604) so that, since GL #606, the
24//! data resolver defaults fresh installs to `$XDG_DATA_HOME/lean-ctx` without
25//! scattering config or state into the data dir. Legacy and pre-split mixed
26//! installs keep resolving every category to their existing single directory.
27//!
28//! Determinism (#498): every resolver is a pure function of environment + HOME;
29//! no timestamps, counters or randomness.
30
31use std::path::{Path, PathBuf};
32
33use super::data_dir::{ensure_dir_permissions, has_data_files, lean_ctx_data_dir};
34
35/// Reads a directory path from `name`, treating empty/whitespace as unset.
36fn env_path(name: &str) -> Option<PathBuf> {
37    std::env::var(name)
38        .ok()
39        .map(|v| v.trim().to_string())
40        .filter(|v| !v.is_empty())
41        .map(PathBuf::from)
42}
43
44/// Resolves an XDG base directory (e.g. `~/.config`), honoring the `env_name`
45/// override and falling back to `$HOME/<home_fallback>`. Returns the base only;
46/// callers append `lean-ctx`.
47fn xdg_base(env_name: &str, home_fallback: &str) -> Result<PathBuf, String> {
48    if let Some(p) = env_path(env_name) {
49        return Ok(p);
50    }
51    dirs::home_dir()
52        .map(|h| h.join(home_fallback))
53        .ok_or_else(|| "Cannot determine home directory".to_string())
54}
55
56/// Pure resolution order for a category: explicit override > single-dir
57/// backward-compat > XDG split default (`<base>/lean-ctx`).
58fn resolve(
59    category_override: Option<PathBuf>,
60    single: Option<PathBuf>,
61    xdg_base_dir: &Path,
62) -> PathBuf {
63    category_override
64        .or(single)
65        .unwrap_or_else(|| xdg_base_dir.join("lean-ctx"))
66}
67
68/// Returns the single directory that ALL categories must collapse onto for
69/// backward compatibility, or `None` for a fresh install that may split.
70///
71/// `Some` when `LEAN_CTX_DATA_DIR` is set, or a legacy `~/.lean-ctx` / mixed
72/// `$XDG_CONFIG_HOME/lean-ctx` install already holds data. An empty directory
73/// does NOT trigger single-dir mode (matches [`lean_ctx_data_dir`] semantics).
74pub(crate) fn single_dir_override() -> Option<PathBuf> {
75    if let Some(p) = env_path("LEAN_CTX_DATA_DIR") {
76        return Some(p);
77    }
78    let home = dirs::home_dir()?;
79    let xdg_config_base = xdg_base("XDG_CONFIG_HOME", ".config").ok()?;
80    single_dir_override_fs(&home, &xdg_config_base)
81}
82
83/// Filesystem half of [`single_dir_override`], parameterized for hermetic tests.
84fn single_dir_override_fs(home: &Path, xdg_config_base: &Path) -> Option<PathBuf> {
85    // A committed XDG install is the single source of truth: never re-collapse
86    // it onto a stray legacy/mixed data marker (GL #623). The pin lives next to
87    // the mixed probe below, so the two reads always agree on `xdg_config_base`.
88    if crate::core::layout_pin::is_xdg_pinned_in(xdg_config_base) {
89        return None;
90    }
91    let legacy = home.join(".lean-ctx");
92    if legacy.exists() && has_data_files(&legacy) {
93        return Some(legacy);
94    }
95    let mixed = xdg_config_base.join("lean-ctx");
96    if mixed.exists() && has_data_files(&mixed) {
97        return Some(mixed);
98    }
99    None
100}
101
102/// Shared resolver for the config/state/cache categories.
103// Under `#[cfg(test)]` the body always succeeds (returns the sandbox); the
104// fallible XDG resolution only runs in real builds.
105#[cfg_attr(test, allow(clippy::unnecessary_wraps))]
106fn category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
107    let category_override = env_path(cat_env);
108
109    // A category override always wins, even under #[cfg(test)] — this lets the
110    // RO-config sandbox integration test point each category at a temp dir.
111    #[cfg(test)]
112    {
113        if let Some(p) = category_override {
114            ensure_dir_permissions(&p);
115            return Ok(p);
116        }
117        // Unit tests share one per-process sandbox so stray store writes can't
118        // escape to a developer's real dirs. The branch logic itself is covered
119        // by the pure `resolve` / `single_dir_override_fs` tests below.
120        let _ = (xdg_env, home_fallback);
121        Ok(super::data_dir::test_sandbox_dir())
122    }
123    #[cfg(not(test))]
124    {
125        let base = xdg_base(xdg_env, home_fallback)?;
126        let dir = resolve(category_override, single_dir_override(), &base);
127        ensure_dir_permissions(&dir);
128        Ok(dir)
129    }
130}
131
132/// Config directory — `config.toml`, shell hooks, `env.sh`. RO-safe.
133/// Override: `LEAN_CTX_CONFIG_DIR`; default `$XDG_CONFIG_HOME/lean-ctx`.
134pub fn config_dir() -> Result<PathBuf, String> {
135    category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
136}
137
138/// Data directory — sessions, vectors, graphs, knowledge, archives, memory.
139///
140/// Delegates to [`lean_ctx_data_dir`], which since GL #606 defaults fresh
141/// installs to `$XDG_DATA_HOME/lean-ctx`. Legacy `~/.lean-ctx` and pre-split
142/// mixed `$XDG_CONFIG_HOME/lean-ctx` installs (and an explicit
143/// `LEAN_CTX_DATA_DIR`) continue to resolve in place for backward compatibility.
144pub fn data_dir() -> Result<PathBuf, String> {
145    lean_ctx_data_dir()
146}
147
148/// State directory — events, stats, logs, journals, ledgers, captured keys.
149/// Override: `LEAN_CTX_STATE_DIR`; default `$XDG_STATE_HOME/lean-ctx`.
150pub fn state_dir() -> Result<PathBuf, String> {
151    category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
152}
153
154/// Cache directory — semantic cache, models, learned patterns. tmpfs-safe.
155/// Override: `LEAN_CTX_CACHE_DIR`; default `$XDG_CACHE_HOME/lean-ctx`.
156pub fn cache_dir() -> Result<PathBuf, String> {
157    category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
158}
159
160/// Runtime directory — `daemon.pid`, `daemon.sock`. `$XDG_RUNTIME_DIR/lean-ctx`.
161///
162/// When `XDG_RUNTIME_DIR` is unset (common on macOS), falls back to
163/// [`state_dir`] so runtime files stay in a private, writable, non-config path
164/// rather than a world-readable temp location.
165pub fn runtime_dir() -> Result<PathBuf, String> {
166    if let Some(base) = env_path("XDG_RUNTIME_DIR") {
167        return Ok(base.join("lean-ctx"));
168    }
169    state_dir()
170}
171
172/// Raw per-category target dir for the four XDG categories, **bypassing**
173/// single-dir back-compat and the test sandbox. Honors an explicit
174/// `LEAN_CTX_<CAT>_DIR` override, otherwise `<XDG base>/lean-ctx`.
175///
176/// `category_dir`/[`data_dir`] deliberately collapse onto one directory for a
177/// legacy/mixed install; the `doctor --fix` migration (GH #408) needs to know
178/// where each category SHOULD live *after* a split, which is what this returns.
179fn raw_category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
180    if let Some(p) = env_path(cat_env) {
181        return Ok(p);
182    }
183    Ok(xdg_base(xdg_env, home_fallback)?.join("lean-ctx"))
184}
185
186/// Split target for the config category (`$XDG_CONFIG_HOME/lean-ctx`).
187pub(crate) fn config_split_target() -> Result<PathBuf, String> {
188    raw_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
189}
190
191/// `$XDG_CONFIG_HOME/lean-ctx` (or `~/.config/lean-ctx`) — where `config.toml`
192/// and the layout pin (`layout.toml`) live. Resolved through the XDG config base
193/// only, bypassing single-dir collapse, so the pin that governs that collapse
194/// never depends on it (GL #623). `None` only when HOME cannot be determined.
195pub(crate) fn xdg_config_lean_ctx_dir() -> Option<PathBuf> {
196    xdg_base("XDG_CONFIG_HOME", ".config")
197        .ok()
198        .map(|b| b.join("lean-ctx"))
199}
200
201/// Split target for the data category (`$XDG_DATA_HOME/lean-ctx`).
202pub(crate) fn data_split_target() -> Result<PathBuf, String> {
203    raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
204}
205
206/// Split target for the state category (`$XDG_STATE_HOME/lean-ctx`).
207pub(crate) fn state_split_target() -> Result<PathBuf, String> {
208    raw_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
209}
210
211/// Split target for the cache category (`$XDG_CACHE_HOME/lean-ctx`).
212pub(crate) fn cache_split_target() -> Result<PathBuf, String> {
213    raw_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn resolve_prefers_override_then_single_then_xdg() {
222        let over = PathBuf::from("/over/ride");
223        let single = PathBuf::from("/single/dir");
224        let base = PathBuf::from("/xdg/base");
225
226        assert_eq!(
227            resolve(Some(over.clone()), Some(single.clone()), &base),
228            over
229        );
230        assert_eq!(resolve(None, Some(single.clone()), &base), single);
231        assert_eq!(
232            resolve(None, None, &base),
233            PathBuf::from("/xdg/base/lean-ctx")
234        );
235    }
236
237    #[test]
238    fn single_dir_fs_detects_legacy_with_data() {
239        let home = tempfile::tempdir().unwrap();
240        let xdg = tempfile::tempdir().unwrap();
241        let legacy = home.path().join(".lean-ctx");
242        std::fs::create_dir_all(&legacy).unwrap();
243        std::fs::write(legacy.join("stats.json"), "{}").unwrap();
244
245        assert_eq!(
246            single_dir_override_fs(home.path(), xdg.path()),
247            Some(legacy)
248        );
249    }
250
251    #[test]
252    fn single_dir_fs_detects_mixed_with_data() {
253        let home = tempfile::tempdir().unwrap();
254        let xdg = tempfile::tempdir().unwrap();
255        let mixed = xdg.path().join("lean-ctx");
256        std::fs::create_dir_all(&mixed).unwrap();
257        // A real data marker (stats.json) — NOT config.toml, which post-split
258        // lives alone in the config dir and must not trigger single-dir mode.
259        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
260
261        assert_eq!(single_dir_override_fs(home.path(), xdg.path()), Some(mixed));
262    }
263
264    #[test]
265    fn single_dir_fs_ignores_config_only_dir() {
266        // GH #408: a clean post-split config dir (only config.toml + hooks) must
267        // NOT collapse the four-dir layout.
268        let home = tempfile::tempdir().unwrap();
269        let xdg = tempfile::tempdir().unwrap();
270        let mixed = xdg.path().join("lean-ctx");
271        std::fs::create_dir_all(&mixed).unwrap();
272        std::fs::write(mixed.join("config.toml"), "").unwrap();
273        std::fs::write(mixed.join("shell-hook.zsh"), "").unwrap();
274
275        assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
276    }
277
278    #[test]
279    fn single_dir_fs_prefers_legacy_over_mixed() {
280        let home = tempfile::tempdir().unwrap();
281        let xdg = tempfile::tempdir().unwrap();
282        let legacy = home.path().join(".lean-ctx");
283        std::fs::create_dir_all(&legacy).unwrap();
284        std::fs::write(legacy.join("sessions"), "x").unwrap();
285        let mixed = xdg.path().join("lean-ctx");
286        std::fs::create_dir_all(&mixed).unwrap();
287        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
288
289        assert_eq!(
290            single_dir_override_fs(home.path(), xdg.path()),
291            Some(legacy)
292        );
293    }
294
295    #[test]
296    fn xdg_pinned_install_ignores_stray_legacy_marker() {
297        // GL #623: once committed to XDG (pin in the config dir), a stray
298        // `~/.lean-ctx/stats.json` (legacy residue, restored backup, concurrent
299        // old binary) must NOT re-collapse the layout onto the legacy dir.
300        let home = tempfile::tempdir().unwrap();
301        let xdg = tempfile::tempdir().unwrap();
302        crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
303
304        let legacy = home.path().join(".lean-ctx");
305        std::fs::create_dir_all(&legacy).unwrap();
306        std::fs::write(legacy.join("stats.json"), "{}").unwrap();
307
308        assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
309    }
310
311    #[test]
312    fn xdg_pinned_install_ignores_stray_mixed_marker() {
313        // GL #623: same protection for a stray data marker that lands in the
314        // mixed `$XDG_CONFIG_HOME/lean-ctx` dir after the install committed.
315        let home = tempfile::tempdir().unwrap();
316        let xdg = tempfile::tempdir().unwrap();
317        crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
318
319        let mixed = xdg.path().join("lean-ctx");
320        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
321
322        assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
323    }
324
325    #[test]
326    fn single_dir_fs_ignores_empty_dirs() {
327        let home = tempfile::tempdir().unwrap();
328        let xdg = tempfile::tempdir().unwrap();
329        std::fs::create_dir_all(home.path().join(".lean-ctx")).unwrap();
330        std::fs::create_dir_all(xdg.path().join("lean-ctx")).unwrap();
331
332        assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
333    }
334
335    #[test]
336    fn single_dir_fs_ignores_non_marker_files() {
337        let home = tempfile::tempdir().unwrap();
338        let xdg = tempfile::tempdir().unwrap();
339        let mixed = xdg.path().join("lean-ctx");
340        std::fs::create_dir_all(&mixed).unwrap();
341        std::fs::write(mixed.join("random.txt"), "x").unwrap();
342
343        assert_eq!(single_dir_override_fs(home.path(), xdg.path()), None);
344    }
345
346    #[test]
347    fn xdg_base_honors_env_then_home_fallback() {
348        let _lock = crate::core::data_dir::test_env_lock();
349        let tmp = tempfile::tempdir().unwrap();
350        crate::test_env::set_var("XDG_CONFIG_HOME", tmp.path());
351        let from_env = xdg_base("XDG_CONFIG_HOME", ".config").unwrap();
352        crate::test_env::remove_var("XDG_CONFIG_HOME");
353        assert_eq!(from_env, tmp.path());
354
355        // Unset var → falls back to $HOME/<home_fallback>.
356        let fallback = xdg_base("LEAN_CTX_NONEXISTENT_XDG_VAR", ".cache").unwrap();
357        assert!(fallback.ends_with(".cache"), "got: {}", fallback.display());
358    }
359
360    #[test]
361    fn single_dir_override_honors_data_dir_env() {
362        let _lock = crate::core::data_dir::test_env_lock();
363        let tmp = tempfile::tempdir().unwrap();
364        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
365        let got = single_dir_override();
366        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
367        assert_eq!(got, Some(tmp.path().to_path_buf()));
368    }
369
370    #[test]
371    fn config_dir_honors_explicit_override() {
372        let _lock = crate::core::data_dir::test_env_lock();
373        let tmp = tempfile::tempdir().unwrap();
374        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", tmp.path());
375        let got = config_dir().unwrap();
376        crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
377        assert_eq!(got, tmp.path());
378    }
379
380    #[test]
381    fn state_and_cache_dirs_honor_explicit_overrides() {
382        let _lock = crate::core::data_dir::test_env_lock();
383        let state = tempfile::tempdir().unwrap();
384        let cache = tempfile::tempdir().unwrap();
385        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
386        crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache.path());
387        let got_state = state_dir().unwrap();
388        let got_cache = cache_dir().unwrap();
389        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
390        crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
391        assert_eq!(got_state, state.path());
392        assert_eq!(got_cache, cache.path());
393    }
394
395    #[test]
396    fn data_dir_matches_lean_ctx_data_dir() {
397        let _guard = crate::core::data_dir::isolated_data_dir();
398        assert_eq!(
399            data_dir().unwrap(),
400            crate::core::data_dir::lean_ctx_data_dir().unwrap()
401        );
402    }
403
404    #[test]
405    fn runtime_dir_honors_xdg_runtime_dir() {
406        let _lock = crate::core::data_dir::test_env_lock();
407        let tmp = tempfile::tempdir().unwrap();
408        crate::test_env::set_var("XDG_RUNTIME_DIR", tmp.path());
409        let got = runtime_dir().unwrap();
410        crate::test_env::remove_var("XDG_RUNTIME_DIR");
411        assert_eq!(got, tmp.path().join("lean-ctx"));
412    }
413}