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}