Skip to main content

lean_ctx/core/
paths.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Typed XDG base-directory resolvers for lean-ctx (GH #408 / GL #602).
4//!
5//! Historically every lean-ctx file joined onto a single [`lean_ctx_data_dir`]
6//! rooted at `$XDG_CONFIG_HOME/lean-ctx`, mixing `config.toml` with 30+ runtime
7//! data files (sessions, vectors, graphs, events, logs, caches). That violates
8//! the XDG Base Directory Spec and makes a read-only config sandbox impossible.
9//!
10//! This module introduces one typed resolver per XDG category so call-sites can
11//! migrate to the correct base over the following phases (GL #603/#604/#606/#607).
12//!
13//! ## Backward compatibility (single-dir mode)
14//!
15//! Existing installs MUST NOT split silently. `single_dir_override` returns
16//! `Some(dir)` when `LEAN_CTX_DATA_DIR` is set or a legacy/mixed install with
17//! data exists; in that case every category resolves to that one directory —
18//! byte-for-byte today's behavior. The real per-category split only applies to
19//! fresh installs (and, later, on-demand via `lean-ctx doctor --fix`).
20//!
21//! ## `data_dir()` and the fresh-install flip
22//!
23//! [`data_dir`] delegates to [`lean_ctx_data_dir`]. Config (`config.toml` +
24//! hooks) and the runtime STATE/CACHE files were migrated onto [`config_dir`],
25//! [`state_dir`] and [`cache_dir`] (GL #603/#604) so that, since GL #606, the
26//! data resolver defaults fresh installs to `$XDG_DATA_HOME/lean-ctx` without
27//! scattering config or state into the data dir. Legacy and pre-split mixed
28//! installs keep resolving every category to their existing single directory.
29//!
30//! Determinism (#498): every resolver is a pure function of environment + HOME;
31//! no timestamps, counters or randomness.
32
33use std::path::{Path, PathBuf};
34
35use super::data_dir::{ensure_dir_permissions, has_data_files, lean_ctx_data_dir};
36
37/// Reads a directory path from `name`, treating empty/whitespace as unset.
38fn env_path(name: &str) -> Option<PathBuf> {
39    std::env::var(name)
40        .ok()
41        .map(|v| v.trim().to_string())
42        .filter(|v| !v.is_empty())
43        .map(PathBuf::from)
44}
45
46/// The per-process directory overrides. Any of them makes this process run on
47/// directories other than the install's default layout.
48const DIR_OVERRIDES: [&str; 4] = [
49    "LEAN_CTX_CONFIG_DIR",
50    "LEAN_CTX_DATA_DIR",
51    "LEAN_CTX_STATE_DIR",
52    "LEAN_CTX_CACHE_DIR",
53];
54
55/// `true` when a directory override is set. Layout healing that moves files in
56/// the *default* locations (`~/.lean-ctx`, the XDG config dir, the layout pin)
57/// must then do nothing: the override applies to this process only, and the
58/// default locations belong to the real install (GH #2007).
59#[must_use]
60pub(crate) fn dir_override_active() -> bool {
61    DIR_OVERRIDES.iter().any(|name| env_path(name).is_some())
62}
63
64/// Resolves an XDG base directory (e.g. `~/.config`), honoring the `env_name`
65/// override and falling back to `$HOME/<home_fallback>`. Returns the base only;
66/// callers append `lean-ctx`.
67pub(crate) fn xdg_base(env_name: &str, home_fallback: &str) -> Result<PathBuf, String> {
68    if let Some(p) = env_path(env_name) {
69        return Ok(p);
70    }
71    dirs::home_dir()
72        .map(|h| h.join(home_fallback))
73        .ok_or_else(|| "Cannot determine home directory".to_string())
74}
75
76/// Pure resolution order for a category: explicit override > single-dir
77/// backward-compat > XDG split default (`<base>/lean-ctx`).
78fn resolve(
79    category_override: Option<PathBuf>,
80    single: Option<PathBuf>,
81    xdg_base_dir: &Path,
82) -> PathBuf {
83    category_override
84        .or(single)
85        .unwrap_or_else(|| xdg_base_dir.join("lean-ctx"))
86}
87
88/// Returns the single directory that ALL categories must collapse onto for
89/// backward compatibility, or `None` for a fresh install that may split.
90///
91/// `Some` when an *explicit* `LEAN_CTX_DATA_DIR` points at a non-standard
92/// location (a deliberate single-dir choice: a custom dir, or `~/.lean-ctx` via
93/// env), or a legacy `~/.lean-ctx` / mixed `$XDG_CONFIG_HOME/lean-ctx` install
94/// already holds data. An empty directory does NOT trigger single-dir mode
95/// (matches [`lean_ctx_data_dir`] semantics).
96///
97/// A `LEAN_CTX_DATA_DIR` equal to the *standard* XDG data dir
98/// (`$XDG_DATA_HOME/lean-ctx`) is a DATA pin only, NOT a single-dir directive:
99/// editors used to bake that exact value into the MCP server's `env`, and
100/// honoring it as single-dir collapsed config/state/cache onto the data dir for
101/// the MCP process while the terminal CLI kept the XDG split — so the two read
102/// different `config.toml` files (#594). Falling through keeps config in
103/// `$XDG_CONFIG_HOME/lean-ctx` for both; data still resolves to the pin via
104/// [`lean_ctx_data_dir`], which consumes the env var before reaching here.
105pub(crate) fn single_dir_override() -> Option<PathBuf> {
106    if let Some(p) = env_path("LEAN_CTX_DATA_DIR")
107        && !is_standard_xdg_data_dir(&p)
108    {
109        return Some(p);
110    }
111    let home = dirs::home_dir()?;
112    let xdg_config_base = xdg_base("XDG_CONFIG_HOME", ".config").ok()?;
113    let xdg_data_base = xdg_base("XDG_DATA_HOME", ".local/share").ok();
114    single_dir_override_fs(&home, &xdg_config_base, xdg_data_base.as_deref())
115}
116
117/// True when `p` is exactly the standard XDG data dir (`$XDG_DATA_HOME/lean-ctx`,
118/// default `~/.local/share/lean-ctx`). Pure function of env + HOME (no filesystem
119/// access) so category resolution stays deterministic (#498).
120fn is_standard_xdg_data_dir(p: &Path) -> bool {
121    xdg_base("XDG_DATA_HOME", ".local/share")
122        .map(|base| base.join("lean-ctx"))
123        .is_ok_and(|standard| standard.as_path() == p)
124}
125
126/// Whether an editor-baked `LEAN_CTX_DATA_DIR` value (`pin`) would make that
127/// editor's MCP server resolve a *different* `config.toml` than the terminal
128/// CLI. Only a **non-standard** pin collapses config/state/cache onto the data
129/// dir (#594); a pin equal to the standard `$XDG_DATA_HOME/lean-ctx` is a
130/// data-only pin and keeps config parity. Pure function of `pin` + the current
131/// process' env/HOME (the terminal's view), so `doctor` can compare an editor's
132/// baked pin against the CLI's own resolution without mutating env or spawning.
133pub fn data_pin_diverges_config(pin: &Path) -> bool {
134    !is_standard_xdg_data_dir(pin)
135}
136
137/// Filesystem half of [`single_dir_override`], parameterized for hermetic tests.
138pub(crate) fn single_dir_override_fs(
139    home: &Path,
140    xdg_config_base: &Path,
141    xdg_data_base: Option<&Path>,
142) -> Option<PathBuf> {
143    // A committed XDG install is the single source of truth: never re-collapse
144    // it onto a stray legacy/mixed data marker (GL #623). The pin lives next to
145    // the mixed probe below, so the two reads always agree on `xdg_config_base`.
146    if crate::core::layout_pin::is_xdg_pinned_in(xdg_config_base) {
147        return None;
148    }
149    let legacy = home.join(".lean-ctx");
150    if legacy.exists() && has_data_files(&legacy) {
151        return Some(legacy);
152    }
153    let mixed = xdg_config_base.join("lean-ctx");
154    if mixed.exists() && has_data_files(&mixed) {
155        // GL #623 follow-up: an already-split XDG install (real data under
156        // `$XDG_DATA_HOME/lean-ctx`) must NOT re-collapse config/state/cache/data
157        // onto the *config* dir just because a stray data marker leaked there
158        // (e.g. during an upgrade, before the layout pin is written). That would
159        // scatter stats/events/journal/sessions into `$XDG_CONFIG_HOME` and split
160        // history across two trees.
161        if let Some(data_base) = xdg_data_base
162            && has_data_files(&data_base.join("lean-ctx"))
163        {
164            return None;
165        }
166        return Some(mixed);
167    }
168    None
169}
170
171/// Shared resolver for the config/state/cache categories.
172fn category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
173    let dir = resolve_category_dir(cat_env, xdg_env, home_fallback)?;
174    ensure_dir_permissions(&dir);
175    Ok(dir)
176}
177
178/// The same category identity, without creating or changing the resolved directory.
179// Under `#[cfg(test)]` the body always succeeds (returns the sandbox); the
180// fallible XDG resolution only runs in real builds.
181#[cfg_attr(test, allow(clippy::unnecessary_wraps))]
182fn resolve_category_dir(
183    cat_env: &str,
184    xdg_env: &str,
185    home_fallback: &str,
186) -> Result<PathBuf, String> {
187    let category_override = env_path(cat_env);
188
189    // A category override always wins, even under #[cfg(test)] — this lets the
190    // RO-config sandbox integration test point each category at a temp dir.
191    if super::data_dir::sandboxed() {
192        if let Some(p) = category_override {
193            return Ok(p);
194        }
195        // Unit tests share one per-process sandbox so stray store writes can't
196        // escape to a developer's real dirs. The branch logic itself is covered
197        // by the pure `resolve` / `single_dir_override_fs` tests below.
198        return Ok(super::data_dir::test_sandbox_dir());
199    }
200    let base = xdg_base(xdg_env, home_fallback)?;
201    Ok(resolve(category_override, single_dir_override(), &base))
202}
203
204/// Config directory — `config.toml`, shell hooks, `env.sh`. RO-safe.
205/// Override: `LEAN_CTX_CONFIG_DIR`; default `$XDG_CONFIG_HOME/lean-ctx`.
206pub fn config_dir() -> Result<PathBuf, String> {
207    category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
208}
209
210/// Resolve CONFIG without creating directories or repairing permissions.
211pub fn config_dir_read_only() -> Result<PathBuf, String> {
212    resolve_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
213}
214
215/// Resolve a member (a file or sub-directory) of the config dir, adopting a copy
216/// that older builds wrote under the OS-native `dirs::config_dir()` location.
217///
218/// Before #594 (A3), providers/personas/plugins/multi-repo config resolved via
219/// `dirs::config_dir()` — on macOS `~/Library/Application Support`, a *different*
220/// base than the `$XDG_CONFIG_HOME/lean-ctx` dir used for `config.toml`, so those
221/// features silently diverged from the main config. Routing them through
222/// [`config_dir`] unifies the base; this helper performs a one-time,
223/// non-destructive adoption so a user who already had config at the old path
224/// keeps it. The canonical location always wins — an existing canonical entry is
225/// never overwritten — and the legacy entry is copied, never moved, so the
226/// user's default config dir always keeps its own copy.
227///
228/// Adoption is skipped entirely in tests and whenever a `LEAN_CTX_*_DIR`
229/// override is active: an override (a sandbox, a temp dir, a test harness) must
230/// never pull files out of the real install's default config dir.
231pub fn config_dir_member(sub: &str) -> Result<PathBuf, String> {
232    let canonical = config_dir()?.join(sub);
233    if legacy_adoption_allowed(super::data_dir::sandboxed(), dir_override_active())
234        && let Some(legacy_base) = dirs::config_dir()
235    {
236        adopt_legacy_config_member(&legacy_base.join("lean-ctx").join(sub), &canonical);
237    }
238    Ok(canonical)
239}
240
241/// Legacy config adoption only runs for the real install: never in a test
242/// sandbox and never while a directory override points this process elsewhere.
243fn legacy_adoption_allowed(sandboxed: bool, override_active: bool) -> bool {
244    !sandboxed && !override_active
245}
246
247/// Decide whether a legacy config member should be adopted: only when the
248/// canonical entry is still absent, the legacy entry actually exists, and the two
249/// paths genuinely differ (on Linux the two bases coincide, making this a no-op).
250fn legacy_adoption_source(legacy: &Path, canonical: &Path) -> Option<PathBuf> {
251    if canonical.exists() || legacy == canonical || !legacy.exists() {
252        return None;
253    }
254    Some(legacy.to_path_buf())
255}
256
257/// Recursively copy `src` (file or directory) onto `dst`. The source is never
258/// modified; nested directory symlinks are not followed. The caller guarantees
259/// `dst`'s parent exists.
260fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
261    if src.is_dir() {
262        std::fs::create_dir_all(dst)?;
263        for entry in std::fs::read_dir(src)? {
264            let entry = entry?;
265            let child = entry.path();
266            if entry.file_type()?.is_dir() {
267                copy_tree(&child, &dst.join(entry.file_name()))?;
268            } else {
269                std::fs::copy(&child, dst.join(entry.file_name()))?;
270            }
271        }
272    } else {
273        std::fs::copy(src, dst)?;
274    }
275    Ok(())
276}
277
278fn remove_entry(path: &Path) -> std::io::Result<()> {
279    if path.is_dir() {
280        std::fs::remove_dir_all(path)
281    } else {
282        std::fs::remove_file(path)
283    }
284}
285
286/// Copy `src` onto `dst` without ever touching `src`. The copy is staged in a
287/// sibling and renamed into place, so a failed or partial copy never leaves a
288/// half-written canonical entry that would then win over the intact legacy one.
289fn adopt_copy(src: &Path, dst: &Path) -> std::io::Result<()> {
290    let name = dst
291        .file_name()
292        .map(|n| n.to_string_lossy().into_owned())
293        .unwrap_or_default();
294    let staging = dst.with_file_name(format!(".{name}.adopt-{}", std::process::id()));
295    if staging.exists() {
296        remove_entry(&staging)?;
297    }
298    let result = copy_tree(src, &staging).and_then(|()| std::fs::rename(&staging, dst));
299    if result.is_err() && staging.exists() {
300        let _ = remove_entry(&staging);
301    }
302    result
303}
304
305/// A free sibling name for a retired legacy entry: `<name>.adopted`, or
306/// `<name>.adopted-<n>` when an earlier adoption already left one behind.
307fn adopted_name(legacy: &Path) -> Option<PathBuf> {
308    let name = legacy.file_name()?.to_string_lossy().into_owned();
309    (1..=100)
310        .map(|n| {
311            let suffix = if n == 1 {
312                ".adopted".to_string()
313            } else {
314                format!(".adopted-{n}")
315            };
316            legacy.with_file_name(format!("{name}{suffix}"))
317        })
318        .find(|candidate| !candidate.exists() && candidate.symlink_metadata().is_err())
319}
320
321/// One-time adoption of a legacy `dirs::config_dir()/lean-ctx/<sub>` member into
322/// the canonical config dir. Best-effort and lossless: the legacy entry is
323/// copied, and only after the copy is in place is the original retired to
324/// `<name>.adopted` (kept, never deleted), so no live stale duplicate can win
325/// over the adopted copy later (e.g. after a `doctor --fix` split). Any IO
326/// failure leaves the legacy entry untouched.
327fn adopt_legacy_config_member(legacy: &Path, canonical: &Path) {
328    let Some(src) = legacy_adoption_source(legacy, canonical) else {
329        return;
330    };
331    if let Some(parent) = canonical.parent()
332        && std::fs::create_dir_all(parent).is_err()
333    {
334        return;
335    }
336    if adopt_copy(&src, canonical).is_err() {
337        return;
338    }
339    if let Some(retired) = adopted_name(&src) {
340        let _ = std::fs::rename(&src, retired);
341    }
342}
343
344/// Data directory — sessions, vectors, graphs, knowledge, archives, memory.
345///
346/// Delegates to [`lean_ctx_data_dir`], which since GL #606 defaults fresh
347/// installs to `$XDG_DATA_HOME/lean-ctx`. Legacy `~/.lean-ctx` and pre-split
348/// mixed `$XDG_CONFIG_HOME/lean-ctx` installs (and an explicit
349/// `LEAN_CTX_DATA_DIR`) continue to resolve in place for backward compatibility.
350pub fn data_dir() -> Result<PathBuf, String> {
351    lean_ctx_data_dir()
352}
353
354/// State directory — events, stats, logs, journals, ledgers, captured keys.
355/// Override: `LEAN_CTX_STATE_DIR`; default `$XDG_STATE_HOME/lean-ctx`.
356pub fn state_dir() -> Result<PathBuf, String> {
357    category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
358}
359
360/// Resolve STATE without creating directories or repairing permissions.
361pub fn state_dir_read_only() -> Result<PathBuf, String> {
362    resolve_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
363}
364
365/// Cache directory — semantic cache, models, learned patterns. tmpfs-safe.
366/// Override: `LEAN_CTX_CACHE_DIR`; default `$XDG_CACHE_HOME/lean-ctx`.
367pub fn cache_dir() -> Result<PathBuf, String> {
368    category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
369}
370
371/// Resolve CACHE without creating directories or repairing permissions.
372pub(crate) fn cache_dir_read_only() -> Result<PathBuf, String> {
373    resolve_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
374}
375
376/// Runtime directory — `daemon.pid`, `daemon.sock`. `$XDG_RUNTIME_DIR/lean-ctx`.
377///
378/// When `XDG_RUNTIME_DIR` is unset (common on macOS), falls back to
379/// [`state_dir`] so runtime files stay in a private, writable, non-config path
380/// rather than a world-readable temp location.
381pub fn runtime_dir() -> Result<PathBuf, String> {
382    if let Some(base) = env_path("XDG_RUNTIME_DIR") {
383        return Ok(base.join("lean-ctx"));
384    }
385    state_dir()
386}
387
388/// Raw per-category target dir for the four XDG categories, **bypassing**
389/// single-dir back-compat and the test sandbox. Honors an explicit
390/// `LEAN_CTX_<CAT>_DIR` override, otherwise `<XDG base>/lean-ctx`.
391///
392/// `category_dir`/[`data_dir`] deliberately collapse onto one directory for a
393/// legacy/mixed install; the `doctor --fix` migration (GH #408) needs to know
394/// where each category SHOULD live *after* a split, which is what this returns.
395fn raw_category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
396    if let Some(p) = env_path(cat_env) {
397        return Ok(p);
398    }
399    Ok(xdg_base(xdg_env, home_fallback)?.join("lean-ctx"))
400}
401
402/// Split target for the config category (`$XDG_CONFIG_HOME/lean-ctx`).
403pub(crate) fn config_split_target() -> Result<PathBuf, String> {
404    raw_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
405}
406
407/// `$XDG_CONFIG_HOME/lean-ctx` (or `~/.config/lean-ctx`) — where `config.toml`
408/// and the layout pin (`layout.toml`) live. Resolved through the XDG config base
409/// only, bypassing single-dir collapse, so the pin that governs that collapse
410/// never depends on it (GL #623). `None` only when HOME cannot be determined.
411pub(crate) fn xdg_config_lean_ctx_dir() -> Option<PathBuf> {
412    xdg_base("XDG_CONFIG_HOME", ".config")
413        .ok()
414        .map(|b| b.join("lean-ctx"))
415}
416
417/// Split target for the data category (`$XDG_DATA_HOME/lean-ctx`).
418pub(crate) fn data_split_target() -> Result<PathBuf, String> {
419    raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
420}
421
422/// Resolve DATA without creating directories or repairing permissions.
423pub fn data_dir_read_only() -> Result<PathBuf, String> {
424    raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
425}
426
427/// Split target for the state category (`$XDG_STATE_HOME/lean-ctx`).
428pub(crate) fn state_split_target() -> Result<PathBuf, String> {
429    raw_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
430}
431
432/// Split target for the cache category (`$XDG_CACHE_HOME/lean-ctx`).
433pub(crate) fn cache_split_target() -> Result<PathBuf, String> {
434    raw_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn resolve_prefers_override_then_single_then_xdg() {
443        let over = PathBuf::from("/over/ride");
444        let single = PathBuf::from("/single/dir");
445        let base = PathBuf::from("/xdg/base");
446
447        assert_eq!(
448            resolve(Some(over.clone()), Some(single.clone()), &base),
449            over
450        );
451        assert_eq!(resolve(None, Some(single.clone()), &base), single);
452        assert_eq!(
453            resolve(None, None, &base),
454            PathBuf::from("/xdg/base/lean-ctx")
455        );
456    }
457
458    #[test]
459    fn single_dir_fs_detects_legacy_with_data() {
460        let home = tempfile::tempdir().unwrap();
461        let xdg = tempfile::tempdir().unwrap();
462        let legacy = home.path().join(".lean-ctx");
463        std::fs::create_dir_all(&legacy).unwrap();
464        std::fs::write(legacy.join("stats.json"), "{}").unwrap();
465
466        assert_eq!(
467            single_dir_override_fs(home.path(), xdg.path(), None),
468            Some(legacy)
469        );
470    }
471
472    #[test]
473    fn single_dir_fs_detects_mixed_with_data() {
474        let home = tempfile::tempdir().unwrap();
475        let xdg = tempfile::tempdir().unwrap();
476        let mixed = xdg.path().join("lean-ctx");
477        std::fs::create_dir_all(&mixed).unwrap();
478        // A real data marker (stats.json) — NOT config.toml, which post-split
479        // lives alone in the config dir and must not trigger single-dir mode.
480        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
481
482        assert_eq!(
483            single_dir_override_fs(home.path(), xdg.path(), None),
484            Some(mixed)
485        );
486    }
487
488    #[test]
489    fn single_dir_fs_ignores_config_only_dir() {
490        // GH #408: a clean post-split config dir (only config.toml + hooks) must
491        // NOT collapse the four-dir layout.
492        let home = tempfile::tempdir().unwrap();
493        let xdg = tempfile::tempdir().unwrap();
494        let mixed = xdg.path().join("lean-ctx");
495        std::fs::create_dir_all(&mixed).unwrap();
496        std::fs::write(mixed.join("config.toml"), "").unwrap();
497        std::fs::write(mixed.join("shell-hook.zsh"), "").unwrap();
498
499        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
500    }
501
502    #[test]
503    fn single_dir_fs_prefers_legacy_over_mixed() {
504        let home = tempfile::tempdir().unwrap();
505        let xdg = tempfile::tempdir().unwrap();
506        let legacy = home.path().join(".lean-ctx");
507        std::fs::create_dir_all(&legacy).unwrap();
508        std::fs::write(legacy.join("sessions"), "x").unwrap();
509        let mixed = xdg.path().join("lean-ctx");
510        std::fs::create_dir_all(&mixed).unwrap();
511        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
512
513        assert_eq!(
514            single_dir_override_fs(home.path(), xdg.path(), None),
515            Some(legacy)
516        );
517    }
518
519    #[test]
520    fn xdg_pinned_install_ignores_stray_legacy_marker() {
521        // GL #623: once committed to XDG (pin in the config dir), a stray
522        // `~/.lean-ctx/stats.json` (legacy residue, restored backup, concurrent
523        // old binary) must NOT re-collapse the layout onto the legacy dir.
524        let home = tempfile::tempdir().unwrap();
525        let xdg = tempfile::tempdir().unwrap();
526        crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
527
528        let legacy = home.path().join(".lean-ctx");
529        std::fs::create_dir_all(&legacy).unwrap();
530        std::fs::write(legacy.join("stats.json"), "{}").unwrap();
531
532        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
533    }
534
535    #[test]
536    fn xdg_pinned_install_ignores_stray_mixed_marker() {
537        // GL #623: same protection for a stray data marker that lands in the
538        // mixed `$XDG_CONFIG_HOME/lean-ctx` dir after the install committed.
539        let home = tempfile::tempdir().unwrap();
540        let xdg = tempfile::tempdir().unwrap();
541        crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
542
543        let mixed = xdg.path().join("lean-ctx");
544        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
545
546        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
547    }
548
549    #[test]
550    fn split_install_ignores_stray_mixed_marker_when_xdg_data_present() {
551        // Regression: an XDG-split install with real data under
552        // `$XDG_DATA_HOME/lean-ctx` must not re-collapse onto the config dir just
553        // because a stray marker leaked into `$XDG_CONFIG_HOME/lean-ctx` before
554        // the layout pin was written (the upgrade window). Without the guard this
555        // scattered stats/events/journal/sessions into the config dir and split
556        // history across two trees.
557        let home = tempfile::tempdir().unwrap();
558        let xdg_config = tempfile::tempdir().unwrap();
559        let xdg_data = tempfile::tempdir().unwrap();
560
561        let mixed = xdg_config.path().join("lean-ctx");
562        std::fs::create_dir_all(&mixed).unwrap();
563        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
564
565        let data = xdg_data.path().join("lean-ctx");
566        std::fs::create_dir_all(&data).unwrap();
567        std::fs::write(data.join("stats.json"), "{}").unwrap();
568
569        assert_eq!(
570            single_dir_override_fs(home.path(), xdg_config.path(), Some(xdg_data.path())),
571            None,
572            "a populated XDG data dir must keep the config dir from collapsing"
573        );
574
575        // With no real data dir the stray marker still collapses (legacy
576        // single-dir semantics preserved).
577        let empty_data = tempfile::tempdir().unwrap();
578        assert_eq!(
579            single_dir_override_fs(home.path(), xdg_config.path(), Some(empty_data.path())),
580            Some(mixed)
581        );
582    }
583
584    #[test]
585    fn single_dir_fs_ignores_empty_dirs() {
586        let home = tempfile::tempdir().unwrap();
587        let xdg = tempfile::tempdir().unwrap();
588        std::fs::create_dir_all(home.path().join(".lean-ctx")).unwrap();
589        std::fs::create_dir_all(xdg.path().join("lean-ctx")).unwrap();
590
591        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
592    }
593
594    #[test]
595    fn single_dir_fs_ignores_non_marker_files() {
596        let home = tempfile::tempdir().unwrap();
597        let xdg = tempfile::tempdir().unwrap();
598        let mixed = xdg.path().join("lean-ctx");
599        std::fs::create_dir_all(&mixed).unwrap();
600        std::fs::write(mixed.join("random.txt"), "x").unwrap();
601
602        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
603    }
604
605    #[test]
606    fn xdg_base_honors_env_then_home_fallback() {
607        let _lock = crate::core::data_dir::test_env_lock();
608        let tmp = tempfile::tempdir().unwrap();
609        crate::test_env::set_var("XDG_CONFIG_HOME", tmp.path());
610        let from_env = xdg_base("XDG_CONFIG_HOME", ".config").unwrap();
611        crate::test_env::remove_var("XDG_CONFIG_HOME");
612        assert_eq!(from_env, tmp.path());
613
614        // Unset var → falls back to $HOME/<home_fallback>.
615        let fallback = xdg_base("LEAN_CTX_NONEXISTENT_XDG_VAR", ".cache").unwrap();
616        assert!(fallback.ends_with(".cache"), "got: {}", fallback.display());
617    }
618
619    #[test]
620    fn legacy_adoption_source_only_when_canonical_absent() {
621        let tmp = tempfile::tempdir().unwrap();
622        let legacy = tmp.path().join("legacy");
623        let canonical = tmp.path().join("canonical");
624
625        // Legacy missing → nothing to adopt.
626        assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
627
628        // Legacy present, canonical absent → adopt the legacy copy.
629        std::fs::create_dir_all(&legacy).unwrap();
630        assert_eq!(
631            legacy_adoption_source(&legacy, &canonical),
632            Some(legacy.clone())
633        );
634
635        // Canonical present → the newer location wins, never overwrite it.
636        std::fs::create_dir_all(&canonical).unwrap();
637        assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
638    }
639
640    #[test]
641    fn legacy_adoption_never_runs_under_an_override_or_sandbox() {
642        assert!(legacy_adoption_allowed(false, false));
643        assert!(!legacy_adoption_allowed(false, true), "dir override");
644        assert!(!legacy_adoption_allowed(true, false), "test sandbox");
645        assert!(!legacy_adoption_allowed(true, true));
646    }
647
648    #[test]
649    fn adoption_copies_then_retires_the_original_as_adopted() {
650        let tmp = tempfile::tempdir().unwrap();
651
652        // File: adoption creates the canonical parent itself.
653        let src_file = tmp.path().join("legacy/providers.toml");
654        std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
655        std::fs::write(&src_file, "id = \"x\"\n").unwrap();
656        let dst_file = tmp.path().join("config/lean-ctx/providers.toml");
657        adopt_legacy_config_member(&src_file, &dst_file);
658        assert!(!src_file.exists(), "no live stale duplicate stays behind");
659        assert_eq!(
660            std::fs::read_to_string(tmp.path().join("legacy/providers.toml.adopted")).unwrap(),
661            "id = \"x\"\n",
662            "the original is kept as <name>.adopted"
663        );
664        assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"x\"\n");
665
666        // Directory with nested content.
667        let src_dir = tmp.path().join("legacy/personas");
668        std::fs::create_dir_all(src_dir.join("nested")).unwrap();
669        std::fs::write(src_dir.join("a.toml"), "a").unwrap();
670        std::fs::write(src_dir.join("nested/b.toml"), "b").unwrap();
671        let dst_dir = tmp.path().join("config/lean-ctx/personas");
672        adopt_legacy_config_member(&src_dir, &dst_dir);
673        let retired_dir = tmp.path().join("legacy/personas.adopted");
674        assert!(!src_dir.exists());
675        for (rel, body) in [("a.toml", "a"), ("nested/b.toml", "b")] {
676            assert_eq!(
677                std::fs::read_to_string(retired_dir.join(rel)).unwrap(),
678                body
679            );
680            assert_eq!(std::fs::read_to_string(dst_dir.join(rel)).unwrap(), body);
681        }
682
683        // A second legacy copy never overwrites an earlier retired one.
684        std::fs::remove_file(&dst_file).unwrap();
685        std::fs::write(&src_file, "id = \"y\"\n").unwrap();
686        adopt_legacy_config_member(&src_file, &dst_file);
687        assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"y\"\n");
688        assert_eq!(
689            std::fs::read_to_string(tmp.path().join("legacy/providers.toml.adopted")).unwrap(),
690            "id = \"x\"\n"
691        );
692        assert_eq!(
693            std::fs::read_to_string(tmp.path().join("legacy/providers.toml.adopted-2")).unwrap(),
694            "id = \"y\"\n"
695        );
696        std::fs::remove_file(&dst_file).unwrap();
697        std::fs::write(&dst_file, "id = \"x\"\n").unwrap();
698
699        // No staging leftovers next to the canonical entries.
700        let names: Vec<String> = std::fs::read_dir(tmp.path().join("config/lean-ctx"))
701            .unwrap()
702            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
703            .collect();
704        assert!(
705            names.iter().all(|n| !n.contains(".adopt-")),
706            "staging left behind: {names:?}"
707        );
708
709        // An existing canonical entry wins and is never overwritten.
710        std::fs::write(&src_file, "id = \"changed\"\n").unwrap();
711        adopt_legacy_config_member(&src_file, &dst_file);
712        assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"x\"\n");
713    }
714
715    #[test]
716    fn single_dir_override_honors_data_dir_env() {
717        let _lock = crate::core::data_dir::test_env_lock();
718        let tmp = tempfile::tempdir().unwrap();
719        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
720        let got = single_dir_override();
721        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
722        // A custom (non-standard) data dir is a deliberate single-dir choice.
723        assert_eq!(got, Some(tmp.path().to_path_buf()));
724    }
725
726    #[test]
727    fn is_standard_xdg_data_dir_matches_xdg_data_home() {
728        let _lock = crate::core::data_dir::test_env_lock();
729        let data_home = tempfile::tempdir().unwrap();
730        crate::test_env::set_var("XDG_DATA_HOME", data_home.path());
731        let is_std = is_standard_xdg_data_dir(&data_home.path().join("lean-ctx"));
732        let is_custom = is_standard_xdg_data_dir(Path::new("/some/custom/lean-ctx"));
733        crate::test_env::remove_var("XDG_DATA_HOME");
734        assert!(is_std, "$XDG_DATA_HOME/lean-ctx is the standard data dir");
735        assert!(!is_custom, "a custom path is not the standard data dir");
736    }
737
738    #[test]
739    fn standard_data_pin_does_not_collapse_categories() {
740        // #594: an editor (MCP env) that pins LEAN_CTX_DATA_DIR to the *standard*
741        // XDG data dir must NOT drag config/state/cache along — single_dir_override
742        // must return None so they keep their own XDG bases, matching the CLI.
743        let _lock = crate::core::data_dir::test_env_lock();
744        let home = tempfile::tempdir().unwrap();
745        let xdg_config = tempfile::tempdir().unwrap();
746        let xdg_data = tempfile::tempdir().unwrap();
747        let data_pin = xdg_data.path().join("lean-ctx");
748        crate::test_env::set_var("HOME", home.path());
749        crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
750        crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
751        crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data_pin);
752
753        let got = single_dir_override();
754
755        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
756        crate::test_env::remove_var("XDG_DATA_HOME");
757        crate::test_env::remove_var("XDG_CONFIG_HOME");
758        crate::test_env::remove_var("HOME");
759
760        assert_eq!(got, None);
761    }
762
763    #[test]
764    fn config_dir_honors_explicit_override() {
765        let _lock = crate::core::data_dir::test_env_lock();
766        let tmp = tempfile::tempdir().unwrap();
767        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", tmp.path());
768        let got = config_dir().unwrap();
769        crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
770        assert_eq!(got, tmp.path());
771    }
772
773    #[test]
774    fn state_and_cache_dirs_honor_explicit_overrides() {
775        let _lock = crate::core::data_dir::test_env_lock();
776        let state = tempfile::tempdir().unwrap();
777        let cache = tempfile::tempdir().unwrap();
778        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
779        crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache.path());
780        let got_state = state_dir().unwrap();
781        let got_cache = cache_dir().unwrap();
782        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
783        crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
784        assert_eq!(got_state, state.path());
785        assert_eq!(got_cache, cache.path());
786    }
787
788    #[test]
789    fn data_dir_matches_lean_ctx_data_dir() {
790        let _guard = crate::core::data_dir::isolated_data_dir();
791        assert_eq!(
792            data_dir().unwrap(),
793            crate::core::data_dir::lean_ctx_data_dir().unwrap()
794        );
795    }
796
797    #[test]
798    fn runtime_dir_honors_xdg_runtime_dir() {
799        let _lock = crate::core::data_dir::test_env_lock();
800        let tmp = tempfile::tempdir().unwrap();
801        crate::test_env::set_var("XDG_RUNTIME_DIR", tmp.path());
802        let got = runtime_dir().unwrap();
803        crate::test_env::remove_var("XDG_RUNTIME_DIR");
804        assert_eq!(got, tmp.path().join("lean-ctx"));
805    }
806}