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 an *explicit* `LEAN_CTX_DATA_DIR` points at a non-standard
72/// location (a deliberate single-dir choice: a custom dir, or `~/.lean-ctx` via
73/// env), or a legacy `~/.lean-ctx` / mixed `$XDG_CONFIG_HOME/lean-ctx` install
74/// already holds data. An empty directory does NOT trigger single-dir mode
75/// (matches [`lean_ctx_data_dir`] semantics).
76///
77/// A `LEAN_CTX_DATA_DIR` equal to the *standard* XDG data dir
78/// (`$XDG_DATA_HOME/lean-ctx`) is a DATA pin only, NOT a single-dir directive:
79/// editors used to bake that exact value into the MCP server's `env`, and
80/// honoring it as single-dir collapsed config/state/cache onto the data dir for
81/// the MCP process while the terminal CLI kept the XDG split — so the two read
82/// different `config.toml` files (#594). Falling through keeps config in
83/// `$XDG_CONFIG_HOME/lean-ctx` for both; data still resolves to the pin via
84/// [`lean_ctx_data_dir`], which consumes the env var before reaching here.
85pub(crate) fn single_dir_override() -> Option<PathBuf> {
86    if let Some(p) = env_path("LEAN_CTX_DATA_DIR")
87        && !is_standard_xdg_data_dir(&p)
88    {
89        return Some(p);
90    }
91    let home = dirs::home_dir()?;
92    let xdg_config_base = xdg_base("XDG_CONFIG_HOME", ".config").ok()?;
93    let xdg_data_base = xdg_base("XDG_DATA_HOME", ".local/share").ok();
94    single_dir_override_fs(&home, &xdg_config_base, xdg_data_base.as_deref())
95}
96
97/// True when `p` is exactly the standard XDG data dir (`$XDG_DATA_HOME/lean-ctx`,
98/// default `~/.local/share/lean-ctx`). Pure function of env + HOME (no filesystem
99/// access) so category resolution stays deterministic (#498).
100fn is_standard_xdg_data_dir(p: &Path) -> bool {
101    xdg_base("XDG_DATA_HOME", ".local/share")
102        .map(|base| base.join("lean-ctx"))
103        .is_ok_and(|standard| standard.as_path() == p)
104}
105
106/// Whether an editor-baked `LEAN_CTX_DATA_DIR` value (`pin`) would make that
107/// editor's MCP server resolve a *different* `config.toml` than the terminal
108/// CLI. Only a **non-standard** pin collapses config/state/cache onto the data
109/// dir (#594); a pin equal to the standard `$XDG_DATA_HOME/lean-ctx` is a
110/// data-only pin and keeps config parity. Pure function of `pin` + the current
111/// process' env/HOME (the terminal's view), so `doctor` can compare an editor's
112/// baked pin against the CLI's own resolution without mutating env or spawning.
113pub(crate) fn data_pin_diverges_config(pin: &Path) -> bool {
114    !is_standard_xdg_data_dir(pin)
115}
116
117/// Filesystem half of [`single_dir_override`], parameterized for hermetic tests.
118fn single_dir_override_fs(
119    home: &Path,
120    xdg_config_base: &Path,
121    xdg_data_base: Option<&Path>,
122) -> Option<PathBuf> {
123    // A committed XDG install is the single source of truth: never re-collapse
124    // it onto a stray legacy/mixed data marker (GL #623). The pin lives next to
125    // the mixed probe below, so the two reads always agree on `xdg_config_base`.
126    if crate::core::layout_pin::is_xdg_pinned_in(xdg_config_base) {
127        return None;
128    }
129    let legacy = home.join(".lean-ctx");
130    if legacy.exists() && has_data_files(&legacy) {
131        return Some(legacy);
132    }
133    let mixed = xdg_config_base.join("lean-ctx");
134    if mixed.exists() && has_data_files(&mixed) {
135        // GL #623 follow-up: an already-split XDG install (real data under
136        // `$XDG_DATA_HOME/lean-ctx`) must NOT re-collapse config/state/cache/data
137        // onto the *config* dir just because a stray data marker leaked there
138        // (e.g. during an upgrade, before the layout pin is written). That would
139        // scatter stats/events/journal/sessions into `$XDG_CONFIG_HOME` and split
140        // history across two trees.
141        if let Some(data_base) = xdg_data_base
142            && has_data_files(&data_base.join("lean-ctx"))
143        {
144            return None;
145        }
146        return Some(mixed);
147    }
148    None
149}
150
151/// Shared resolver for the config/state/cache categories.
152// Under `#[cfg(test)]` the body always succeeds (returns the sandbox); the
153// fallible XDG resolution only runs in real builds.
154#[cfg_attr(test, allow(clippy::unnecessary_wraps))]
155fn category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
156    let category_override = env_path(cat_env);
157
158    // A category override always wins, even under #[cfg(test)] — this lets the
159    // RO-config sandbox integration test point each category at a temp dir.
160    #[cfg(test)]
161    {
162        if let Some(p) = category_override {
163            ensure_dir_permissions(&p);
164            return Ok(p);
165        }
166        // Unit tests share one per-process sandbox so stray store writes can't
167        // escape to a developer's real dirs. The branch logic itself is covered
168        // by the pure `resolve` / `single_dir_override_fs` tests below.
169        let _ = (xdg_env, home_fallback);
170        Ok(super::data_dir::test_sandbox_dir())
171    }
172    #[cfg(not(test))]
173    {
174        let base = xdg_base(xdg_env, home_fallback)?;
175        let dir = resolve(category_override, single_dir_override(), &base);
176        ensure_dir_permissions(&dir);
177        Ok(dir)
178    }
179}
180
181/// Config directory — `config.toml`, shell hooks, `env.sh`. RO-safe.
182/// Override: `LEAN_CTX_CONFIG_DIR`; default `$XDG_CONFIG_HOME/lean-ctx`.
183pub fn config_dir() -> Result<PathBuf, String> {
184    category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
185}
186
187/// Resolve a member (a file or sub-directory) of the config dir, adopting a copy
188/// that older builds wrote under the OS-native `dirs::config_dir()` location.
189///
190/// Before #594 (A3), providers/personas/plugins/multi-repo config resolved via
191/// `dirs::config_dir()` — on macOS `~/Library/Application Support`, a *different*
192/// base than the `$XDG_CONFIG_HOME/lean-ctx` dir used for `config.toml`, so those
193/// features silently diverged from the main config. Routing them through
194/// [`config_dir`] unifies the base; this helper performs a one-time,
195/// non-destructive adoption so a user who already had config at the old path
196/// keeps it. The canonical location always wins — an existing canonical entry is
197/// never overwritten — and adoption is skipped entirely in tests so it can never
198/// touch a developer's real `~/Library/Application Support`.
199pub fn config_dir_member(sub: &str) -> Result<PathBuf, String> {
200    let canonical = config_dir()?.join(sub);
201    #[cfg(not(test))]
202    adopt_legacy_config_member(sub, &canonical);
203    Ok(canonical)
204}
205
206/// Decide whether a legacy config member should be adopted: only when the
207/// canonical entry is still absent, the legacy entry actually exists, and the two
208/// paths genuinely differ (on Linux the two bases coincide, making this a no-op).
209fn legacy_adoption_source(legacy: &Path, canonical: &Path) -> Option<PathBuf> {
210    if canonical.exists() || legacy == canonical || !legacy.exists() {
211        return None;
212    }
213    Some(legacy.to_path_buf())
214}
215
216/// Move `src` onto `dst` (file or directory). Prefers an atomic rename on the
217/// same filesystem; falls back to a recursive copy for the rare cross-device
218/// case so no config is ever left stranded. The caller guarantees `dst`'s parent
219/// exists.
220fn relocate(src: &Path, dst: &Path) -> std::io::Result<()> {
221    if std::fs::rename(src, dst).is_ok() {
222        return Ok(());
223    }
224    if src.is_dir() {
225        std::fs::create_dir_all(dst)?;
226        for entry in std::fs::read_dir(src)? {
227            let entry = entry?;
228            relocate(&entry.path(), &dst.join(entry.file_name()))?;
229        }
230        std::fs::remove_dir_all(src)?;
231    } else {
232        std::fs::copy(src, dst)?;
233        std::fs::remove_file(src)?;
234    }
235    Ok(())
236}
237
238/// One-time adoption of a legacy `dirs::config_dir()/lean-ctx/<sub>` member into
239/// the canonical config dir. Best-effort: any IO failure leaves the legacy copy
240/// in place and resolution simply proceeds against the canonical path.
241#[cfg(not(test))]
242fn adopt_legacy_config_member(sub: &str, canonical: &Path) {
243    let Some(legacy_base) = dirs::config_dir() else {
244        return;
245    };
246    let legacy = legacy_base.join("lean-ctx").join(sub);
247    let Some(src) = legacy_adoption_source(&legacy, canonical) else {
248        return;
249    };
250    if let Some(parent) = canonical.parent()
251        && std::fs::create_dir_all(parent).is_err()
252    {
253        return;
254    }
255    let _ = relocate(&src, canonical);
256}
257
258/// Data directory — sessions, vectors, graphs, knowledge, archives, memory.
259///
260/// Delegates to [`lean_ctx_data_dir`], which since GL #606 defaults fresh
261/// installs to `$XDG_DATA_HOME/lean-ctx`. Legacy `~/.lean-ctx` and pre-split
262/// mixed `$XDG_CONFIG_HOME/lean-ctx` installs (and an explicit
263/// `LEAN_CTX_DATA_DIR`) continue to resolve in place for backward compatibility.
264pub fn data_dir() -> Result<PathBuf, String> {
265    lean_ctx_data_dir()
266}
267
268/// State directory — events, stats, logs, journals, ledgers, captured keys.
269/// Override: `LEAN_CTX_STATE_DIR`; default `$XDG_STATE_HOME/lean-ctx`.
270pub fn state_dir() -> Result<PathBuf, String> {
271    category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
272}
273
274/// Cache directory — semantic cache, models, learned patterns. tmpfs-safe.
275/// Override: `LEAN_CTX_CACHE_DIR`; default `$XDG_CACHE_HOME/lean-ctx`.
276pub fn cache_dir() -> Result<PathBuf, String> {
277    category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
278}
279
280/// Runtime directory — `daemon.pid`, `daemon.sock`. `$XDG_RUNTIME_DIR/lean-ctx`.
281///
282/// When `XDG_RUNTIME_DIR` is unset (common on macOS), falls back to
283/// [`state_dir`] so runtime files stay in a private, writable, non-config path
284/// rather than a world-readable temp location.
285pub fn runtime_dir() -> Result<PathBuf, String> {
286    if let Some(base) = env_path("XDG_RUNTIME_DIR") {
287        return Ok(base.join("lean-ctx"));
288    }
289    state_dir()
290}
291
292/// Raw per-category target dir for the four XDG categories, **bypassing**
293/// single-dir back-compat and the test sandbox. Honors an explicit
294/// `LEAN_CTX_<CAT>_DIR` override, otherwise `<XDG base>/lean-ctx`.
295///
296/// `category_dir`/[`data_dir`] deliberately collapse onto one directory for a
297/// legacy/mixed install; the `doctor --fix` migration (GH #408) needs to know
298/// where each category SHOULD live *after* a split, which is what this returns.
299fn raw_category_dir(cat_env: &str, xdg_env: &str, home_fallback: &str) -> Result<PathBuf, String> {
300    if let Some(p) = env_path(cat_env) {
301        return Ok(p);
302    }
303    Ok(xdg_base(xdg_env, home_fallback)?.join("lean-ctx"))
304}
305
306/// Split target for the config category (`$XDG_CONFIG_HOME/lean-ctx`).
307pub(crate) fn config_split_target() -> Result<PathBuf, String> {
308    raw_category_dir("LEAN_CTX_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")
309}
310
311/// `$XDG_CONFIG_HOME/lean-ctx` (or `~/.config/lean-ctx`) — where `config.toml`
312/// and the layout pin (`layout.toml`) live. Resolved through the XDG config base
313/// only, bypassing single-dir collapse, so the pin that governs that collapse
314/// never depends on it (GL #623). `None` only when HOME cannot be determined.
315pub(crate) fn xdg_config_lean_ctx_dir() -> Option<PathBuf> {
316    xdg_base("XDG_CONFIG_HOME", ".config")
317        .ok()
318        .map(|b| b.join("lean-ctx"))
319}
320
321/// Split target for the data category (`$XDG_DATA_HOME/lean-ctx`).
322pub(crate) fn data_split_target() -> Result<PathBuf, String> {
323    raw_category_dir("LEAN_CTX_DATA_DIR", "XDG_DATA_HOME", ".local/share")
324}
325
326/// Split target for the state category (`$XDG_STATE_HOME/lean-ctx`).
327pub(crate) fn state_split_target() -> Result<PathBuf, String> {
328    raw_category_dir("LEAN_CTX_STATE_DIR", "XDG_STATE_HOME", ".local/state")
329}
330
331/// Split target for the cache category (`$XDG_CACHE_HOME/lean-ctx`).
332pub(crate) fn cache_split_target() -> Result<PathBuf, String> {
333    raw_category_dir("LEAN_CTX_CACHE_DIR", "XDG_CACHE_HOME", ".cache")
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn resolve_prefers_override_then_single_then_xdg() {
342        let over = PathBuf::from("/over/ride");
343        let single = PathBuf::from("/single/dir");
344        let base = PathBuf::from("/xdg/base");
345
346        assert_eq!(
347            resolve(Some(over.clone()), Some(single.clone()), &base),
348            over
349        );
350        assert_eq!(resolve(None, Some(single.clone()), &base), single);
351        assert_eq!(
352            resolve(None, None, &base),
353            PathBuf::from("/xdg/base/lean-ctx")
354        );
355    }
356
357    #[test]
358    fn single_dir_fs_detects_legacy_with_data() {
359        let home = tempfile::tempdir().unwrap();
360        let xdg = tempfile::tempdir().unwrap();
361        let legacy = home.path().join(".lean-ctx");
362        std::fs::create_dir_all(&legacy).unwrap();
363        std::fs::write(legacy.join("stats.json"), "{}").unwrap();
364
365        assert_eq!(
366            single_dir_override_fs(home.path(), xdg.path(), None),
367            Some(legacy)
368        );
369    }
370
371    #[test]
372    fn single_dir_fs_detects_mixed_with_data() {
373        let home = tempfile::tempdir().unwrap();
374        let xdg = tempfile::tempdir().unwrap();
375        let mixed = xdg.path().join("lean-ctx");
376        std::fs::create_dir_all(&mixed).unwrap();
377        // A real data marker (stats.json) — NOT config.toml, which post-split
378        // lives alone in the config dir and must not trigger single-dir mode.
379        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
380
381        assert_eq!(
382            single_dir_override_fs(home.path(), xdg.path(), None),
383            Some(mixed)
384        );
385    }
386
387    #[test]
388    fn single_dir_fs_ignores_config_only_dir() {
389        // GH #408: a clean post-split config dir (only config.toml + hooks) must
390        // NOT collapse the four-dir layout.
391        let home = tempfile::tempdir().unwrap();
392        let xdg = tempfile::tempdir().unwrap();
393        let mixed = xdg.path().join("lean-ctx");
394        std::fs::create_dir_all(&mixed).unwrap();
395        std::fs::write(mixed.join("config.toml"), "").unwrap();
396        std::fs::write(mixed.join("shell-hook.zsh"), "").unwrap();
397
398        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
399    }
400
401    #[test]
402    fn single_dir_fs_prefers_legacy_over_mixed() {
403        let home = tempfile::tempdir().unwrap();
404        let xdg = tempfile::tempdir().unwrap();
405        let legacy = home.path().join(".lean-ctx");
406        std::fs::create_dir_all(&legacy).unwrap();
407        std::fs::write(legacy.join("sessions"), "x").unwrap();
408        let mixed = xdg.path().join("lean-ctx");
409        std::fs::create_dir_all(&mixed).unwrap();
410        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
411
412        assert_eq!(
413            single_dir_override_fs(home.path(), xdg.path(), None),
414            Some(legacy)
415        );
416    }
417
418    #[test]
419    fn xdg_pinned_install_ignores_stray_legacy_marker() {
420        // GL #623: once committed to XDG (pin in the config dir), a stray
421        // `~/.lean-ctx/stats.json` (legacy residue, restored backup, concurrent
422        // old binary) must NOT re-collapse the layout onto the legacy dir.
423        let home = tempfile::tempdir().unwrap();
424        let xdg = tempfile::tempdir().unwrap();
425        crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
426
427        let legacy = home.path().join(".lean-ctx");
428        std::fs::create_dir_all(&legacy).unwrap();
429        std::fs::write(legacy.join("stats.json"), "{}").unwrap();
430
431        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
432    }
433
434    #[test]
435    fn xdg_pinned_install_ignores_stray_mixed_marker() {
436        // GL #623: same protection for a stray data marker that lands in the
437        // mixed `$XDG_CONFIG_HOME/lean-ctx` dir after the install committed.
438        let home = tempfile::tempdir().unwrap();
439        let xdg = tempfile::tempdir().unwrap();
440        crate::core::layout_pin::write_xdg_pin_in(xdg.path()).unwrap();
441
442        let mixed = xdg.path().join("lean-ctx");
443        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
444
445        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
446    }
447
448    #[test]
449    fn split_install_ignores_stray_mixed_marker_when_xdg_data_present() {
450        // Regression: an XDG-split install with real data under
451        // `$XDG_DATA_HOME/lean-ctx` must not re-collapse onto the config dir just
452        // because a stray marker leaked into `$XDG_CONFIG_HOME/lean-ctx` before
453        // the layout pin was written (the upgrade window). Without the guard this
454        // scattered stats/events/journal/sessions into the config dir and split
455        // history across two trees.
456        let home = tempfile::tempdir().unwrap();
457        let xdg_config = tempfile::tempdir().unwrap();
458        let xdg_data = tempfile::tempdir().unwrap();
459
460        let mixed = xdg_config.path().join("lean-ctx");
461        std::fs::create_dir_all(&mixed).unwrap();
462        std::fs::write(mixed.join("stats.json"), "{}").unwrap();
463
464        let data = xdg_data.path().join("lean-ctx");
465        std::fs::create_dir_all(&data).unwrap();
466        std::fs::write(data.join("stats.json"), "{}").unwrap();
467
468        assert_eq!(
469            single_dir_override_fs(home.path(), xdg_config.path(), Some(xdg_data.path())),
470            None,
471            "a populated XDG data dir must keep the config dir from collapsing"
472        );
473
474        // With no real data dir the stray marker still collapses (legacy
475        // single-dir semantics preserved).
476        let empty_data = tempfile::tempdir().unwrap();
477        assert_eq!(
478            single_dir_override_fs(home.path(), xdg_config.path(), Some(empty_data.path())),
479            Some(mixed)
480        );
481    }
482
483    #[test]
484    fn single_dir_fs_ignores_empty_dirs() {
485        let home = tempfile::tempdir().unwrap();
486        let xdg = tempfile::tempdir().unwrap();
487        std::fs::create_dir_all(home.path().join(".lean-ctx")).unwrap();
488        std::fs::create_dir_all(xdg.path().join("lean-ctx")).unwrap();
489
490        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
491    }
492
493    #[test]
494    fn single_dir_fs_ignores_non_marker_files() {
495        let home = tempfile::tempdir().unwrap();
496        let xdg = tempfile::tempdir().unwrap();
497        let mixed = xdg.path().join("lean-ctx");
498        std::fs::create_dir_all(&mixed).unwrap();
499        std::fs::write(mixed.join("random.txt"), "x").unwrap();
500
501        assert_eq!(single_dir_override_fs(home.path(), xdg.path(), None), None);
502    }
503
504    #[test]
505    fn xdg_base_honors_env_then_home_fallback() {
506        let _lock = crate::core::data_dir::test_env_lock();
507        let tmp = tempfile::tempdir().unwrap();
508        crate::test_env::set_var("XDG_CONFIG_HOME", tmp.path());
509        let from_env = xdg_base("XDG_CONFIG_HOME", ".config").unwrap();
510        crate::test_env::remove_var("XDG_CONFIG_HOME");
511        assert_eq!(from_env, tmp.path());
512
513        // Unset var → falls back to $HOME/<home_fallback>.
514        let fallback = xdg_base("LEAN_CTX_NONEXISTENT_XDG_VAR", ".cache").unwrap();
515        assert!(fallback.ends_with(".cache"), "got: {}", fallback.display());
516    }
517
518    #[test]
519    fn legacy_adoption_source_only_when_canonical_absent() {
520        let tmp = tempfile::tempdir().unwrap();
521        let legacy = tmp.path().join("legacy");
522        let canonical = tmp.path().join("canonical");
523
524        // Legacy missing → nothing to adopt.
525        assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
526
527        // Legacy present, canonical absent → adopt the legacy copy.
528        std::fs::create_dir_all(&legacy).unwrap();
529        assert_eq!(
530            legacy_adoption_source(&legacy, &canonical),
531            Some(legacy.clone())
532        );
533
534        // Canonical present → the newer location wins, never overwrite it.
535        std::fs::create_dir_all(&canonical).unwrap();
536        assert_eq!(legacy_adoption_source(&legacy, &canonical), None);
537    }
538
539    #[test]
540    fn relocate_moves_file_then_directory() {
541        let tmp = tempfile::tempdir().unwrap();
542
543        // File: dst parent must be created by the caller (as adopt does).
544        let src_file = tmp.path().join("providers.toml");
545        std::fs::write(&src_file, "id = \"x\"\n").unwrap();
546        let dst_file = tmp.path().join("config/lean-ctx/providers.toml");
547        std::fs::create_dir_all(dst_file.parent().unwrap()).unwrap();
548        relocate(&src_file, &dst_file).unwrap();
549        assert!(!src_file.exists(), "source file must be moved, not copied");
550        assert_eq!(std::fs::read_to_string(&dst_file).unwrap(), "id = \"x\"\n");
551
552        // Directory with nested content.
553        let src_dir = tmp.path().join("personas");
554        std::fs::create_dir_all(src_dir.join("nested")).unwrap();
555        std::fs::write(src_dir.join("a.toml"), "a").unwrap();
556        std::fs::write(src_dir.join("nested/b.toml"), "b").unwrap();
557        let dst_dir = tmp.path().join("config/lean-ctx/personas");
558        std::fs::create_dir_all(dst_dir.parent().unwrap()).unwrap();
559        relocate(&src_dir, &dst_dir).unwrap();
560        assert!(!src_dir.exists(), "source dir must be moved");
561        assert_eq!(
562            std::fs::read_to_string(dst_dir.join("a.toml")).unwrap(),
563            "a"
564        );
565        assert_eq!(
566            std::fs::read_to_string(dst_dir.join("nested/b.toml")).unwrap(),
567            "b"
568        );
569    }
570
571    #[test]
572    fn single_dir_override_honors_data_dir_env() {
573        let _lock = crate::core::data_dir::test_env_lock();
574        let tmp = tempfile::tempdir().unwrap();
575        crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
576        let got = single_dir_override();
577        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
578        // A custom (non-standard) data dir is a deliberate single-dir choice.
579        assert_eq!(got, Some(tmp.path().to_path_buf()));
580    }
581
582    #[test]
583    fn is_standard_xdg_data_dir_matches_xdg_data_home() {
584        let _lock = crate::core::data_dir::test_env_lock();
585        let data_home = tempfile::tempdir().unwrap();
586        crate::test_env::set_var("XDG_DATA_HOME", data_home.path());
587        let is_std = is_standard_xdg_data_dir(&data_home.path().join("lean-ctx"));
588        let is_custom = is_standard_xdg_data_dir(Path::new("/some/custom/lean-ctx"));
589        crate::test_env::remove_var("XDG_DATA_HOME");
590        assert!(is_std, "$XDG_DATA_HOME/lean-ctx is the standard data dir");
591        assert!(!is_custom, "a custom path is not the standard data dir");
592    }
593
594    #[test]
595    fn standard_data_pin_does_not_collapse_categories() {
596        // #594: an editor (MCP env) that pins LEAN_CTX_DATA_DIR to the *standard*
597        // XDG data dir must NOT drag config/state/cache along — single_dir_override
598        // must return None so they keep their own XDG bases, matching the CLI.
599        let _lock = crate::core::data_dir::test_env_lock();
600        let home = tempfile::tempdir().unwrap();
601        let xdg_config = tempfile::tempdir().unwrap();
602        let xdg_data = tempfile::tempdir().unwrap();
603        let data_pin = xdg_data.path().join("lean-ctx");
604        crate::test_env::set_var("HOME", home.path());
605        crate::test_env::set_var("XDG_CONFIG_HOME", xdg_config.path());
606        crate::test_env::set_var("XDG_DATA_HOME", xdg_data.path());
607        crate::test_env::set_var("LEAN_CTX_DATA_DIR", &data_pin);
608
609        let got = single_dir_override();
610
611        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
612        crate::test_env::remove_var("XDG_DATA_HOME");
613        crate::test_env::remove_var("XDG_CONFIG_HOME");
614        crate::test_env::remove_var("HOME");
615
616        assert_eq!(got, None);
617    }
618
619    #[test]
620    fn config_dir_honors_explicit_override() {
621        let _lock = crate::core::data_dir::test_env_lock();
622        let tmp = tempfile::tempdir().unwrap();
623        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", tmp.path());
624        let got = config_dir().unwrap();
625        crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
626        assert_eq!(got, tmp.path());
627    }
628
629    #[test]
630    fn state_and_cache_dirs_honor_explicit_overrides() {
631        let _lock = crate::core::data_dir::test_env_lock();
632        let state = tempfile::tempdir().unwrap();
633        let cache = tempfile::tempdir().unwrap();
634        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
635        crate::test_env::set_var("LEAN_CTX_CACHE_DIR", cache.path());
636        let got_state = state_dir().unwrap();
637        let got_cache = cache_dir().unwrap();
638        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
639        crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
640        assert_eq!(got_state, state.path());
641        assert_eq!(got_cache, cache.path());
642    }
643
644    #[test]
645    fn data_dir_matches_lean_ctx_data_dir() {
646        let _guard = crate::core::data_dir::isolated_data_dir();
647        assert_eq!(
648            data_dir().unwrap(),
649            crate::core::data_dir::lean_ctx_data_dir().unwrap()
650        );
651    }
652
653    #[test]
654    fn runtime_dir_honors_xdg_runtime_dir() {
655        let _lock = crate::core::data_dir::test_env_lock();
656        let tmp = tempfile::tempdir().unwrap();
657        crate::test_env::set_var("XDG_RUNTIME_DIR", tmp.path());
658        let got = runtime_dir().unwrap();
659        crate::test_env::remove_var("XDG_RUNTIME_DIR");
660        assert_eq!(got, tmp.path().join("lean-ctx"));
661    }
662}