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 `LEAN_CTX_DATA_DIR` is set (a deliberate single-dir choice);
51/// - skips while a legacy `~/.lean-ctx` or mixed `$XDG_CONFIG_HOME/lean-ctx`
52///   still holds data markers (a real single-dir/mixed install that must keep
53///   resolving in place until `doctor --fix` splits it);
54/// - otherwise writes `mode = "xdg"` atomically.
55///
56/// Idempotent: a no-op once the pin already says `xdg`. Safe to call from any
57/// startup path.
58pub fn ensure_pinned() {
59    if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
60        return;
61    }
62    // `single_dir_override` returns `Some` only for an unpinned legacy/mixed
63    // single-dir install; `None` means we are already on (or defaulting to) XDG.
64    if crate::core::paths::single_dir_override().is_some() {
65        return;
66    }
67    let Some(dir) = crate::core::paths::xdg_config_lean_ctx_dir() else {
68        return;
69    };
70    let path = dir.join(LAYOUT_FILE);
71    if read_mode(&path).as_deref() == Some("xdg") {
72        return;
73    }
74    if std::fs::create_dir_all(&dir).is_ok() {
75        crate::core::data_dir::ensure_dir_permissions(&dir);
76        write_atomic(&path, XDG_PIN_BODY);
77    }
78}
79
80/// Self-heal the layout at startup. Idempotent and best-effort:
81///
82/// 1. [`ensure_pinned`] — commit to XDG once the install genuinely is XDG.
83/// 2. When committed, drain a residual `~/.lean-ctx` into the XDG dirs and
84///    remove it. Safe precisely *because* of the pin: `single_dir_override`
85///    ignores `~/.lean-ctx` for a committed install, so no live writer targets
86///    it and the drain can never race a concurrent write (GL #623 / #626).
87///
88/// Cheap to call from any startup path: the reclaim returns immediately when
89/// `~/.lean-ctx` does not exist.
90pub fn heal() {
91    ensure_pinned();
92    if is_xdg_pinned() {
93        let _ = crate::core::xdg_migrate::reclaim_legacy();
94    }
95}
96
97/// Atomic write via a sibling temp file + rename, so a crash never leaves a
98/// half-written pin that could be misread as a different mode.
99fn write_atomic(path: &Path, body: &str) {
100    let tmp = path.with_extension("toml.tmp");
101    if std::fs::write(&tmp, body).is_ok() && std::fs::rename(&tmp, path).is_err() {
102        let _ = std::fs::remove_file(&tmp);
103    }
104}
105
106/// Parse the `mode = "..."` value from a pin file, ignoring comments/blank
107/// lines. Returns `None` when the file is absent, unreadable, or has no `mode`.
108fn read_mode(path: &Path) -> Option<String> {
109    let body = std::fs::read_to_string(path).ok()?;
110    body.lines().find_map(|line| {
111        let rest = line.trim().strip_prefix("mode")?;
112        let val = rest
113            .trim_start()
114            .strip_prefix('=')?
115            .trim()
116            .trim_matches('"')
117            .trim();
118        (!val.is_empty()).then(|| val.to_string())
119    })
120}
121
122/// Write the XDG pin under `<config_base>/lean-ctx` regardless of the current
123/// install state. Test-only helper for driving the hermetic resolver tests; the
124/// production write path is [`ensure_pinned`].
125#[cfg(test)]
126pub(crate) fn write_xdg_pin_in(config_base: &Path) -> std::io::Result<()> {
127    let dir = config_base.join("lean-ctx");
128    std::fs::create_dir_all(&dir)?;
129    crate::core::data_dir::ensure_dir_permissions(&dir);
130    std::fs::write(dir.join(LAYOUT_FILE), XDG_PIN_BODY)
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn read_mode_parses_xdg_pin() {
139        let tmp = tempfile::tempdir().unwrap();
140        let cfg = tmp.path();
141        write_xdg_pin_in(cfg).unwrap();
142        assert!(is_xdg_pinned_in(cfg));
143    }
144
145    #[test]
146    fn unpinned_dir_is_not_pinned() {
147        let tmp = tempfile::tempdir().unwrap();
148        assert!(!is_xdg_pinned_in(tmp.path()));
149    }
150
151    #[test]
152    fn read_mode_ignores_comments_and_other_keys() {
153        let tmp = tempfile::tempdir().unwrap();
154        let dir = tmp.path().join("lean-ctx");
155        std::fs::create_dir_all(&dir).unwrap();
156        std::fs::write(
157            dir.join(LAYOUT_FILE),
158            "# mode = \"legacy\"\nother = 1\nmode = \"xdg\"\n",
159        )
160        .unwrap();
161        assert!(is_xdg_pinned_in(tmp.path()));
162    }
163
164    #[test]
165    fn non_xdg_mode_is_not_xdg_pinned() {
166        let tmp = tempfile::tempdir().unwrap();
167        let dir = tmp.path().join("lean-ctx");
168        std::fs::create_dir_all(&dir).unwrap();
169        std::fs::write(dir.join(LAYOUT_FILE), "mode = \"single\"\n").unwrap();
170        assert!(!is_xdg_pinned_in(tmp.path()));
171    }
172}