1use std::path::Path;
22
23use serde_yaml::Value;
24
25use super::BrainConfig;
26
27pub struct ConfigMigration {
32 pub introduced_in: &'static str,
35 pub description: &'static str,
37 pub apply: fn(&mut serde_yaml::Mapping) -> Vec<String>,
39}
40
41pub(crate) const MIGRATIONS: &[ConfigMigration] = &[];
48
49#[derive(Debug, Default, PartialEq, Eq)]
51pub struct MigrationOutcome {
52 pub from_version: String,
53 pub to_version: String,
54 pub changes: Vec<String>,
56 pub unknown_keys: Vec<String>,
59 pub backup_path: Option<std::path::PathBuf>,
61}
62
63impl BrainConfig {
64 pub fn migrate_user_config_if_needed() -> std::io::Result<Option<MigrationOutcome>> {
69 Self::migrate_config_at(&Self::user_config_path())
70 }
71
72 pub(crate) fn migrate_config_at(path: &Path) -> std::io::Result<Option<MigrationOutcome>> {
76 if !path.exists() {
77 return Ok(None);
78 }
79 let raw = std::fs::read_to_string(path)?;
80 let Ok(value) = serde_yaml::from_str::<Value>(&raw) else {
83 return Ok(None);
84 };
85
86 let from = config_version(&value).unwrap_or_else(|| "0.0.0".to_string());
87 let to = env!("CARGO_PKG_VERSION").to_string();
88
89 if !is_older(&from, &to) {
90 return Ok(None);
91 }
92
93 let Value::Mapping(mut root) = value else {
95 return Ok(None);
96 };
97
98 let mut changes = apply_migrations(&mut root, &from, &to, MIGRATIONS);
99 changes.push(format!("stamped brain.version {from} → {to}"));
100
101 let unknown_keys = unknown_keys_against_defaults(&Value::Mapping(root.clone()));
102
103 let backup_path = backup_sibling(path, &from);
105 std::fs::copy(path, &backup_path)?;
106
107 let rewritten = serde_yaml::to_string(&Value::Mapping(root))
108 .map_err(|e| std::io::Error::other(format!("serialize migrated config: {e}")))?;
109 std::fs::write(path, rewritten)?;
110
111 Ok(Some(MigrationOutcome {
112 from_version: from,
113 to_version: to,
114 changes,
115 unknown_keys,
116 backup_path: Some(backup_path),
117 }))
118 }
119}
120
121fn backup_sibling(path: &Path, from: &str) -> std::path::PathBuf {
123 let name = path
124 .file_name()
125 .and_then(|n| n.to_str())
126 .unwrap_or("config.yaml");
127 path.with_file_name(format!("{name}.bak-v{from}"))
128}
129
130fn config_version(value: &Value) -> Option<String> {
132 value
133 .get("brain")
134 .and_then(|b| b.get("version"))
135 .and_then(|v| v.as_str())
136 .map(str::to_string)
137}
138
139fn apply_migrations(
143 root: &mut serde_yaml::Mapping,
144 from: &str,
145 to: &str,
146 migrations: &[ConfigMigration],
147) -> Vec<String> {
148 let mut log = Vec::new();
149 for m in migrations {
150 if is_older(from, m.introduced_in) && !is_older(to, m.introduced_in) {
152 log.extend((m.apply)(root));
153 }
154 }
155 stamp_version(root, to);
156 log
157}
158
159fn stamp_version(root: &mut serde_yaml::Mapping, to: &str) {
161 let brain = root
162 .entry(Value::String("brain".into()))
163 .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
164 if let Value::Mapping(b) = brain {
165 b.insert(
166 Value::String("version".into()),
167 Value::String(to.to_string()),
168 );
169 }
170}
171
172fn parse_semver(v: &str) -> (u64, u64, u64) {
176 let core = v.split(['-', '+']).next().unwrap_or(v);
178 let mut it = core
179 .split('.')
180 .map(|p| p.trim().parse::<u64>().unwrap_or(0));
181 (
182 it.next().unwrap_or(0),
183 it.next().unwrap_or(0),
184 it.next().unwrap_or(0),
185 )
186}
187
188fn is_older(a: &str, b: &str) -> bool {
190 parse_semver(a) < parse_semver(b)
191}
192
193fn unknown_keys_against_defaults(user: &Value) -> Vec<String> {
198 let reference: Value = serde_yaml::from_str(super::DEFAULT_CONFIG)
199 .expect("embedded default.yaml must parse as YAML");
200 let mut out = Vec::new();
201 diff_keys(user, &reference, String::new(), &mut out);
202 out
203}
204
205fn diff_keys(user: &Value, reference: &Value, prefix: String, out: &mut Vec<String>) {
206 let (Value::Mapping(u), Value::Mapping(r)) = (user, reference) else {
207 return;
208 };
209 for (k, uv) in u {
210 let Some(key) = k.as_str() else { continue };
211 let path = if prefix.is_empty() {
212 key.to_string()
213 } else {
214 format!("{prefix}.{key}")
215 };
216 match r.get(k) {
217 None => out.push(path),
218 Some(rv) => diff_keys(uv, rv, path, out),
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn map_from(yaml: &str) -> serde_yaml::Mapping {
228 match serde_yaml::from_str::<Value>(yaml).unwrap() {
229 Value::Mapping(m) => m,
230 _ => panic!("expected a mapping"),
231 }
232 }
233
234 #[test]
235 fn semver_ordering_is_numeric_not_lexical() {
236 assert!(is_older("0.9.0", "0.10.0"), "0.9 < 0.10 numerically");
237 assert!(is_older("0.4.0", "0.4.1"));
238 assert!(!is_older("0.4.0", "0.4.0"));
239 assert!(!is_older("1.0.0", "0.9.9"));
240 assert!(!is_older("0.4", "0.4.0"));
242 assert_eq!(parse_semver("0.5.0-rc1"), (0, 5, 0));
243 assert!(is_older("garbage", "0.0.1"));
245 }
246
247 #[test]
248 fn apply_migrations_runs_only_the_in_range_window() {
249 fn tag(root: &mut serde_yaml::Mapping, key: &str) -> Vec<String> {
252 root.insert(Value::String(key.into()), Value::Bool(true));
253 vec![format!("set {key}")]
254 }
255 const MS: &[ConfigMigration] = &[
256 ConfigMigration {
257 introduced_in: "0.4.0",
258 description: "already applied",
259 apply: |r| tag(r, "m040"),
260 },
261 ConfigMigration {
262 introduced_in: "0.5.0",
263 description: "in range",
264 apply: |r| tag(r, "m050"),
265 },
266 ConfigMigration {
267 introduced_in: "0.6.0",
268 description: "in range (== binary)",
269 apply: |r| tag(r, "m060"),
270 },
271 ];
272
273 let mut root = map_from("brain:\n version: \"0.4.0\"\n");
274 let log = apply_migrations(&mut root, "0.4.0", "0.6.0", MS);
275
276 assert!(
277 !root.contains_key(Value::String("m040".into())),
278 "0.4.0 already in the file"
279 );
280 assert!(
281 root.contains_key(Value::String("m050".into())),
282 "0.5.0 in (0.4.0, 0.6.0]"
283 );
284 assert!(
285 root.contains_key(Value::String("m060".into())),
286 "0.6.0 in (0.4.0, 0.6.0]"
287 );
288 assert_eq!(log.len(), 2, "two transforms fired");
289 assert_eq!(
291 config_version(&Value::Mapping(root)).as_deref(),
292 Some("0.6.0")
293 );
294 }
295
296 #[test]
297 fn migration_renames_a_field_without_losing_intent() {
298 const RENAME: &[ConfigMigration] = &[ConfigMigration {
300 introduced_in: "0.5.0",
301 description: "rename legacy.timeout -> network.timeout",
302 apply: |root| {
303 let legacy = root.remove(Value::String("legacy".into()));
304 if let Some(Value::Mapping(mut l)) = legacy {
305 if let Some(t) = l.remove(Value::String("timeout".into())) {
306 let net = root
307 .entry(Value::String("network".into()))
308 .or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
309 if let Value::Mapping(n) = net {
310 n.insert(Value::String("timeout".into()), t);
311 }
312 return vec!["moved legacy.timeout → network.timeout".into()];
313 }
314 }
315 vec![]
316 },
317 }];
318
319 let mut root = map_from("brain:\n version: \"0.4.0\"\nlegacy:\n timeout: 42\n");
320 apply_migrations(&mut root, "0.4.0", "0.5.0", RENAME);
321
322 assert!(!root.contains_key(Value::String("legacy".into())));
324 let net = root.get(Value::String("network".into())).unwrap();
325 assert_eq!(net.get("timeout").and_then(Value::as_i64), Some(42));
326 }
327
328 fn scratch_path(tag: &str) -> std::path::PathBuf {
330 let nanos = std::time::SystemTime::now()
331 .duration_since(std::time::UNIX_EPOCH)
332 .unwrap()
333 .as_nanos();
334 std::env::temp_dir().join(format!(
335 "brain-migrate-{tag}-{}-{nanos}.yaml",
336 std::process::id()
337 ))
338 }
339
340 #[test]
341 fn migrate_config_at_is_noop_when_file_absent() {
342 let path = scratch_path("absent");
343 assert!(BrainConfig::migrate_config_at(&path).unwrap().is_none());
344 }
345
346 #[test]
347 fn migrate_config_at_round_trips_file_and_snapshots() {
348 let path = scratch_path("roundtrip");
351 std::fs::write(
352 &path,
353 "brain:\n version: \"0.0.1\"\n data_dir: \"~/.brain\"\n bogus_key: 7\n",
354 )
355 .unwrap();
356
357 let outcome = BrainConfig::migrate_config_at(&path)
358 .unwrap()
359 .expect("an older file must migrate");
360
361 assert_eq!(outcome.from_version, "0.0.1");
363 assert_eq!(outcome.to_version, env!("CARGO_PKG_VERSION"));
364 let backup = outcome.backup_path.clone().unwrap();
365 assert!(backup.exists(), "expected snapshot at {}", backup.display());
366 assert!(backup.to_string_lossy().contains(".bak-v0.0.1"));
367
368 let rewritten = std::fs::read_to_string(&path).unwrap();
370 let value: Value = serde_yaml::from_str(&rewritten).unwrap();
371 assert_eq!(
372 config_version(&value).as_deref(),
373 Some(env!("CARGO_PKG_VERSION"))
374 );
375 assert_eq!(
376 value
377 .get("brain")
378 .and_then(|b| b.get("data_dir"))
379 .and_then(Value::as_str),
380 Some("~/.brain")
381 );
382 assert!(outcome
383 .unknown_keys
384 .contains(&"brain.bogus_key".to_string()));
385
386 assert!(BrainConfig::migrate_config_at(&path).unwrap().is_none());
388
389 let _ = std::fs::remove_file(&path);
390 let _ = std::fs::remove_file(&backup);
391 }
392
393 #[test]
394 fn unknown_keys_flags_only_unrecognized_paths() {
395 let user = serde_yaml::from_str::<Value>(
397 "brain:\n data_dir: \"~/.brain\"\n bogus: 1\nnonsense:\n x: 2\n",
398 )
399 .unwrap();
400 let unknown = unknown_keys_against_defaults(&user);
401 assert!(
402 unknown.contains(&"brain.bogus".to_string()),
403 "got {unknown:?}"
404 );
405 assert!(unknown.contains(&"nonsense".to_string()), "got {unknown:?}");
406 assert!(
407 !unknown.contains(&"brain.data_dir".to_string()),
408 "data_dir is valid"
409 );
410 }
411}