use std::path::Path;
use serde_yaml::Value;
use super::BrainConfig;
pub struct ConfigMigration {
pub introduced_in: &'static str,
pub description: &'static str,
pub apply: fn(&mut serde_yaml::Mapping) -> Vec<String>,
}
pub(crate) const MIGRATIONS: &[ConfigMigration] = &[];
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MigrationOutcome {
pub from_version: String,
pub to_version: String,
pub changes: Vec<String>,
pub unknown_keys: Vec<String>,
pub backup_path: Option<std::path::PathBuf>,
}
impl BrainConfig {
pub fn migrate_user_config_if_needed() -> std::io::Result<Option<MigrationOutcome>> {
Self::migrate_config_at(&Self::user_config_path())
}
pub(crate) fn migrate_config_at(path: &Path) -> std::io::Result<Option<MigrationOutcome>> {
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(path)?;
let Ok(value) = serde_yaml::from_str::<Value>(&raw) else {
return Ok(None);
};
let from = config_version(&value).unwrap_or_else(|| "0.0.0".to_string());
let to = env!("CARGO_PKG_VERSION").to_string();
if !is_older(&from, &to) {
return Ok(None);
}
let Value::Mapping(mut root) = value else {
return Ok(None);
};
let mut changes = apply_migrations(&mut root, &from, &to, MIGRATIONS);
changes.push(format!("stamped brain.version {from} → {to}"));
let unknown_keys = unknown_keys_against_defaults(&Value::Mapping(root.clone()));
let backup_path = backup_sibling(path, &from);
std::fs::copy(path, &backup_path)?;
let rewritten = serde_yaml::to_string(&Value::Mapping(root))
.map_err(|e| std::io::Error::other(format!("serialize migrated config: {e}")))?;
std::fs::write(path, rewritten)?;
Ok(Some(MigrationOutcome {
from_version: from,
to_version: to,
changes,
unknown_keys,
backup_path: Some(backup_path),
}))
}
}
fn backup_sibling(path: &Path, from: &str) -> std::path::PathBuf {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config.yaml");
path.with_file_name(format!("{name}.bak-v{from}"))
}
fn config_version(value: &Value) -> Option<String> {
value
.get("brain")
.and_then(|b| b.get("version"))
.and_then(|v| v.as_str())
.map(str::to_string)
}
fn apply_migrations(
root: &mut serde_yaml::Mapping,
from: &str,
to: &str,
migrations: &[ConfigMigration],
) -> Vec<String> {
let mut log = Vec::new();
for m in migrations {
if is_older(from, m.introduced_in) && !is_older(to, m.introduced_in) {
log.extend((m.apply)(root));
}
}
stamp_version(root, to);
log
}
fn stamp_version(root: &mut serde_yaml::Mapping, to: &str) {
let brain = root
.entry(Value::String("brain".into()))
.or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
if let Value::Mapping(b) = brain {
b.insert(
Value::String("version".into()),
Value::String(to.to_string()),
);
}
}
fn parse_semver(v: &str) -> (u64, u64, u64) {
let core = v.split(['-', '+']).next().unwrap_or(v);
let mut it = core
.split('.')
.map(|p| p.trim().parse::<u64>().unwrap_or(0));
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
}
fn is_older(a: &str, b: &str) -> bool {
parse_semver(a) < parse_semver(b)
}
fn unknown_keys_against_defaults(user: &Value) -> Vec<String> {
let reference: Value = serde_yaml::from_str(super::DEFAULT_CONFIG)
.expect("embedded default.yaml must parse as YAML");
let mut out = Vec::new();
diff_keys(user, &reference, String::new(), &mut out);
out
}
fn diff_keys(user: &Value, reference: &Value, prefix: String, out: &mut Vec<String>) {
let (Value::Mapping(u), Value::Mapping(r)) = (user, reference) else {
return;
};
for (k, uv) in u {
let Some(key) = k.as_str() else { continue };
let path = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
match r.get(k) {
None => out.push(path),
Some(rv) => diff_keys(uv, rv, path, out),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map_from(yaml: &str) -> serde_yaml::Mapping {
match serde_yaml::from_str::<Value>(yaml).unwrap() {
Value::Mapping(m) => m,
_ => panic!("expected a mapping"),
}
}
#[test]
fn semver_ordering_is_numeric_not_lexical() {
assert!(is_older("0.9.0", "0.10.0"), "0.9 < 0.10 numerically");
assert!(is_older("0.4.0", "0.4.1"));
assert!(!is_older("0.4.0", "0.4.0"));
assert!(!is_older("1.0.0", "0.9.9"));
assert!(!is_older("0.4", "0.4.0"));
assert_eq!(parse_semver("0.5.0-rc1"), (0, 5, 0));
assert!(is_older("garbage", "0.0.1"));
}
#[test]
fn apply_migrations_runs_only_the_in_range_window() {
fn tag(root: &mut serde_yaml::Mapping, key: &str) -> Vec<String> {
root.insert(Value::String(key.into()), Value::Bool(true));
vec![format!("set {key}")]
}
const MS: &[ConfigMigration] = &[
ConfigMigration {
introduced_in: "0.4.0",
description: "already applied",
apply: |r| tag(r, "m040"),
},
ConfigMigration {
introduced_in: "0.5.0",
description: "in range",
apply: |r| tag(r, "m050"),
},
ConfigMigration {
introduced_in: "0.6.0",
description: "in range (== binary)",
apply: |r| tag(r, "m060"),
},
];
let mut root = map_from("brain:\n version: \"0.4.0\"\n");
let log = apply_migrations(&mut root, "0.4.0", "0.6.0", MS);
assert!(
!root.contains_key(Value::String("m040".into())),
"0.4.0 already in the file"
);
assert!(
root.contains_key(Value::String("m050".into())),
"0.5.0 in (0.4.0, 0.6.0]"
);
assert!(
root.contains_key(Value::String("m060".into())),
"0.6.0 in (0.4.0, 0.6.0]"
);
assert_eq!(log.len(), 2, "two transforms fired");
assert_eq!(
config_version(&Value::Mapping(root)).as_deref(),
Some("0.6.0")
);
}
#[test]
fn migration_renames_a_field_without_losing_intent() {
const RENAME: &[ConfigMigration] = &[ConfigMigration {
introduced_in: "0.5.0",
description: "rename legacy.timeout -> network.timeout",
apply: |root| {
let legacy = root.remove(Value::String("legacy".into()));
if let Some(Value::Mapping(mut l)) = legacy {
if let Some(t) = l.remove(Value::String("timeout".into())) {
let net = root
.entry(Value::String("network".into()))
.or_insert_with(|| Value::Mapping(serde_yaml::Mapping::new()));
if let Value::Mapping(n) = net {
n.insert(Value::String("timeout".into()), t);
}
return vec!["moved legacy.timeout → network.timeout".into()];
}
}
vec![]
},
}];
let mut root = map_from("brain:\n version: \"0.4.0\"\nlegacy:\n timeout: 42\n");
apply_migrations(&mut root, "0.4.0", "0.5.0", RENAME);
assert!(!root.contains_key(Value::String("legacy".into())));
let net = root.get(Value::String("network".into())).unwrap();
assert_eq!(net.get("timeout").and_then(Value::as_i64), Some(42));
}
fn scratch_path(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"brain-migrate-{tag}-{}-{nanos}.yaml",
std::process::id()
))
}
#[test]
fn migrate_config_at_is_noop_when_file_absent() {
let path = scratch_path("absent");
assert!(BrainConfig::migrate_config_at(&path).unwrap().is_none());
}
#[test]
fn migrate_config_at_round_trips_file_and_snapshots() {
let path = scratch_path("roundtrip");
std::fs::write(
&path,
"brain:\n version: \"0.0.1\"\n data_dir: \"~/.brain\"\n bogus_key: 7\n",
)
.unwrap();
let outcome = BrainConfig::migrate_config_at(&path)
.unwrap()
.expect("an older file must migrate");
assert_eq!(outcome.from_version, "0.0.1");
assert_eq!(outcome.to_version, env!("CARGO_PKG_VERSION"));
let backup = outcome.backup_path.clone().unwrap();
assert!(backup.exists(), "expected snapshot at {}", backup.display());
assert!(backup.to_string_lossy().contains(".bak-v0.0.1"));
let rewritten = std::fs::read_to_string(&path).unwrap();
let value: Value = serde_yaml::from_str(&rewritten).unwrap();
assert_eq!(
config_version(&value).as_deref(),
Some(env!("CARGO_PKG_VERSION"))
);
assert_eq!(
value
.get("brain")
.and_then(|b| b.get("data_dir"))
.and_then(Value::as_str),
Some("~/.brain")
);
assert!(outcome
.unknown_keys
.contains(&"brain.bogus_key".to_string()));
assert!(BrainConfig::migrate_config_at(&path).unwrap().is_none());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&backup);
}
#[test]
fn unknown_keys_flags_only_unrecognized_paths() {
let user = serde_yaml::from_str::<Value>(
"brain:\n data_dir: \"~/.brain\"\n bogus: 1\nnonsense:\n x: 2\n",
)
.unwrap();
let unknown = unknown_keys_against_defaults(&user);
assert!(
unknown.contains(&"brain.bogus".to_string()),
"got {unknown:?}"
);
assert!(unknown.contains(&"nonsense".to_string()), "got {unknown:?}");
assert!(
!unknown.contains(&"brain.data_dir".to_string()),
"data_dir is valid"
);
}
}