Skip to main content

zeroclaw_config/
migration.rs

1use anyhow::{Context, Result};
2use std::path::Path;
3
4use crate::schema::Config;
5use crate::schema::v1::V1Config;
6use crate::schema::v2::V2Config;
7
8/// The schema version this binary writes and expects on disk.
9pub const CURRENT_SCHEMA_VERSION: u32 = 3;
10
11/// Top-level TOML keys that legacy schema versions had but V3 either
12/// removed or restructured. Suppresses "unknown key" warnings on V1/V2
13/// configs flowing through `migrate_to_current`: every key here is
14/// consumed by `V1Config::migrate` or `V2Config::migrate`, so it's
15/// expected on a stale-but-being-migrated config.
16pub 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
36/// Detect a config's schema version from its parsed TOML representation.
37///
38/// - Missing top-level `schema_version` key → V1 (pre-versioned).
39/// - Integer ≥ 1 → that integer.
40/// - Anything else → error.
41pub 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
61/// Pure migration from any supported version's TOML string into the current
62/// schema version's TOML string. Returns `Ok(None)` when the input is already
63/// at `CURRENT_SCHEMA_VERSION`.
64///
65/// Comments and decoration on keys whose dotted path survives the migration
66/// are preserved via `toml_edit::DocumentMut` reconciliation (`sync_table`).
67/// Keys that are renamed, removed, or restructured lose their comments — the
68/// `.backup` file written by `migrate_file_in_place` retains the original
69/// for manual recovery.
70pub 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    // Try to preserve comments by reconciling into the original DocumentMut.
100    // If the original doesn't parse as toml_edit (rare — toml::from_str
101    // already succeeded on it), fall back to a fresh serialization.
102    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
112/// Embedded V1 fixture used by [`generate`] / the `zeroclaw config generate`
113/// CLI. Authored against the V1 schema at the parent of the V2-intro
114/// commit; see `fixtures/v1.toml`.
115const V1_FIXTURE: &str = include_str!("../fixtures/v1.toml");
116
117/// Options for [`generate`].
118#[derive(Debug, Default, Clone)]
119pub struct GenerateOptions<'a> {
120    /// Encrypt secret-bearing string values in the output. Works at every
121    /// schema version via [`encrypt_secret_strings`], which walks the TOML
122    /// and ChaCha20-Poly1305-encrypts any leaf whose key name appears in
123    /// [`SECRET_KEY_NAMES`].
124    pub encrypt_secrets: bool,
125    /// Directory containing (or to receive) the `.secret_key` used for
126    /// `enc2:` encryption. Required when `encrypt_secrets` is true. The
127    /// key is created with 0o600 permissions if absent — matches how the
128    /// daemon's `SecretStore` behaves on first use.
129    pub secret_store_dir: Option<&'a Path>,
130}
131
132/// Generate a canonical TOML config at `target_version`, derived by
133/// running the V1 fixture forward through the typed migration chain.
134///
135/// `target_version` must be in `1..=CURRENT_SCHEMA_VERSION`. The chain is
136/// the same one used to migrate real on-disk configs — V1 fixture →
137/// `V1Config::migrate` → V2 typed value → `V2Config::migrate` → V3 typed
138/// value — so `generate <n>` shows exactly the shape an operator running
139/// `zeroclaw config migrate` would land on if they started from the V1
140/// fixture.
141///
142/// When [`GenerateOptions::encrypt_secrets`] is set, secret-bearing
143/// string values (api_key, bot_token, access_token, etc. — see
144/// [`SECRET_KEY_NAMES`]) are ChaCha20-Poly1305-encrypted with the
145/// `.secret_key` under `secret_store_dir`. Works at every version.
146pub 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
176/// Set of TOML terminal key names whose string leaves are treated as
177/// secrets by [`encrypt_secret_strings`]. Sourced from
178/// `Config::secret_field_terminals()`, the macro-emitted static
179/// enumeration of every `#[secret]` field reachable from the schema.
180/// The set is schema-driven — adding a new `#[secret]` annotation
181/// anywhere in the schema automatically extends encryption coverage
182/// with no companion edit in this module.
183///
184/// `secret_field_terminals()` (vs. the older `prop_fields().filter(is_secret)`
185/// approach) covers compound shapes like `HashMap<String, String>`
186/// — `prop_fields()` intentionally skips non-Vec compound types, which
187/// would silently drop e.g. `mcp.servers[*].headers` from the allowlist.
188fn 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
195/// Walk a TOML tree and encrypt every string leaf whose terminal key
196/// name appears in [`secret_key_names`]. Strings already in `enc2:` /
197/// `enc:` form are left alone (idempotent). Arrays of strings under a
198/// matching key (e.g. `paired_tokens`) are encrypted element-wise.
199///
200/// Works at every schema version because it operates on raw TOML
201/// rather than a typed `#[secret]` index — only the *set of key names
202/// to encrypt* comes from the typed schema; the walker itself doesn't
203/// care about types.
204pub 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
238/// Encrypt the value at this slot — a string, an array of strings, or
239/// a table containing strings — using the given store. Non-string leaves
240/// (numbers, bools) are left alone; the operator presumably annotated a
241/// non-secret field with a secret-shaped name and we don't second-guess.
242///
243/// When the slot is a Table (e.g. `headers = { Authorization = "Bearer
244/// ...", X-Tenant = "..." }`), every leaf in the subtree is encrypted —
245/// the parent key matched the secret allowlist, so every value below it
246/// inherits the secret marker. This is the contract for `HashMap<String,
247/// String>`-shaped `#[secret]` fields where individual keys are
248/// user-supplied and can't be checked against a static allowlist.
249fn 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
272/// High-level: arbitrary versioned TOML → fully validated V3 `Config`.
273/// Runs migration if needed, then deserializes into the current `Config` type.
274pub 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
301/// File-API wrapper: read disk config, migrate, write `<file>.backup`
302/// adjacent to the original, then atomically replace the original. Returns
303/// `Ok(None)` when already current.
304///
305/// Backup file is `<config_filename>.backup` (joined cross-platform via
306/// `Path` ops). The write path mirrors `Config::save()` so the documented
307/// durability guarantee holds end-to-end:
308///
309/// 1. Write the migrated content to `<path>.tmp-<uuid>` and fsync it.
310/// 2. Copy the original to `<path>.backup` (existing behavior; recovery
311///    rope if anything later goes wrong).
312/// 3. `rename(<path>.tmp, <path>)` — atomic on Unix and on modern Windows.
313/// 4. Fsync the parent directory so the rename is durable.
314///
315/// On rename failure the temp file is removed and the backup is restored
316/// over the original so the operator never observes a partial write.
317pub 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    // 1. Write migrated content to temp + fsync.
340    {
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    // 2. Backup original BEFORE touching the destination. Copy gets a fresh inode.
366    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    // 3. Atomic rename. On failure, restore from backup so the operator
375    //    never observes a partial write.
376    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    // 4. Fsync the parent directory so the rename is durable across crashes.
401    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/// Fsync the directory entry so a subsequent rename inside it is durable.
415/// No-op on platforms where directory fsync isn't a meaningful primitive.
416#[allow(clippy::unused_async)] // kept sync to mirror Config::save()'s helper
417fn 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        // Best-effort: open + drop. Windows doesn't provide a portable
433        // directory-fsync primitive in std; the rename itself is durable
434        // on NTFS.
435        let _ = std::fs::File::open(path);
436    }
437    Ok(())
438}
439
440/// Result of an on-disk migration. Returned by `migrate_file_in_place` when
441/// migration ran (vs. `Ok(None)` when input was already current).
442#[derive(Debug, Clone)]
443pub struct MigrateReport {
444    pub backup_path: std::path::PathBuf,
445    pub to_version: u32,
446}
447
448/// Refuse to proceed if the on-disk config is at a stale schema version.
449///
450/// Used by CLI write commands (`config set`, `config patch`, `config init`)
451/// to ensure the user explicitly opts into the migration via
452/// `zeroclaw config migrate` before modifying a stale config — the alternative
453/// would be a silent auto-migrate-on-write, which is harder to audit and
454/// surprises users who didn't realize their config schema had changed.
455///
456/// - Missing file → `Ok(())` (fresh install: nothing to migrate yet).
457/// - Current version → `Ok(())`.
458/// - Stale (or future) version → `Err` with a message that names the disk
459///   version and the command the user needs to run.
460pub 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
489/// Fold a `from_key: String` value into a `to_key: Vec<String>` array on the
490/// same table. Used for the singular→plural channel transforms (V1→V2:
491/// `matrix.room_id` → `allowed_rooms`, `slack.channel_id` → `channel_ids`;
492/// V2→V3: `discord.guild_id` → `guild_ids`, etc.).
493///
494/// - Removes `from_key` from the table.
495/// - If the value was a non-empty string, appends it to `to_key`'s array
496///   (creating the array if missing). Existing entries are preserved; the new
497///   value is deduplicated against current contents.
498/// - Empty strings, non-string types, and missing `from_key` are no-ops.
499///
500/// Returns `true` if a value was actually folded (caller may emit a log line).
501pub(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            // Non-string: re-insert under from_key untouched (caller may handle).
510            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        // Existing to_key wasn't an array (unusual). Reinsert from_key as-is.
526        table.insert(from_key.to_string(), toml::Value::String(value));
527        false
528    }
529}
530
531/// One typed migration step: `V_n` TOML → `V_{n+1}` TOML.
532type MigrationStep = fn(toml::Value) -> Result<toml::Value>;
533
534/// Migration steps keyed 1-indexed by `from` version: `MIGRATION_STEPS[n]`
535/// is the step from `V_n` to `V_{n+1}`. Slot 0 is a never-invoked
536/// placeholder so callers can write `&MIGRATION_STEPS[from..target]`
537/// directly — both bounds read as schema-version numbers, no offset math.
538///
539/// To add a new schema version `V_n`:
540/// 1. Add `schema/v{n-1}.rs` with a partial typed lens for the prior shape.
541/// 2. Implement `V{n-1}Config::migrate(self) -> Result<toml::Value>`.
542/// 3. Bump [`CURRENT_SCHEMA_VERSION`] to `n`.
543/// 4. Append a new closure here that deserializes `V{n-1}Config` and calls
544///    its `migrate()`. The compile-time assertion below catches drift.
545const MIGRATION_STEPS: &[MigrationStep] = &[
546    // V0 → V1: padding so slot 0 is never indexed. V0 does not exist.
547    |_| unreachable!("MIGRATION_STEPS[0] is a 1-indexing pad and is never invoked"),
548    // V1 → V2
549    |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    // V2 → V3
557    |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
571/// Run the typed migration chain from `from` up to `CURRENT_SCHEMA_VERSION`.
572/// `from` must be `< CURRENT_SCHEMA_VERSION` (caller checks).
573fn run_chain(value: toml::Value, from: u32) -> Result<toml::Value> {
574    run_chain_until(value, from, CURRENT_SCHEMA_VERSION)
575}
576
577/// Run the typed migration chain from `from` up to `target` (the shape that
578/// is emitted). `target` must be in `from..=CURRENT_SCHEMA_VERSION`.
579///
580/// Used by `migrate_file` / `migrate_to_current` (target = current) and by
581/// [`generate`] (target = any historical version, for fixture generation).
582fn 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
599/// Reconcile new typed values into an existing `toml_edit::DocumentMut` so
600/// comments and decoration on surviving keys are preserved across save.
601///
602/// Walks `new` recursively. For each key:
603/// - If the key exists in `doc` AND both sides are tables, recurse.
604/// - If the key exists in `doc` and at least one side is not a table, replace
605///   the value while preserving the key's prefix decor (i.e. the comment lines
606///   that lead the key).
607/// - If the key does not exist in `doc`, insert it.
608///
609/// Removed keys (present in `doc` but absent from `new`) are dropped from `doc`.
610/// This matches the prior crate behavior: the typed schema is authoritative,
611/// and any TOML key not represented in `new` is not part of the current schema.
612pub(crate) fn sync_table(doc: &mut toml_edit::Table, new: &toml::Table) {
613    // Drop keys not present in new
614    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            // Both tables — recurse to preserve nested comments.
629            sync_table(doc_sub, new_sub);
630            continue;
631        }
632        // Otherwise, replace the value while preserving the key's leading decor.
633        let new_item = toml_value_to_edit_item(new_value);
634        match doc.get_mut(key.as_str()) {
635            Some(existing) => {
636                // Preserve the key's leading decor (comments) by mutating in place.
637                *existing = new_item;
638            }
639            None => {
640                doc.insert(key.as_str(), new_item);
641            }
642        }
643    }
644}
645
646/// Convert a `toml::Value` into a `toml_edit::Item` for insertion into
647/// a `DocumentMut`. Tables become inline tables when small, real tables
648/// otherwise — matches `toml_edit`'s default round-trip behavior.
649pub(crate) fn toml_value_to_edit_item(value: &toml::Value) -> toml_edit::Item {
650    // Easiest path: serialize to string, parse as toml_edit. Lossy on numeric
651    // formatting nuance but correct for migration round-trip where we're
652    // emitting freshly-serialized values.
653    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    // ── migrate_file_in_place atomic-write semantics ──
698
699    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        // Minimal V1 input (no schema_version) so migration runs.
708        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        // Backup retains the original content verbatim.
715        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        // Original is replaced with migrated content.
722        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        // No `<file>.tmp-*` files left behind in the parent.
729        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        // No backup file should exist when the migration didn't run.
760        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}