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 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
50pub 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
63fn heal_between(config_dir: &Path, data_dir: &Path) -> Option<ConfigHealReport> {
65 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 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 move_overwrite(&stray, &canonical).ok()?;
95 Some(ConfigHealReport {
96 action: HealAction::Adopted,
97 from: stray,
98 to: canonical,
99 })
100}
101
102fn file_has_content(p: &Path) -> bool {
104 std::fs::read_to_string(p).is_ok_and(|s| !s.trim().is_empty())
105}
106
107fn 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 assert_eq!(
163 std::fs::read_to_string(config_dir.join("config.toml")).unwrap(),
164 "ultra_compact = true\n"
165 );
166 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 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 assert_eq!(heal_between(&config_dir, &data_dir), None);
202 }
203}