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).
44pub fn heal() -> Option<ConfigHealReport> {
45    let config_dir = crate::core::paths::config_dir().ok()?;
46    let data_dir = crate::core::paths::data_dir().ok()?;
47    heal_between(&config_dir, &data_dir)
48}
49
50/// Read-only check used by `doctor`: returns the stray data-dir `config.toml`
51/// that [`heal`] would relocate, or `None` when the CLI and the MCP server
52/// already resolve the same config (no divergence).
53pub fn pending() -> Option<PathBuf> {
54    let config_dir = crate::core::paths::config_dir().ok()?;
55    let data_dir = crate::core::paths::data_dir().ok()?;
56    if config_dir == data_dir {
57        return None;
58    }
59    let stray = data_dir.join("config.toml");
60    file_has_content(&stray).then_some(stray)
61}
62
63/// Pure core of [`heal`], parameterized for hermetic tests.
64fn heal_between(config_dir: &Path, data_dir: &Path) -> Option<ConfigHealReport> {
65    // Single-dir layout (legacy/mixed/explicit pin): config legitimately lives
66    // in that one directory and is not stranded.
67    if config_dir == data_dir {
68        return None;
69    }
70
71    let stray = data_dir.join("config.toml");
72    if !file_has_content(&stray) {
73        return None;
74    }
75
76    std::fs::create_dir_all(config_dir).ok()?;
77    crate::core::data_dir::ensure_dir_permissions(config_dir);
78    let canonical = config_dir.join("config.toml");
79
80    if file_has_content(&canonical) {
81        // The CLI-authored config is the source of truth; preserve the stray
82        // copy next to it (lossless) instead of dropping it.
83        let aside = data_dir.join("config.toml.superseded");
84        move_overwrite(&stray, &aside).ok()?;
85        return Some(ConfigHealReport {
86            action: HealAction::Superseded,
87            from: stray,
88            to: aside,
89        });
90    }
91
92    // Canonical config is absent/empty → adopt the stray copy so the user's
93    // settings keep working after config resolves to the config dir.
94    move_overwrite(&stray, &canonical).ok()?;
95    Some(ConfigHealReport {
96        action: HealAction::Adopted,
97        from: stray,
98        to: canonical,
99    })
100}
101
102/// True when `p` is a readable file with non-whitespace content.
103fn file_has_content(p: &Path) -> bool {
104    std::fs::read_to_string(p).is_ok_and(|s| !s.trim().is_empty())
105}
106
107/// Move `from` onto `to`, replacing any existing file. Atomic `rename` first,
108/// with a copy+remove fallback across filesystems; the source is removed only
109/// after a successful copy, so an interrupted move never loses data.
110fn move_overwrite(from: &Path, to: &Path) -> std::io::Result<()> {
111    if std::fs::rename(from, to).is_ok() {
112        return Ok(());
113    }
114    std::fs::copy(from, to)?;
115    std::fs::remove_file(from)?;
116    Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn write(path: &Path, body: &str) {
124        if let Some(parent) = path.parent() {
125            std::fs::create_dir_all(parent).unwrap();
126        }
127        std::fs::write(path, body).unwrap();
128    }
129
130    #[test]
131    fn adopts_stray_when_canonical_absent() {
132        let tmp = tempfile::tempdir().unwrap();
133        let config_dir = tmp.path().join("config");
134        let data_dir = tmp.path().join("data");
135        write(&data_dir.join("config.toml"), "path_jail = false\n");
136
137        let report = heal_between(&config_dir, &data_dir).expect("should adopt");
138
139        assert_eq!(report.action, HealAction::Adopted);
140        assert_eq!(
141            std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
142            "path_jail = false\n"
143        );
144        assert!(
145            !data_dir.join("config.toml").exists(),
146            "stray copy must be relocated out of the data dir"
147        );
148    }
149
150    #[test]
151    fn supersedes_stray_when_canonical_present() {
152        let tmp = tempfile::tempdir().unwrap();
153        let config_dir = tmp.path().join("config");
154        let data_dir = tmp.path().join("data");
155        write(&config_dir.join("config.toml"), "ultra_compact = true\n");
156        write(&data_dir.join("config.toml"), "STALE\n");
157
158        let report = heal_between(&config_dir, &data_dir).expect("should supersede");
159
160        assert_eq!(report.action, HealAction::Superseded);
161        // The CLI-authored canonical config is untouched.
162        assert_eq!(
163            std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
164            "ultra_compact = true\n"
165        );
166        // The stray copy is preserved aside (lossless), not deleted.
167        assert!(!data_dir.join("config.toml").exists());
168        assert_eq!(
169            std::fs::read_to_string(data_dir.join("config.toml.superseded")).unwrap(),
170            "STALE\n"
171        );
172    }
173
174    #[test]
175    fn noop_when_no_stray_config() {
176        let tmp = tempfile::tempdir().unwrap();
177        let config_dir = tmp.path().join("config");
178        let data_dir = tmp.path().join("data");
179        std::fs::create_dir_all(&data_dir).unwrap();
180        assert_eq!(heal_between(&config_dir, &data_dir), None);
181    }
182
183    #[test]
184    fn noop_for_single_dir_layout() {
185        let tmp = tempfile::tempdir().unwrap();
186        let dir = tmp.path().join("lean-ctx");
187        write(&dir.join("config.toml"), "x = 1\n");
188        // Same dir for config and data → nothing is stranded.
189        assert_eq!(heal_between(&dir, &dir), None);
190    }
191
192    #[test]
193    fn is_idempotent() {
194        let tmp = tempfile::tempdir().unwrap();
195        let config_dir = tmp.path().join("config");
196        let data_dir = tmp.path().join("data");
197        write(&data_dir.join("config.toml"), "k = 1\n");
198
199        assert!(heal_between(&config_dir, &data_dir).is_some());
200        // Second run: stray is gone → no-op.
201        assert_eq!(heal_between(&config_dir, &data_dir), None);
202    }
203}