use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealAction {
Adopted,
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigHealReport {
pub action: HealAction,
pub from: PathBuf,
pub to: PathBuf,
}
pub fn heal() -> Option<ConfigHealReport> {
let config_dir = crate::core::paths::config_dir().ok()?;
let data_dir = crate::core::paths::data_dir().ok()?;
heal_between(&config_dir, &data_dir)
}
pub(crate) fn heal_legacy_config() -> Option<ConfigHealReport> {
if crate::core::paths::dir_override_active() {
return None;
}
let config_dir = crate::core::paths::config_dir().ok()?;
let legacy_dir = dirs::home_dir()?.join(".lean-ctx");
if legacy_dir == config_dir {
return None;
}
heal_between(&config_dir, &legacy_dir)
}
pub(crate) fn heal_xdg_stale_config() -> Option<ConfigHealReport> {
if crate::core::paths::dir_override_active() {
return None;
}
let config_dir = crate::core::paths::config_dir().ok()?;
let xdg_config = crate::core::paths::xdg_config_lean_ctx_dir()?;
if xdg_config == config_dir {
return None;
}
heal_between(&config_dir, &xdg_config)
}
pub(crate) fn heal_all() -> Vec<ConfigHealReport> {
let mut reports = Vec::new();
if let Some(r) = heal() {
reports.push(r);
}
if let Some(r) = heal_legacy_config() {
reports.push(r);
}
if let Some(r) = heal_xdg_stale_config() {
reports.push(r);
}
reports
}
pub fn pending() -> Option<PathBuf> {
let config_dir = crate::core::paths::config_dir().ok()?;
let data_dir = crate::core::paths::data_dir().ok()?;
if config_dir == data_dir {
return None;
}
let stray = data_dir.join("config.toml");
file_has_content(&stray).then_some(stray)
}
fn heal_between(config_dir: &Path, data_dir: &Path) -> Option<ConfigHealReport> {
if config_dir == data_dir {
return None;
}
let stray = data_dir.join("config.toml");
if !file_has_content(&stray) {
return None;
}
std::fs::create_dir_all(config_dir).ok()?;
crate::core::data_dir::ensure_dir_permissions(config_dir);
let canonical = config_dir.join("config.toml");
if file_has_content(&canonical) {
let aside = data_dir.join("config.toml.superseded");
move_overwrite(&stray, &aside).ok()?;
return Some(ConfigHealReport {
action: HealAction::Superseded,
from: stray,
to: aside,
});
}
move_overwrite(&stray, &canonical).ok()?;
Some(ConfigHealReport {
action: HealAction::Adopted,
from: stray,
to: canonical,
})
}
fn file_has_content(p: &Path) -> bool {
std::fs::read_to_string(p).is_ok_and(|s| !s.trim().is_empty())
}
fn move_overwrite(from: &Path, to: &Path) -> std::io::Result<()> {
if std::fs::rename(from, to).is_ok() {
return Ok(());
}
std::fs::copy(from, to)?;
std::fs::remove_file(from)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, body).unwrap();
}
#[test]
fn adopts_stray_when_canonical_absent() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("config");
let data_dir = tmp.path().join("data");
write(&data_dir.join("config.toml"), "path_jail = false\n");
let report = heal_between(&config_dir, &data_dir).expect("should adopt");
assert_eq!(report.action, HealAction::Adopted);
assert_eq!(
std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
"path_jail = false\n"
);
assert!(
!data_dir.join("config.toml").exists(),
"stray copy must be relocated out of the data dir"
);
}
#[test]
fn supersedes_stray_when_canonical_present() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("config");
let data_dir = tmp.path().join("data");
write(&config_dir.join("config.toml"), "ultra_compact = true\n");
write(&data_dir.join("config.toml"), "STALE\n");
let report = heal_between(&config_dir, &data_dir).expect("should supersede");
assert_eq!(report.action, HealAction::Superseded);
assert_eq!(
std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
"ultra_compact = true\n"
);
assert!(!data_dir.join("config.toml").exists());
assert_eq!(
std::fs::read_to_string(data_dir.join("config.toml.superseded")).unwrap(),
"STALE\n"
);
}
#[test]
fn noop_when_no_stray_config() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("config");
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();
assert_eq!(heal_between(&config_dir, &data_dir), None);
}
#[test]
fn noop_for_single_dir_layout() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("lean-ctx");
write(&dir.join("config.toml"), "x = 1\n");
assert_eq!(heal_between(&dir, &dir), None);
}
#[test]
fn heal_legacy_adopts_when_canonical_absent() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("config");
let legacy_dir = tmp.path().join("legacy");
write(&legacy_dir.join("config.toml"), "legacy = true\n");
let report = heal_between(&config_dir, &legacy_dir).expect("should adopt legacy");
assert_eq!(report.action, HealAction::Adopted);
assert_eq!(
std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
"legacy = true\n"
);
assert!(!legacy_dir.join("config.toml").exists());
}
#[test]
fn heal_legacy_supersedes_when_canonical_present() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("config");
let legacy_dir = tmp.path().join("legacy");
write(&config_dir.join("config.toml"), "canonical = true\n");
write(&legacy_dir.join("config.toml"), "stale_legacy = true\n");
let report = heal_between(&config_dir, &legacy_dir).expect("should supersede");
assert_eq!(report.action, HealAction::Superseded);
assert_eq!(
std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
"canonical = true\n"
);
assert!(
legacy_dir.join("config.toml.superseded").exists(),
"stale legacy copy must be preserved as .superseded"
);
}
#[cfg(unix)]
#[test]
fn heal_all_leaves_default_locations_alone_under_a_dir_override() {
let _lock = crate::core::data_dir::test_env_lock();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let xdg_config = tmp.path().join("xc");
let scratch = tmp.path().join("scratch");
write(&home.join(".lean-ctx/config.toml"), "real_legacy = true\n");
write(
&xdg_config.join("lean-ctx/config.toml"),
"real_xdg = true\n",
);
write(&scratch.join("config/config.toml"), "scratch = true\n");
let names = [
"HOME",
"XDG_CONFIG_HOME",
"LEAN_CTX_CONFIG_DIR",
"LEAN_CTX_DATA_DIR",
"LEAN_CTX_STATE_DIR",
"LEAN_CTX_CACHE_DIR",
];
let saved: Vec<_> = names.iter().map(|k| (*k, std::env::var_os(k))).collect();
crate::test_env::set_var("HOME", &home);
crate::test_env::set_var("XDG_CONFIG_HOME", &xdg_config);
crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", scratch.join("config"));
crate::test_env::set_var("LEAN_CTX_DATA_DIR", scratch.join("data"));
crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
crate::test_env::remove_var("LEAN_CTX_CACHE_DIR");
let legacy = heal_legacy_config();
let xdg = heal_xdg_stale_config();
for (k, v) in saved {
match v {
Some(value) => crate::test_env::set_var(k, value),
None => crate::test_env::remove_var(k),
}
}
assert_eq!(legacy, None);
assert_eq!(xdg, None);
assert_eq!(
std::fs::read_to_string(home.join(".lean-ctx/config.toml")).unwrap(),
"real_legacy = true\n"
);
assert!(!home.join(".lean-ctx/config.toml.superseded").exists());
assert_eq!(
std::fs::read_to_string(xdg_config.join("lean-ctx/config.toml")).unwrap(),
"real_xdg = true\n"
);
}
#[test]
fn is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let config_dir = tmp.path().join("config");
let data_dir = tmp.path().join("data");
write(&data_dir.join("config.toml"), "k = 1\n");
assert!(heal_between(&config_dir, &data_dir).is_some());
assert_eq!(heal_between(&config_dir, &data_dir), None);
}
}