Skip to main content

brainos_core/config/
migrate.rs

1//! Config-file migration across binary versions.
2//!
3//! The user's `~/.brain/config.yaml` carries a `brain.version` stamp — the app
4//! version that last wrote it. When a newer binary loads an older file, renamed
5//! or moved keys would be silently lost: figment drops keys the struct no longer
6//! has, and back-fills missing keys from the embedded defaults. The user's
7//! intent (a value they set under the *old* name) vanishes.
8//!
9//! This module runs **before** [`BrainConfig::load`](super::BrainConfig::load),
10//! operating on the raw YAML so renames survive into the typed struct. It:
11//!
12//! 1. reads the file's `brain.version` (the *from* version),
13//! 2. applies every [`ConfigMigration`] introduced in `(from, binary]`,
14//! 3. warns about keys the current schema no longer recognizes,
15//! 4. snapshots the prior file to `config.yaml.bak-v<from>` and rewrites it
16//!    with the new version stamp.
17//!
18//! An *older* binary reading a *newer* config needs no migration — figment is
19//! already tolerant of unknown keys — so the engine only ever moves forward.
20
21use std::path::Path;
22
23use serde_yaml::Value;
24
25use super::BrainConfig;
26
27/// One forward config transform, keyed by the version that introduced it.
28///
29/// `apply` mutates the root mapping in place (rename/move/default-fill/remove)
30/// and returns a human-readable line per change it made, for the migration log.
31pub struct ConfigMigration {
32    /// The binary version that first required this transform. Applied when the
33    /// file's stamped version is below it and the running binary is at or above.
34    pub introduced_in: &'static str,
35    /// Short description of what the transform does (for diagnostics).
36    pub description: &'static str,
37    /// The transform. Returns one log line per concrete change applied.
38    pub apply: fn(&mut serde_yaml::Mapping) -> Vec<String>,
39}
40
41/// Ordered registry of config migrations.
42///
43/// Empty today — no config field has been renamed or moved in the v0.x line, so
44/// there is nothing to transform yet. The machinery (version-stamp bump,
45/// backup, unknown-key warning) still runs. Append an entry here the moment a
46/// field changes shape between versions; the engine applies it automatically.
47pub(crate) const MIGRATIONS: &[ConfigMigration] = &[];
48
49/// What a migration pass did, for the caller to report.
50#[derive(Debug, Default, PartialEq, Eq)]
51pub struct MigrationOutcome {
52    pub from_version: String,
53    pub to_version: String,
54    /// One line per applied transform (plus the version bump itself).
55    pub changes: Vec<String>,
56    /// Dotted paths present in the user file but absent from the current
57    /// schema — likely typos or fields removed in a newer version.
58    pub unknown_keys: Vec<String>,
59    /// The path the snapshot was written to, if the file was rewritten.
60    pub backup_path: Option<std::path::PathBuf>,
61}
62
63impl BrainConfig {
64    /// Migrate the user config file at the default path forward to this binary's
65    /// version, if it is stamped older. Returns `Ok(None)` when there is nothing
66    /// to do (no file, unparseable, or already current). Errors only on a failed
67    /// backup or rewrite — a half-migrated file must never be left on disk.
68    pub fn migrate_user_config_if_needed() -> std::io::Result<Option<MigrationOutcome>> {
69        Self::migrate_config_at(&Self::user_config_path())
70    }
71
72    /// Path-taking core of [`migrate_user_config_if_needed`]. Separated so tests
73    /// can drive the full read → backup → rewrite round-trip against a temp file
74    /// without mutating the process-global `BRAIN_CONFIG` env var.
75    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        // A malformed file is not this layer's problem to report — the normal
81        // load path surfaces the parse error with full context. Skip quietly.
82        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        // Parse into an owned mapping for the transforms to mutate.
94        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        // Snapshot the prior file before overwriting it.
104        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
121/// `config.yaml` → `config.yaml.bak-v<from>` next to it.
122fn 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
130/// Read `brain.version` from a parsed config value.
131fn 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
139/// Apply every migration whose `introduced_in` is in `(from, to]`, then stamp
140/// `brain.version = to`. Pure over the mapping; no filesystem access. Returns
141/// the concatenated change log from each applied transform.
142fn 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        // (from, to]: strictly newer than the file, at or below the binary.
151        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
159/// Set `brain.version` to `to`, creating the `brain` mapping if absent.
160fn 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
172/// Parse a dotted `major.minor.patch` version into a comparable tuple. Missing
173/// or non-numeric components read as 0, so `"0.4"` and `"0.4.0"` compare equal
174/// and a junk stamp sorts oldest.
175fn parse_semver(v: &str) -> (u64, u64, u64) {
176    // Drop any pre-release/build suffix (`-rc1`, `+meta`).
177    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
188/// `a` is strictly an older version than `b`.
189fn is_older(a: &str, b: &str) -> bool {
190    parse_semver(a) < parse_semver(b)
191}
192
193/// Best-effort unknown-key detection: dotted paths present in `user` whose key
194/// is absent from the embedded default config tree. Only recurses through
195/// mappings — sequence elements (e.g. provider entries) are not walked, since
196/// their inner keys are list-item shapes, not top-level schema keys.
197fn 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        // Short and suffixed forms normalise.
241        assert!(!is_older("0.4", "0.4.0"));
242        assert_eq!(parse_semver("0.5.0-rc1"), (0, 5, 0));
243        // Junk stamp sorts oldest.
244        assert!(is_older("garbage", "0.0.1"));
245    }
246
247    #[test]
248    fn apply_migrations_runs_only_the_in_range_window() {
249        // Three synthetic migrations; the file is at 0.4.0, binary at 0.6.0, so
250        // only 0.5.0 and 0.6.0 should fire — not the already-applied 0.4.0.
251        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        // Version stamp advanced to the binary version.
290        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        // A representative rename transform: old.key -> new.key.
299        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        // The user's value survived under the new path; the old path is gone.
323        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    /// A unique scratch path under the system temp dir (no tempfile dev-dep).
329    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        // A real-shaped config stamped at an ancient version, with a user value
349        // we must not lose and an unrecognized key we should flag.
350        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        // Version advanced to this binary; a snapshot of the old file exists.
362        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        // The rewritten file is stamped current and the user value survived.
369        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        // Re-running is now a no-op (already at current version).
387        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        // `brain.data_dir` is real; `brain.bogus` and top-level `nonsense` are not.
396        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}