Skip to main content

lean_ctx/core/
config_heal.rs

1//! Heal a `config.toml` that got stranded in the DATA dir (#594).
2//!
3//! When an older lean-ctx baked `LEAN_CTX_DATA_DIR` into an editor's MCP server
4//! `env`, that process ran in single-dir mode and wrote `config.toml` into the
5//! data dir (`$XDG_DATA_HOME/lean-ctx`), while the terminal CLI kept config in
6//! `$XDG_CONFIG_HOME/lean-ctx`. The resolver now keeps both on the config dir
7//! (see `core::paths::single_dir_override`), but a `config.toml` that was already
8//! written into the data dir would be silently ignored from then on.
9//!
10//! This module relocates it to the canonical config dir, **losslessly**:
11//! - canonical config absent/empty → the stray copy is *adopted* as the real
12//!   config, so the user's settings survive the switch;
13//! - canonical config already present → the CLI-authored config wins and the
14//!   stray copy is moved aside to `config.toml.superseded` (never deleted).
15//!
16//! Idempotent and safe: it only ever touches the user's own config/data dirs,
17//! moves with an atomic `rename` (copy+remove fallback across filesystems), and
18//! becomes a no-op once the stray file is gone.
19
20use std::path::{Path, PathBuf};
21
22/// What the heal pass did with the stray data-dir `config.toml`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum HealAction {
25    /// The stray copy became the canonical config (canonical was absent/empty).
26    Adopted,
27    /// Canonical config already existed; the stray copy was moved aside.
28    Superseded,
29}
30
31/// Outcome of a config-heal pass, surfaced through setup / `doctor`.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ConfigHealReport {
34    pub action: HealAction,
35    /// The stray `config.toml` that was relocated out of the data dir.
36    pub from: PathBuf,
37    /// Where it landed: the canonical config (adopted) or the `.superseded` copy.
38    pub to: PathBuf,
39}
40
41/// Relocate a stray data-dir `config.toml` into the canonical config dir.
42/// Returns `None` when there is nothing to do (single-dir layout, or no stray
43/// config in the data dir, or any `LEAN_CTX_*_DIR` override is active).
44pub fn heal() -> Option<ConfigHealReport> {
45    // With a config override the data dir is still the real install's, so the
46    // real `config.toml` would be moved into the override (a temp dir in tests).
47    // Same rule as the two siblings below; the #594 recovery runs from
48    // `doctor --fix` and setup in a terminal, where no override is set.
49    if crate::core::paths::dir_override_active() {
50        return None;
51    }
52    let config_dir = crate::core::paths::config_dir().ok()?;
53    let data_dir = crate::core::paths::data_dir().ok()?;
54    heal_between(&config_dir, &data_dir)
55}
56
57/// Relocate a stale `config.toml` from the legacy `~/.lean-ctx` dir to the
58/// canonical config dir (GH #1421). Only acts when `~/.lean-ctx` is NOT the
59/// active config dir — i.e. the install has already migrated to XDG but the
60/// old config file was left behind. Uses the same adopt/supersede logic as
61/// [`heal`].
62pub(crate) fn heal_legacy_config() -> Option<ConfigHealReport> {
63    // GH #2007: with LEAN_CTX_CONFIG_DIR pointing at a scratch dir, the real
64    // ~/.lean-ctx/config.toml looked stale and was moved aside.
65    if crate::core::paths::dir_override_active() {
66        return None;
67    }
68    let config_dir = crate::core::paths::config_dir().ok()?;
69    let legacy_dir = dirs::home_dir()?.join(".lean-ctx");
70    if legacy_dir == config_dir {
71        return None;
72    }
73    heal_between(&config_dir, &legacy_dir)
74}
75
76/// Relocate a stale `config.toml` from `$XDG_CONFIG_HOME/lean-ctx` when the
77/// active config dir is elsewhere (e.g. a single-dir `~/.lean-ctx` install).
78/// GH #1421: prevents user confusion from editing the "wrong" config file.
79pub(crate) fn heal_xdg_stale_config() -> Option<ConfigHealReport> {
80    // GH #2007: an override never makes the default XDG config stale.
81    if crate::core::paths::dir_override_active() {
82        return None;
83    }
84    let config_dir = crate::core::paths::config_dir().ok()?;
85    let xdg_config = crate::core::paths::xdg_config_lean_ctx_dir()?;
86    if xdg_config == config_dir {
87        return None;
88    }
89    heal_between(&config_dir, &xdg_config)
90}
91
92/// Heal ALL known stale config locations in a single pass (GH #1421).
93/// Called from startup heal paths to ensure at most one `config.toml` exists.
94pub(crate) fn heal_all() -> Vec<ConfigHealReport> {
95    let mut reports = Vec::new();
96    if let Some(r) = heal() {
97        reports.push(r);
98    }
99    if let Some(r) = heal_legacy_config() {
100        reports.push(r);
101    }
102    if let Some(r) = heal_xdg_stale_config() {
103        reports.push(r);
104    }
105    reports
106}
107
108/// Read-only check used by `doctor`: returns the stray data-dir `config.toml`
109/// that [`heal`] would relocate, or `None` when the CLI and the MCP server
110/// already resolve the same config (no divergence).
111pub fn pending() -> Option<PathBuf> {
112    if crate::core::paths::dir_override_active() {
113        return None;
114    }
115    let config_dir = crate::core::paths::config_dir().ok()?;
116    let data_dir = crate::core::paths::data_dir().ok()?;
117    if config_dir == data_dir {
118        return None;
119    }
120    let stray = data_dir.join("config.toml");
121    file_has_content(&stray).then_some(stray)
122}
123
124/// Pure core of [`heal`], parameterized for hermetic tests.
125fn heal_between(config_dir: &Path, data_dir: &Path) -> Option<ConfigHealReport> {
126    // Single-dir layout (legacy/mixed/explicit pin): config legitimately lives
127    // in that one directory and is not stranded.
128    if config_dir == data_dir {
129        return None;
130    }
131
132    let stray = data_dir.join("config.toml");
133    if !file_has_content(&stray) {
134        return None;
135    }
136
137    std::fs::create_dir_all(config_dir).ok()?;
138    crate::core::data_dir::ensure_dir_permissions(config_dir);
139    let canonical = config_dir.join("config.toml");
140
141    if file_has_content(&canonical) {
142        // The CLI-authored config is the source of truth; preserve the stray
143        // copy next to it (lossless) instead of dropping it.
144        let aside = data_dir.join("config.toml.superseded");
145        move_overwrite(&stray, &aside).ok()?;
146        return Some(ConfigHealReport {
147            action: HealAction::Superseded,
148            from: stray,
149            to: aside,
150        });
151    }
152
153    // Canonical config is absent/empty → adopt the stray copy so the user's
154    // settings keep working after config resolves to the config dir.
155    move_overwrite(&stray, &canonical).ok()?;
156    Some(ConfigHealReport {
157        action: HealAction::Adopted,
158        from: stray,
159        to: canonical,
160    })
161}
162
163/// True when `p` is a readable file with non-whitespace content.
164fn file_has_content(p: &Path) -> bool {
165    std::fs::read_to_string(p).is_ok_and(|s| !s.trim().is_empty())
166}
167
168/// Move `from` onto `to`, replacing any existing file. Atomic `rename` first,
169/// with a copy+remove fallback across filesystems; the source is removed only
170/// after a successful copy, so an interrupted move never loses data.
171fn move_overwrite(from: &Path, to: &Path) -> std::io::Result<()> {
172    if std::fs::rename(from, to).is_ok() {
173        return Ok(());
174    }
175    std::fs::copy(from, to)?;
176    std::fs::remove_file(from)?;
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    fn write(path: &Path, body: &str) {
185        if let Some(parent) = path.parent() {
186            std::fs::create_dir_all(parent).unwrap();
187        }
188        std::fs::write(path, body).unwrap();
189    }
190
191    #[test]
192    fn adopts_stray_when_canonical_absent() {
193        let tmp = tempfile::tempdir().unwrap();
194        let config_dir = tmp.path().join("config");
195        let data_dir = tmp.path().join("data");
196        write(&data_dir.join("config.toml"), "path_jail = false\n");
197
198        let report = heal_between(&config_dir, &data_dir).expect("should adopt");
199
200        assert_eq!(report.action, HealAction::Adopted);
201        assert_eq!(
202            std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
203            "path_jail = false\n"
204        );
205        assert!(
206            !data_dir.join("config.toml").exists(),
207            "stray copy must be relocated out of the data dir"
208        );
209    }
210
211    #[test]
212    fn supersedes_stray_when_canonical_present() {
213        let tmp = tempfile::tempdir().unwrap();
214        let config_dir = tmp.path().join("config");
215        let data_dir = tmp.path().join("data");
216        write(&config_dir.join("config.toml"), "ultra_compact = true\n");
217        write(&data_dir.join("config.toml"), "STALE\n");
218
219        let report = heal_between(&config_dir, &data_dir).expect("should supersede");
220
221        assert_eq!(report.action, HealAction::Superseded);
222        // The CLI-authored canonical config is untouched.
223        assert_eq!(
224            std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
225            "ultra_compact = true\n"
226        );
227        // The stray copy is preserved aside (lossless), not deleted.
228        assert!(!data_dir.join("config.toml").exists());
229        assert_eq!(
230            std::fs::read_to_string(data_dir.join("config.toml.superseded")).unwrap(),
231            "STALE\n"
232        );
233    }
234
235    #[test]
236    fn noop_when_no_stray_config() {
237        let tmp = tempfile::tempdir().unwrap();
238        let config_dir = tmp.path().join("config");
239        let data_dir = tmp.path().join("data");
240        std::fs::create_dir_all(&data_dir).unwrap();
241        assert_eq!(heal_between(&config_dir, &data_dir), None);
242    }
243
244    #[test]
245    fn noop_for_single_dir_layout() {
246        let tmp = tempfile::tempdir().unwrap();
247        let dir = tmp.path().join("lean-ctx");
248        write(&dir.join("config.toml"), "x = 1\n");
249        // Same dir for config and data → nothing is stranded.
250        assert_eq!(heal_between(&dir, &dir), None);
251    }
252
253    #[test]
254    fn heal_legacy_adopts_when_canonical_absent() {
255        let tmp = tempfile::tempdir().unwrap();
256        let config_dir = tmp.path().join("config");
257        let legacy_dir = tmp.path().join("legacy");
258        write(&legacy_dir.join("config.toml"), "legacy = true\n");
259
260        let report = heal_between(&config_dir, &legacy_dir).expect("should adopt legacy");
261        assert_eq!(report.action, HealAction::Adopted);
262        assert_eq!(
263            std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
264            "legacy = true\n"
265        );
266        assert!(!legacy_dir.join("config.toml").exists());
267    }
268
269    #[test]
270    fn heal_legacy_supersedes_when_canonical_present() {
271        let tmp = tempfile::tempdir().unwrap();
272        let config_dir = tmp.path().join("config");
273        let legacy_dir = tmp.path().join("legacy");
274        write(&config_dir.join("config.toml"), "canonical = true\n");
275        write(&legacy_dir.join("config.toml"), "stale_legacy = true\n");
276
277        let report = heal_between(&config_dir, &legacy_dir).expect("should supersede");
278        assert_eq!(report.action, HealAction::Superseded);
279        assert_eq!(
280            std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
281            "canonical = true\n"
282        );
283        assert!(
284            legacy_dir.join("config.toml.superseded").exists(),
285            "stale legacy copy must be preserved as .superseded"
286        );
287    }
288
289    // GH #2007: a process started with LEAN_CTX_CONFIG_DIR / _DATA_DIR pointing
290    // at a scratch dir renamed the real ~/.lean-ctx/config.toml to
291    // config.toml.superseded. An override applies to that process only.
292    #[cfg(unix)]
293    #[test]
294    fn heal_all_leaves_default_locations_alone_under_a_dir_override() {
295        let _lock = crate::core::data_dir::test_env_lock();
296        let tmp = tempfile::tempdir().unwrap();
297        let home = tmp.path().join("home");
298        let xdg_config = tmp.path().join("xc");
299        let scratch = tmp.path().join("scratch");
300        write(&home.join(".lean-ctx/config.toml"), "real_legacy = true\n");
301        write(
302            &xdg_config.join("lean-ctx/config.toml"),
303            "real_xdg = true\n",
304        );
305        write(&scratch.join("config/config.toml"), "scratch = true\n");
306        let names = [
307            "HOME",
308            "XDG_CONFIG_HOME",
309            "LEAN_CTX_CONFIG_DIR",
310            "LEAN_CTX_DATA_DIR",
311            "LEAN_CTX_STATE_DIR",
312            "LEAN_CTX_CACHE_DIR",
313        ];
314        let saved: Vec<_> = names.iter().map(|k| (*k, std::env::var_os(k))).collect();
315        crate::test_env::set_var("HOME", &home);
316        crate::test_env::set_var("XDG_CONFIG_HOME", &xdg_config);
317        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", scratch.join("config"));
318        crate::test_env::set_var("LEAN_CTX_DATA_DIR", scratch.join("data"));
319        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
320        crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
321
322        let legacy = heal_legacy_config();
323        let xdg = heal_xdg_stale_config();
324
325        for (k, v) in saved {
326            match v {
327                Some(value) => crate::test_env::set_var(k, value),
328                None => crate::test_env::remove_var(k),
329            }
330        }
331        assert_eq!(legacy, None);
332        assert_eq!(xdg, None);
333        assert_eq!(
334            std::fs::read_to_string(home.join(".lean-ctx/config.toml")).unwrap(),
335            "real_legacy = true\n"
336        );
337        assert!(!home.join(".lean-ctx/config.toml.superseded").exists());
338        assert_eq!(
339            std::fs::read_to_string(xdg_config.join("lean-ctx/config.toml")).unwrap(),
340            "real_xdg = true\n"
341        );
342    }
343
344    #[test]
345    fn is_idempotent() {
346        let tmp = tempfile::tempdir().unwrap();
347        let config_dir = tmp.path().join("config");
348        let data_dir = tmp.path().join("data");
349        write(&data_dir.join("config.toml"), "k = 1\n");
350
351        assert!(heal_between(&config_dir, &data_dir).is_some());
352        // Second run: stray is gone → no-op.
353        assert_eq!(heal_between(&config_dir, &data_dir), None);
354    }
355}