1use anyhow::{Context, Result};
2use std::path::Path;
3
4use crate::schema::Config;
5use crate::schema::v1::V1Config;
6use crate::schema::v2::V2Config;
7
8pub const CURRENT_SCHEMA_VERSION: u32 = 3;
10
11pub const V1_LEGACY_KEYS: &[&str] = &[
17 "api_key",
18 "api_url",
19 "api_path",
20 "default_model_provider",
21 "default_model",
22 "model_providers",
23 "default_temperature",
24 "provider_timeout_secs",
25 "provider_max_tokens",
26 "extra_headers",
27 "model_routes",
28 "embedding_routes",
29 "channels_config",
30 "autonomy",
31 "agent",
32 "swarms",
33 "cron",
34];
35
36pub fn detect_version(value: &toml::Value) -> Result<u32> {
42 let table = value
43 .as_table()
44 .context("config root must be a TOML table")?;
45 match table.get("schema_version") {
46 None => Ok(1),
47 Some(toml::Value::Integer(n)) if *n >= 1 => Ok(*n as u32),
48 Some(other) => {
49 ::zeroclaw_log::record!(
50 ERROR,
51 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
52 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
53 .with_attrs(::serde_json::json!({"found": other.to_string()})),
54 "config schema_version is not a positive integer"
55 );
56 anyhow::bail!("schema_version must be a positive integer, got {other}")
57 }
58 }
59}
60
61pub fn migrate_file(input: &str) -> Result<Option<String>> {
71 let value: toml::Value = toml::from_str(input).context("failed to parse config TOML")?;
72 let from = detect_version(&value)?;
73 if from == CURRENT_SCHEMA_VERSION {
74 return Ok(None);
75 }
76 if from > CURRENT_SCHEMA_VERSION {
77 ::zeroclaw_log::record!(
78 ERROR,
79 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
80 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
81 .with_attrs(::serde_json::json!({
82 "from_version": from,
83 "supported_version": CURRENT_SCHEMA_VERSION,
84 })),
85 "config schema_version is newer than this binary supports"
86 );
87 anyhow::bail!(
88 "config schema_version {from} is newer than this binary supports ({CURRENT_SCHEMA_VERSION})"
89 );
90 }
91 let migrated_value = run_chain(value, from)?;
92 let migrated_table = match migrated_value {
93 toml::Value::Table(t) => t,
94 _ => {
95 anyhow::bail!("migrated config is not a TOML table");
96 }
97 };
98
99 if let Ok(mut doc) = input.parse::<toml_edit::DocumentMut>() {
103 sync_table(doc.as_table_mut(), &migrated_table);
104 Ok(Some(doc.to_string()))
105 } else {
106 let serialized = toml::to_string_pretty(&toml::Value::Table(migrated_table))
107 .context("failed to serialize migrated config")?;
108 Ok(Some(serialized))
109 }
110}
111
112const V1_FIXTURE: &str = include_str!("../fixtures/v1.toml");
116
117#[derive(Debug, Default, Clone)]
119pub struct GenerateOptions<'a> {
120 pub encrypt_secrets: bool,
125 pub secret_store_dir: Option<&'a Path>,
130}
131
132pub fn generate(target_version: u32, opts: &GenerateOptions<'_>) -> Result<String> {
147 if target_version == 0 || target_version > CURRENT_SCHEMA_VERSION {
148 anyhow::bail!(
149 "unsupported schema version {target_version} \
150 (valid: 1..={CURRENT_SCHEMA_VERSION})"
151 );
152 }
153
154 let value = if target_version == 1 {
155 toml::from_str::<toml::Value>(V1_FIXTURE).context("embedded V1 fixture is malformed")?
156 } else {
157 let v1_value: toml::Value =
158 toml::from_str(V1_FIXTURE).context("embedded V1 fixture is malformed")?;
159 run_chain_until(v1_value, 1, target_version)?
160 };
161
162 let mut value = value;
163 if opts.encrypt_secrets {
164 let store_dir = opts.secret_store_dir.context(
165 "--encrypt requires a secret-store directory \
166 (typically the resolved ZEROCLAW_CONFIG_DIR)",
167 )?;
168 let store = crate::secrets::SecretStore::new(store_dir, true);
169 encrypt_secret_strings(&mut value, &store)
170 .context("failed to encrypt secret-bearing fields in generated config")?;
171 }
172
173 toml::to_string_pretty(&value).context("failed to serialize generated config")
174}
175
176fn secret_key_names() -> &'static std::collections::HashSet<&'static str> {
189 use std::collections::HashSet;
190 use std::sync::OnceLock;
191 static CACHE: OnceLock<HashSet<&'static str>> = OnceLock::new();
192 CACHE.get_or_init(|| Config::secret_field_terminals().into_iter().collect())
193}
194
195pub fn encrypt_secret_strings(
205 value: &mut toml::Value,
206 store: &crate::secrets::SecretStore,
207) -> Result<()> {
208 let names = secret_key_names();
209 encrypt_walk(value, store, names)
210}
211
212fn encrypt_walk(
213 value: &mut toml::Value,
214 store: &crate::secrets::SecretStore,
215 names: &std::collections::HashSet<&'static str>,
216) -> Result<()> {
217 match value {
218 toml::Value::Table(table) => {
219 for (key, child) in table.iter_mut() {
220 if names.contains(key.as_str()) {
221 encrypt_in_place(child, store)
222 .with_context(|| format!("encrypting secret at key `{key}`"))?;
223 } else {
224 encrypt_walk(child, store, names)?;
225 }
226 }
227 }
228 toml::Value::Array(items) => {
229 for item in items.iter_mut() {
230 encrypt_walk(item, store, names)?;
231 }
232 }
233 _ => {}
234 }
235 Ok(())
236}
237
238fn encrypt_in_place(value: &mut toml::Value, store: &crate::secrets::SecretStore) -> Result<()> {
250 match value {
251 toml::Value::String(s)
252 if !crate::secrets::SecretStore::is_encrypted(s) && !s.is_empty() =>
253 {
254 let encrypted = store.encrypt(s).context("encrypt string")?;
255 *s = encrypted;
256 }
257 toml::Value::Array(items) => {
258 for item in items.iter_mut() {
259 encrypt_in_place(item, store)?;
260 }
261 }
262 toml::Value::Table(table) => {
263 for (_, child) in table.iter_mut() {
264 encrypt_in_place(child, store)?;
265 }
266 }
267 _ => {}
268 }
269 Ok(())
270}
271
272pub fn migrate_to_current(input: &str) -> Result<Config> {
275 let value: toml::Value = toml::from_str(input).context("failed to parse config TOML")?;
276 let from = detect_version(&value)?;
277 let final_value = if from == CURRENT_SCHEMA_VERSION {
278 value
279 } else if from > CURRENT_SCHEMA_VERSION {
280 ::zeroclaw_log::record!(
281 ERROR,
282 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
283 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
284 .with_attrs(::serde_json::json!({
285 "from_version": from,
286 "supported_version": CURRENT_SCHEMA_VERSION,
287 })),
288 "config schema_version is newer than this binary supports"
289 );
290 anyhow::bail!(
291 "config schema_version {from} is newer than this binary supports ({CURRENT_SCHEMA_VERSION})"
292 );
293 } else {
294 run_chain(value, from)?
295 };
296 final_value
297 .try_into()
298 .context("migrated config failed to deserialize as current schema")
299}
300
301pub fn migrate_file_in_place(path: &Path) -> Result<Option<MigrateReport>> {
318 let raw = std::fs::read_to_string(path)
319 .with_context(|| format!("failed to read config at {}", path.display().to_string()))?;
320 let migrated = match migrate_file(&raw)? {
321 Some(s) => s,
322 None => return Ok(None),
323 };
324 let parent = path.parent().with_context(|| {
325 format!(
326 "config path {} has no parent directory",
327 path.display().to_string()
328 )
329 })?;
330 let file_name = path.file_name().and_then(|s| s.to_str()).with_context(|| {
331 format!(
332 "config path {} has no file name",
333 path.display().to_string()
334 )
335 })?;
336 let backup_path = parent.join(format!("{file_name}.backup"));
337 let temp_path = parent.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
338
339 {
341 let mut temp = std::fs::OpenOptions::new()
342 .create_new(true)
343 .write(true)
344 .open(&temp_path)
345 .with_context(|| {
346 format!(
347 "failed to create temporary migrated config at {}",
348 temp_path.display()
349 )
350 })?;
351 std::io::Write::write_all(&mut temp, migrated.as_bytes()).with_context(|| {
352 format!(
353 "failed to write migrated config to {}",
354 temp_path.display().to_string()
355 )
356 })?;
357 temp.sync_all().with_context(|| {
358 format!(
359 "failed to fsync temporary migrated config at {}",
360 temp_path.display()
361 )
362 })?;
363 }
364
365 std::fs::copy(path, &backup_path).with_context(|| {
367 format!(
368 "failed to write backup {} before migration (temp file intact at {})",
369 backup_path.display().to_string(),
370 temp_path.display().to_string(),
371 )
372 })?;
373
374 if let Err(rename_err) = std::fs::rename(&temp_path, path) {
377 let _ = std::fs::remove_file(&temp_path);
378 if backup_path.exists() {
379 let _ = std::fs::copy(&backup_path, path);
380 }
381 ::zeroclaw_log::record!(
382 ERROR,
383 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
384 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
385 .with_attrs(::serde_json::json!({
386 "path": path.display().to_string(),
387 "backup_path": backup_path.display().to_string(),
388 "error": format!("{}", rename_err),
389 })),
390 "atomic rename failed during config migration"
391 );
392 anyhow::bail!(
393 "failed to atomically replace {} with migrated config: {rename_err} \
394 (backup retained at {})",
395 path.display().to_string(),
396 backup_path.display().to_string(),
397 );
398 }
399
400 sync_directory(parent).with_context(|| {
402 format!(
403 "failed to fsync parent directory after migration: {}",
404 parent.display()
405 )
406 })?;
407
408 Ok(Some(MigrateReport {
409 backup_path,
410 to_version: CURRENT_SCHEMA_VERSION,
411 }))
412}
413
414#[allow(clippy::unused_async)] fn sync_directory(path: &Path) -> Result<()> {
418 #[cfg(unix)]
419 {
420 let dir = std::fs::File::open(path).with_context(|| {
421 format!(
422 "failed to open directory for fsync: {}",
423 path.display().to_string()
424 )
425 })?;
426 dir.sync_all().with_context(|| {
427 format!("failed to fsync directory: {}", path.display().to_string())
428 })?;
429 }
430 #[cfg(not(unix))]
431 {
432 let _ = std::fs::File::open(path);
436 }
437 Ok(())
438}
439
440#[derive(Debug, Clone)]
443pub struct MigrateReport {
444 pub backup_path: std::path::PathBuf,
445 pub to_version: u32,
446}
447
448pub fn ensure_disk_at_current_version(path: &Path) -> Result<()> {
461 let raw = match std::fs::read_to_string(path) {
462 Ok(s) => s,
463 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
464 Err(e) => {
465 return Err(anyhow::Error::from(e)).with_context(|| {
466 format!("failed to read config at {}", path.display().to_string())
467 });
468 }
469 };
470 let value: toml::Value =
471 toml::from_str(&raw).context("failed to parse config TOML for version check")?;
472 let from = detect_version(&value)?;
473 if from == CURRENT_SCHEMA_VERSION {
474 return Ok(());
475 }
476 if from > CURRENT_SCHEMA_VERSION {
477 anyhow::bail!(
478 "config at {} is schema_version {from}, newer than this binary supports ({})",
479 path.display().to_string(),
480 CURRENT_SCHEMA_VERSION,
481 );
482 }
483 anyhow::bail!(
484 "config at {} is schema_version {from}; run `zeroclaw config migrate` to update before modifying",
485 path.display().to_string(),
486 );
487}
488
489pub(crate) fn fold_string_into_array(
502 table: &mut toml::Table,
503 from_key: &str,
504 to_key: &str,
505) -> bool {
506 let value = match table.remove(from_key) {
507 Some(toml::Value::String(s)) if !s.is_empty() => s,
508 Some(other) => {
509 table.insert(from_key.to_string(), other);
511 return false;
512 }
513 None => return false,
514 };
515 let entry = table
516 .entry(to_key.to_string())
517 .or_insert_with(|| toml::Value::Array(Vec::new()));
518 if let Some(arr) = entry.as_array_mut() {
519 let already_present = arr.iter().any(|v| v.as_str() == Some(value.as_str()));
520 if !already_present {
521 arr.push(toml::Value::String(value));
522 }
523 true
524 } else {
525 table.insert(from_key.to_string(), toml::Value::String(value));
527 false
528 }
529}
530
531type MigrationStep = fn(toml::Value) -> Result<toml::Value>;
533
534const MIGRATION_STEPS: &[MigrationStep] = &[
546 |_| unreachable!("MIGRATION_STEPS[0] is a 1-indexing pad and is never invoked"),
548 |value| {
550 let v1: V1Config = value
551 .try_into()
552 .context("failed to deserialize input as V1 schema")?;
553 let v2 = v1.migrate();
554 toml::Value::try_from(v2).context("failed to serialize V2 intermediate")
555 },
556 |value| {
558 let v2: V2Config = value
559 .try_into()
560 .context("failed to deserialize as V2 schema")?;
561 v2.migrate().context("failed to migrate V2 → V3")
562 },
563];
564
565const _: () = assert!(
566 MIGRATION_STEPS.len() as u32 == CURRENT_SCHEMA_VERSION,
567 "MIGRATION_STEPS must have exactly one entry per schema version \
568 (length = CURRENT_SCHEMA_VERSION, including the slot-0 padding)",
569);
570
571fn run_chain(value: toml::Value, from: u32) -> Result<toml::Value> {
574 run_chain_until(value, from, CURRENT_SCHEMA_VERSION)
575}
576
577fn run_chain_until(value: toml::Value, from: u32, target: u32) -> Result<toml::Value> {
583 if target < from {
584 anyhow::bail!("cannot migrate backwards from V{from} to V{target}");
585 }
586 if target > CURRENT_SCHEMA_VERSION {
587 anyhow::bail!(
588 "target V{target} exceeds CURRENT_SCHEMA_VERSION (V{CURRENT_SCHEMA_VERSION})"
589 );
590 }
591
592 let mut cur = value;
593 for step in &MIGRATION_STEPS[from as usize..target as usize] {
594 cur = step(cur)?;
595 }
596 Ok(cur)
597}
598
599pub(crate) fn sync_table(doc: &mut toml_edit::Table, new: &toml::Table) {
613 let to_remove: Vec<String> = doc
615 .iter()
616 .map(|(k, _)| k.to_string())
617 .filter(|k| !new.contains_key(k))
618 .collect();
619 for k in to_remove {
620 doc.remove(&k);
621 }
622
623 for (key, new_value) in new.iter() {
624 if let (Some(doc_item), toml::Value::Table(new_sub)) =
625 (doc.get_mut(key.as_str()), new_value)
626 && let Some(doc_sub) = doc_item.as_table_mut()
627 {
628 sync_table(doc_sub, new_sub);
630 continue;
631 }
632 let new_item = toml_value_to_edit_item(new_value);
634 match doc.get_mut(key.as_str()) {
635 Some(existing) => {
636 *existing = new_item;
638 }
639 None => {
640 doc.insert(key.as_str(), new_item);
641 }
642 }
643 }
644}
645
646pub(crate) fn toml_value_to_edit_item(value: &toml::Value) -> toml_edit::Item {
650 let serialized = match value {
654 toml::Value::Table(t) => {
655 let mut wrapper = toml::Table::new();
656 wrapper.insert("__v".into(), toml::Value::Table(t.clone()));
657 toml::to_string(&wrapper).unwrap_or_default()
658 }
659 other => {
660 let mut wrapper = toml::Table::new();
661 wrapper.insert("__v".into(), other.clone());
662 toml::to_string(&wrapper).unwrap_or_default()
663 }
664 };
665 let doc: toml_edit::DocumentMut = serialized.parse().unwrap_or_default();
666 doc.get("__v").cloned().unwrap_or(toml_edit::Item::None)
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 #[test]
674 fn detect_version_missing_is_v1() {
675 let v: toml::Value = toml::from_str("foo = 1").unwrap();
676 assert_eq!(detect_version(&v).unwrap(), 1);
677 }
678
679 #[test]
680 fn detect_version_explicit() {
681 let v: toml::Value = toml::from_str("schema_version = 2\n").unwrap();
682 assert_eq!(detect_version(&v).unwrap(), 2);
683 }
684
685 #[test]
686 fn detect_version_negative_errors() {
687 let v: toml::Value = toml::from_str("schema_version = -1\n").unwrap();
688 assert!(detect_version(&v).is_err());
689 }
690
691 #[test]
692 fn detect_version_string_errors() {
693 let v: toml::Value = toml::from_str("schema_version = \"two\"\n").unwrap();
694 assert!(detect_version(&v).is_err());
695 }
696
697 fn setup_temp_config_dir() -> tempfile::TempDir {
700 tempfile::TempDir::new().expect("temp dir")
701 }
702
703 #[test]
704 fn migrate_file_in_place_writes_backup_and_replaces_atomically() {
705 let dir = setup_temp_config_dir();
706 let path = dir.path().join("config.toml");
707 std::fs::write(&path, "default_model_provider = \"openai\"\nfoo = 1\n").unwrap();
709
710 let report = migrate_file_in_place(&path)
711 .expect("migration succeeds")
712 .expect("migration ran (V1 input)");
713
714 let backup = std::fs::read_to_string(&report.backup_path).unwrap();
716 assert!(
717 backup.contains("default_model_provider = \"openai\"") && backup.contains("foo = 1"),
718 "backup must contain the original V1 content; got: {backup}"
719 );
720
721 let migrated = std::fs::read_to_string(&path).unwrap();
723 assert!(
724 migrated.contains("schema_version"),
725 "migrated config must carry a schema_version line; got: {migrated}"
726 );
727
728 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
730 .unwrap()
731 .filter_map(|e| e.ok())
732 .filter(|e| {
733 e.file_name()
734 .to_string_lossy()
735 .starts_with(".config.toml.tmp-")
736 })
737 .collect();
738 assert!(
739 leftovers.is_empty(),
740 "no temp files must remain after a successful migration; got {leftovers:?}"
741 );
742 }
743
744 #[test]
745 fn migrate_file_in_place_noop_when_already_current() {
746 let dir = setup_temp_config_dir();
747 let path = dir.path().join("config.toml");
748 std::fs::write(
749 &path,
750 format!("schema_version = {CURRENT_SCHEMA_VERSION}\n"),
751 )
752 .unwrap();
753
754 let report = migrate_file_in_place(&path).expect("idempotent on current schema");
755 assert!(
756 report.is_none(),
757 "no migration should run when the file is already at CURRENT_SCHEMA_VERSION"
758 );
759 let backup = path.with_file_name("config.toml.backup");
761 assert!(
762 !backup.exists(),
763 "no `.backup` should be created on the no-op path; got {}",
764 backup.display()
765 );
766 }
767}