lean_ctx/core/
config_heal.rs1use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum HealAction {
25 Adopted,
27 Superseded,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ConfigHealReport {
34 pub action: HealAction,
35 pub from: PathBuf,
37 pub to: PathBuf,
39}
40
41pub fn heal() -> Option<ConfigHealReport> {
45 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
57pub(crate) fn heal_legacy_config() -> Option<ConfigHealReport> {
63 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
76pub(crate) fn heal_xdg_stale_config() -> Option<ConfigHealReport> {
80 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
92pub(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
108pub 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
124fn heal_between(config_dir: &Path, data_dir: &Path) -> Option<ConfigHealReport> {
126 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 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 move_overwrite(&stray, &canonical).ok()?;
156 Some(ConfigHealReport {
157 action: HealAction::Adopted,
158 from: stray,
159 to: canonical,
160 })
161}
162
163fn file_has_content(p: &Path) -> bool {
165 std::fs::read_to_string(p).is_ok_and(|s| !s.trim().is_empty())
166}
167
168fn 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 assert_eq!(
224 std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
225 "ultra_compact = true\n"
226 );
227 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 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 #[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 assert_eq!(heal_between(&config_dir, &data_dir), None);
354 }
355}