Skip to main content

lean_ctx/core/
layout_pin.rs

1//! XDG layout commitment pin (GL #623 / #624).
2//!
3//! `paths::single_dir_override` re-derives which directory is canonical purely
4//! from on-disk markers on *every* operation. Without a commitment signal a
5//! single stray `~/.lean-ctx/<marker>` (a legacy residue, a restored backup, a
6//! concurrent older binary, a half-finished migration) permanently re-collapses
7//! config/data/state/cache back onto that one directory — exactly the "XDG
8//! layout not stable" report (GL #623): config stops being found and the graph
9//! disappears from the dashboard.
10//!
11//! The pin is a tiny `layout.toml` in the **config** dir
12//! (`$XDG_CONFIG_HOME/lean-ctx/layout.toml`). It is resolved through the XDG
13//! config base directly — never through the single-dir collapse it governs — so
14//! it can never depend on the decision it is meant to make (no cycle). Its
15//! presence with `mode = "xdg"` tells the resolver: this install is committed to
16//! XDG, so ignore a legacy `~/.lean-ctx` / mixed `$XDG_CONFIG_HOME` data marker.
17//!
18//! Determinism (#498): the resolver only ever *reads* the pin (a pure function
19//! of the filesystem). Writes happen at explicit, idempotent call sites (setup,
20//! `doctor`, daemon/server start) and the body is byte-stable.
21
22use std::path::Path;
23
24/// Pin filename, living alongside `config.toml` in the config dir. Categorized
25/// as config by `xdg_migrate` so a split never relocates it.
26pub(crate) const LAYOUT_FILE: &str = "layout.toml";
27
28/// Byte-stable body for an XDG-committed install (#498).
29const XDG_PIN_BODY: &str = "# lean-ctx layout pin (GL #623) — managed by lean-ctx, do not edit.\n# Marks this install as committed to the XDG four-dir layout so a stray\n# ~/.lean-ctx never re-collapses config/data/state/cache. Remove via\n# `lean-ctx doctor` only if you intentionally revert to a single-dir layout.\nmode = \"xdg\"\n";
30
31/// `true` when `<config_base>/lean-ctx/layout.toml` pins the XDG layout.
32/// `config_base` is the XDG **config** base (e.g. `~/.config`) — the same base
33/// [`crate::core::paths::single_dir_override`] resolves — so the read location
34/// always matches the mixed-install probe it sits next to. Hermetic (no env
35/// access) so the resolver path stays unit-testable.
36pub(crate) fn is_xdg_pinned_in(config_base: &Path) -> bool {
37    read_mode(&config_base.join("lean-ctx").join(LAYOUT_FILE)).as_deref() == Some("xdg")
38}
39
40/// Runtime read honoring the same env resolution as the resolver
41/// (`$XDG_CONFIG_HOME/lean-ctx`). Used by `doctor`/diagnostics.
42#[must_use]
43pub fn is_xdg_pinned() -> bool {
44    crate::core::paths::xdg_config_lean_ctx_dir()
45        .is_some_and(|d| read_mode(&d.join(LAYOUT_FILE)).as_deref() == Some("xdg"))
46}
47
48/// Pin this install to the XDG layout — but only when it genuinely *is* XDG:
49///
50/// - skips when a directory override (`LEAN_CTX_CONFIG_DIR`, `_DATA_DIR`,
51///   `_STATE_DIR`, `_CACHE_DIR`) is set: it applies to this process only;
52/// - skips while a legacy `~/.lean-ctx` or mixed `$XDG_CONFIG_HOME/lean-ctx`
53///   still holds data markers (a real single-dir/mixed install that must keep
54///   resolving in place until `doctor --fix` splits it);
55/// - otherwise writes `mode = "xdg"` atomically.
56///
57/// Idempotent: a no-op once the pin already says `xdg`. Safe to call from any
58/// startup path.
59pub fn ensure_pinned() {
60    // Any directory override, not only the data dir (GH #2007).
61    if crate::core::paths::dir_override_active() {
62        return;
63    }
64    // `single_dir_override` returns `Some` only for an unpinned legacy/mixed
65    // single-dir install; `None` means we are already on (or defaulting to) XDG.
66    if crate::core::paths::single_dir_override().is_some() {
67        return;
68    }
69    let Some(dir) = crate::core::paths::xdg_config_lean_ctx_dir() else {
70        return;
71    };
72    let path = dir.join(LAYOUT_FILE);
73    if read_mode(&path).as_deref() == Some("xdg") {
74        return;
75    }
76    if std::fs::create_dir_all(&dir).is_ok() {
77        crate::core::data_dir::ensure_dir_permissions(&dir);
78        write_atomic(&path, XDG_PIN_BODY);
79    }
80}
81
82/// Self-heal the layout at startup. Idempotent and best-effort:
83///
84/// 1. [`ensure_pinned`] — commit to XDG once the install genuinely is XDG.
85/// 2. When committed, drain a residual `~/.lean-ctx` into the XDG dirs and
86///    remove it. Safe precisely *because* of the pin: `single_dir_override`
87///    ignores `~/.lean-ctx` for a committed install, so no live writer targets
88///    it and the drain can never race a concurrent write (GL #623 / #626).
89///
90/// Cheap to call from any startup path: the reclaim returns immediately when
91/// `~/.lean-ctx` does not exist.
92pub fn heal() {
93    ensure_pinned();
94    if is_xdg_pinned() {
95        let _ = crate::core::xdg_migrate::reclaim_legacy();
96    }
97    // GH #1421: heal stale config.toml copies in non-canonical directories
98    // (legacy ~/.lean-ctx, stale XDG, or data dir). Runs after the layout pin
99    // and reclaim so the canonical config dir is stable.
100    for report in crate::core::config_heal::heal_all() {
101        eprintln!(
102            "[lean-ctx] config heal: {} {} → {}",
103            match report.action {
104                crate::core::config_heal::HealAction::Adopted => "adopted",
105                crate::core::config_heal::HealAction::Superseded => "superseded",
106            },
107            report.from.display(),
108            report.to.display(),
109        );
110    }
111}
112
113/// Atomic write via a sibling temp file + rename, so a crash never leaves a
114/// half-written pin that could be misread as a different mode.
115fn write_atomic(path: &Path, body: &str) {
116    let tmp = path.with_extension("toml.tmp");
117    if std::fs::write(&tmp, body).is_ok() && std::fs::rename(&tmp, path).is_err() {
118        let _ = std::fs::remove_file(&tmp);
119    }
120}
121
122/// Parse the `mode = "..."` value from a pin file, ignoring comments/blank
123/// lines. Returns `None` when the file is absent, unreadable, or has no `mode`.
124fn read_mode(path: &Path) -> Option<String> {
125    let body = std::fs::read_to_string(path).ok()?;
126    body.lines().find_map(|line| {
127        let rest = line.trim().strip_prefix("mode")?;
128        let val = rest
129            .trim_start()
130            .strip_prefix('=')?
131            .trim()
132            .trim_matches('"')
133            .trim();
134        (!val.is_empty()).then(|| val.to_string())
135    })
136}
137
138/// Write the XDG pin under `<config_base>/lean-ctx` regardless of the current
139/// install state. Test-only helper for driving the hermetic resolver tests; the
140/// production write path is [`ensure_pinned`].
141#[cfg(test)]
142pub(crate) fn write_xdg_pin_in(config_base: &Path) -> std::io::Result<()> {
143    let dir = config_base.join("lean-ctx");
144    std::fs::create_dir_all(&dir)?;
145    crate::core::data_dir::ensure_dir_permissions(&dir);
146    std::fs::write(dir.join(LAYOUT_FILE), XDG_PIN_BODY)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn read_mode_parses_xdg_pin() {
155        let tmp = tempfile::tempdir().unwrap();
156        let cfg = tmp.path();
157        write_xdg_pin_in(cfg).unwrap();
158        assert!(is_xdg_pinned_in(cfg));
159    }
160
161    #[test]
162    fn unpinned_dir_is_not_pinned() {
163        let tmp = tempfile::tempdir().unwrap();
164        assert!(!is_xdg_pinned_in(tmp.path()));
165    }
166
167    #[test]
168    fn read_mode_ignores_comments_and_other_keys() {
169        let tmp = tempfile::tempdir().unwrap();
170        let dir = tmp.path().join("lean-ctx");
171        std::fs::create_dir_all(&dir).unwrap();
172        std::fs::write(
173            dir.join(LAYOUT_FILE),
174            "# mode = \"legacy\"\nother = 1\nmode = \"xdg\"\n",
175        )
176        .unwrap();
177        assert!(is_xdg_pinned_in(tmp.path()));
178    }
179
180    #[test]
181    fn non_xdg_mode_is_not_xdg_pinned() {
182        let tmp = tempfile::tempdir().unwrap();
183        let dir = tmp.path().join("lean-ctx");
184        std::fs::create_dir_all(&dir).unwrap();
185        std::fs::write(dir.join(LAYOUT_FILE), "mode = \"single\"\n").unwrap();
186        assert!(!is_xdg_pinned_in(tmp.path()));
187    }
188}