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}