use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HealAction {
Adopted,
Superseded,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfigHealReport {
pub action: HealAction,
pub from: PathBuf,
pub to: PathBuf,
}
pub(crate) 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> {
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> {
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(crate) 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"
);
}
#[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);
}
}