use std::collections::BTreeSet;
use std::path::Path;
use similar::{ChangeTag, TextDiff};
use zeph_core::config::Config;
use zeph_core::config::migrate::{ConfigMigrator, MIGRATIONS, MigrationResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct MigrateSummary {
pub(crate) total_changed_count: usize,
pub(crate) total_sections_changed: usize,
}
pub(crate) fn handle_migrate_config(
config_path: &Path,
in_place: bool,
diff: bool,
) -> anyhow::Result<MigrateSummary> {
let input = if config_path.exists() {
std::fs::read_to_string(config_path)?
} else {
String::new()
};
let mut current = input.clone();
let mut step_results: Vec<(&str, MigrationResult)> = Vec::with_capacity(MIGRATIONS.len());
for migration in MIGRATIONS.iter() {
let result = migration.apply(¤t)?;
current.clone_from(&result.output);
step_results.push((migration.name(), result));
}
let migrator = ConfigMigrator::new();
let result = migrator.migrate(¤t)?;
let (total_changed_count, total_sections_changed) = aggregate_totals(&step_results, &result);
let total_sections_len = total_sections_changed.len();
warn_if_migrated_config_invalid(&result.output);
if diff {
print_diff(&input, &result.output);
for (name, step_result) in &step_results {
if step_result.changed_count > 0 {
eprintln!(
"{}: {} change(s) (sections: {})",
name,
step_result.changed_count,
if step_result.sections_changed.is_empty() {
"none".to_owned()
} else {
step_result.sections_changed.join(", ")
}
);
}
}
eprintln!(
"Migration would add {total_changed_count} entries ({total_sections_len} sections)."
);
} else if in_place {
atomic_write(config_path, &result.output)?;
eprintln!(
"Config migrated in-place: {} ({} entries added, sections: {})",
config_path.display(),
total_changed_count,
if total_sections_changed.is_empty() {
"none".to_owned()
} else {
total_sections_changed
.into_iter()
.collect::<Vec<_>>()
.join(", ")
}
);
} else {
print!("{}", result.output);
}
Ok(MigrateSummary {
total_changed_count,
total_sections_changed: total_sections_len,
})
}
fn warn_if_migrated_config_invalid(migrated_toml: &str) {
if let Err(err) = toml::from_str::<Config>(migrated_toml) {
eprintln!(
"warning: the migrated config would fail to load: {err}\n\
migrate-config only adds missing keys — it does not fix invalid existing \
values. Fix the reported error in the config file before relying on it."
);
}
}
fn aggregate_totals<'a>(
step_results: &'a [(&str, MigrationResult)],
final_result: &'a MigrationResult,
) -> (usize, BTreeSet<&'a str>) {
let total_changed_count = step_results
.iter()
.map(|(_, r)| r.changed_count)
.sum::<usize>()
+ final_result.changed_count;
let total_sections_changed: BTreeSet<&str> = step_results
.iter()
.flat_map(|(_, r)| r.sections_changed.iter().map(String::as_str))
.chain(final_result.sections_changed.iter().map(String::as_str))
.collect();
(total_changed_count, total_sections_changed)
}
fn print_diff(old: &str, new: &str) {
let diff = TextDiff::from_lines(old, new);
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => print!(" {change}"),
ChangeTag::Insert => print!("+{change}"),
ChangeTag::Delete => print!("-{change}"),
}
}
}
fn atomic_write(path: &Path, content: &str) -> anyhow::Result<()> {
use std::io::Write;
let original_perms = if path.exists() {
Some(std::fs::metadata(path)?.permissions())
} else {
None
};
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(content.as_bytes())?;
tmp.flush()?;
tmp.as_file().sync_all()?;
if let Some(perms) = original_perms {
std::fs::set_permissions(tmp.path(), perms)?;
}
tmp.persist(path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::io::Write as _;
use tempfile::NamedTempFile;
use super::*;
fn result(changed_count: usize, sections: &[&str]) -> MigrationResult {
MigrationResult {
output: String::new(),
changed_count,
sections_changed: sections.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[test]
fn aggregate_totals_sums_named_step_changes_when_catchall_is_a_no_op() {
let step_results = vec![
("rename_embed_provider", result(2, &["memory"])),
("add_goals_section", result(1, &["goals"])),
];
let final_result = result(0, &[]);
let (total_changed, total_sections) = aggregate_totals(&step_results, &final_result);
assert_eq!(total_changed, 3);
assert_eq!(total_sections.len(), 2);
assert!(total_sections.contains("memory"));
assert!(total_sections.contains("goals"));
}
#[test]
fn aggregate_totals_deduplicates_sections_touched_by_both_passes() {
let step_results = vec![("rename_embed_provider", result(2, &["memory"]))];
let final_result = result(1, &["memory", "goals"]);
let (total_changed, total_sections) = aggregate_totals(&step_results, &final_result);
assert_eq!(total_changed, 3);
assert_eq!(total_sections.len(), 2);
assert!(total_sections.contains("memory"));
assert!(total_sections.contains("goals"));
}
#[test]
fn aggregate_totals_empty_when_nothing_changed() {
let final_result = result(0, &[]);
let (total_changed, total_sections) = aggregate_totals(&[], &final_result);
assert_eq!(total_changed, 0);
assert!(total_sections.is_empty());
}
#[test]
fn handle_migrate_config_in_place_writes_migrated_output() {
let mut file = NamedTempFile::new().expect("create temp config file");
file.write_all(b"").expect("write empty config");
let path = file.path().to_path_buf();
let summary = handle_migrate_config(&path, true, false).expect("migration succeeds");
assert!(summary.total_changed_count > 0);
assert!(summary.total_sections_changed > 0);
let migrated = std::fs::read_to_string(&path).expect("read migrated config");
assert!(
!migrated.is_empty(),
"migration should add content to an empty config"
);
}
#[test]
fn warn_if_migrated_config_invalid_detects_bogus_vault_backend() {
let default_toml = toml::to_string(&Config::default()).expect("serialize default config");
let mut value: toml::Value = toml::from_str(&default_toml).expect("parse as toml value");
value["vault"]["backend"] = toml::Value::String("bogus_backend_name".to_owned());
let corrupted = toml::to_string(&value).expect("serialize corrupted config");
assert!(toml::from_str::<Config>(&corrupted).is_err());
}
#[test]
fn warn_if_migrated_config_invalid_accepts_valid_config() {
let default_toml = toml::to_string(&Config::default()).expect("serialize default config");
assert!(toml::from_str::<Config>(&default_toml).is_ok());
}
#[test]
fn handle_migrate_config_with_invalid_vault_backend_still_succeeds() {
let raw = "[vault]\nbackend = \"bogus_backend_name\"\n";
let mut file = NamedTempFile::new().expect("create temp config file");
file.write_all(raw.as_bytes()).expect("write config");
let path = file.path().to_path_buf();
handle_migrate_config(&path, false, true)
.expect("migrate-config must not hard-fail on an invalid existing value");
}
#[test]
fn handle_migrate_config_diff_mode_does_not_modify_the_file() {
let mut file = NamedTempFile::new().expect("create temp config file");
file.write_all(b"").expect("write empty config");
let path = file.path().to_path_buf();
let summary = handle_migrate_config(&path, false, true).expect("migration succeeds");
assert!(summary.total_changed_count > 0);
let unchanged = std::fs::read_to_string(&path).expect("read config after diff");
assert!(unchanged.is_empty(), "diff mode must not write to disk");
}
#[test]
fn handle_migrate_config_summary_includes_named_step_contributions() {
let raw = "[index]\nembed_provider = \"openai\"\n";
let mut file = NamedTempFile::new().expect("create temp config file");
file.write_all(raw.as_bytes()).expect("write config");
let path = file.path().to_path_buf();
let mut current = raw.to_owned();
for migration in MIGRATIONS.iter() {
current = migration
.apply(¤t)
.expect("named step applies")
.output;
}
let catchall_only = ConfigMigrator::new()
.migrate(¤t)
.expect("catch-all pass migrates");
let summary = handle_migrate_config(&path, true, false).expect("migration succeeds");
assert!(
summary.total_changed_count > catchall_only.changed_count,
"summary total ({}) must exceed the catch-all-only count ({}); named step \
contributions (e.g. the embed_provider rename) must be included",
summary.total_changed_count,
catchall_only.changed_count,
);
}
}