Skip to main content

ai_memory/cli/commands/
config.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.x (#1146) — `ai-memory config <subcommand>` CLI surface.
5//!
6//! Today exposes a single action: `migrate`. Rewrites a legacy v1
7//! (flat-field) `config.toml` to the canonical v2 sectioned shape
8//! (`[llm]`, `[embeddings]`, `[reranker]`, `[storage]`) defined in
9//! issue #1146.
10//!
11//! ## Wire shape
12//!
13//! ```bash
14//! ai-memory config migrate              # write <file>.bak.<ts> + rewrite
15//! ai-memory config migrate --dry-run    # print diff, write nothing
16//! ai-memory config migrate \
17//!     --also-clean-claude-json          # additionally remove the
18//!                                       # mcpServers.<*>.env block from
19//!                                       # ~/.claude.json after verifying
20//!                                       # the new config.toml works
21//! ```
22//!
23//! ## Exit codes
24//!
25//! | Code | Meaning                                                  |
26//! |-----:|----------------------------------------------------------|
27//! |   0  | success — file migrated or already v2 (no-op INFO)       |
28//! |   1  | informational — dry-run mode, no writes performed        |
29//! |   2  | file not found (no `~/.config/ai-memory/config.toml`)    |
30//! |   3  | parse error — file is not valid TOML                     |
31//! |   4  | write error — could not write `.bak` or new file         |
32
33use crate::models::field_names;
34use std::path::{Path, PathBuf};
35
36use anyhow::Result;
37use clap::{Args, Subcommand};
38
39use crate::cli::CliOutput;
40use crate::config::config_keys;
41
42/// Args for `ai-memory config <subcommand>`.
43#[derive(Args, Debug, Clone)]
44pub struct ConfigCliArgs {
45    #[command(subcommand)]
46    pub action: ConfigAction,
47}
48
49#[derive(Subcommand, Debug, Clone)]
50pub enum ConfigAction {
51    /// Rewrite a legacy v1 (flat-field) `config.toml` to the v2
52    /// sectioned shape (`[llm]`, `[embeddings]`, `[reranker]`,
53    /// `[storage]`).
54    ///
55    /// Default behaviour: write `<config.toml>.bak.<timestamp>` then
56    /// rewrite the live file. Idempotent — running against a v2 file
57    /// is a no-op `INFO` log.
58    Migrate {
59        /// Print the diff to stderr without writing anything. Exits
60        /// with code 1 (informational).
61        #[arg(long)]
62        dry_run: bool,
63
64        /// Additionally remove every `mcpServers.<*>.env` block whose
65        /// command resolves to `ai-memory` from `~/.claude.json`. A
66        /// timestamped `.bak` is written alongside. Default OFF — the
67        /// operator must opt in after verifying the new
68        /// `config.toml` works.
69        #[arg(long)]
70        also_clean_claude_json: bool,
71    },
72}
73
74/// Entry point dispatched by `daemon_runtime::run`.
75///
76/// # Errors
77///
78/// Returns the underlying I/O / parse error if the migration fails.
79pub fn run(_db: &Path, args: ConfigCliArgs, out: &mut CliOutput) -> Result<i32> {
80    match args.action {
81        ConfigAction::Migrate {
82            dry_run,
83            also_clean_claude_json,
84        } => migrate(dry_run, also_clean_claude_json, out),
85    }
86}
87
88fn migrate(dry_run: bool, also_clean_claude_json: bool, out: &mut CliOutput) -> Result<i32> {
89    use crate::config::AppConfig;
90
91    let Some(path) = AppConfig::config_path() else {
92        let _ = writeln!(
93            out.stderr,
94            "ERROR: $HOME is not set; cannot resolve config path."
95        );
96        return Ok(2);
97    };
98
99    if !path.exists() {
100        let _ = writeln!(
101            out.stderr,
102            "ERROR: no config file at {} — nothing to migrate.",
103            path.display()
104        );
105        return Ok(2);
106    }
107
108    let contents = match std::fs::read_to_string(&path) {
109        Ok(c) => c,
110        Err(e) => {
111            let _ = writeln!(
112                out.stderr,
113                "ERROR: could not read {}: {}",
114                path.display(),
115                e
116            );
117            return Ok(4);
118        }
119    };
120
121    let original_value: toml::Value = match toml::from_str(&contents) {
122        Ok(v) => v,
123        Err(e) => {
124            let _ = writeln!(
125                out.stderr,
126                "ERROR: {} is not valid TOML: {}",
127                path.display(),
128                e
129            );
130            return Ok(3);
131        }
132    };
133
134    let original_table = match original_value.as_table() {
135        Some(t) => t.clone(),
136        None => {
137            let _ = writeln!(
138                out.stderr,
139                "ERROR: {} is valid TOML but not a top-level table.",
140                path.display()
141            );
142            return Ok(3);
143        }
144    };
145
146    // Detect idempotent no-op: schema_version >= 2 AND no legacy
147    // fields present.
148    let v2_already = original_table
149        .get(field_names::SCHEMA_VERSION)
150        .and_then(toml::Value::as_integer)
151        .is_some_and(|v| v >= 2);
152    let has_legacy = LEGACY_FIELDS
153        .iter()
154        .any(|k| original_table.contains_key(*k));
155
156    if v2_already && !has_legacy {
157        let _ = writeln!(
158            out.stderr,
159            "INFO: {} is already schema_version >= 2 with no legacy fields; no migration needed.",
160            path.display()
161        );
162        return Ok(0);
163    }
164
165    let migrated_table = build_migrated_table(&original_table);
166    let migrated_value = toml::Value::Table(migrated_table);
167    let migrated_text = toml::to_string_pretty(&migrated_value).unwrap_or_else(|_| String::new());
168
169    if dry_run {
170        let _ = writeln!(
171            out.stderr,
172            "--- DRY RUN — {} would be rewritten as: ---",
173            path.display()
174        );
175        let _ = writeln!(out.stderr, "{migrated_text}");
176        let _ = writeln!(out.stderr, "--- end dry run ---");
177        if also_clean_claude_json {
178            let _ = writeln!(
179                out.stderr,
180                "(--also-clean-claude-json also skipped in dry-run.)"
181            );
182        }
183        return Ok(1);
184    }
185
186    // Write backup.
187    let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
188    let backup_path = path.with_extension(format!("toml.bak.{timestamp}"));
189    if let Err(e) = std::fs::write(&backup_path, &contents) {
190        let _ = writeln!(
191            out.stderr,
192            "ERROR: could not write backup {}: {}",
193            backup_path.display(),
194            e
195        );
196        return Ok(4);
197    }
198
199    // Write migrated file.
200    if let Err(e) = std::fs::write(&path, &migrated_text) {
201        let _ = writeln!(
202            out.stderr,
203            "ERROR: could not write {}: {}",
204            path.display(),
205            e
206        );
207        return Ok(4);
208    }
209
210    let _ = writeln!(
211        out.stderr,
212        "OK: migrated {} (backup: {})",
213        path.display(),
214        backup_path.display()
215    );
216
217    if also_clean_claude_json {
218        match clean_claude_json(&timestamp) {
219            Ok(Some(claude_path)) => {
220                let _ = writeln!(
221                    out.stderr,
222                    "OK: cleaned ~/.claude.json (backup: {claude_path})"
223                );
224            }
225            Ok(None) => {
226                let _ = writeln!(
227                    out.stderr,
228                    "INFO: ~/.claude.json had no mcpServers env block referencing ai-memory; no changes."
229                );
230            }
231            Err(e) => {
232                let _ = writeln!(out.stderr, "WARN: ~/.claude.json clean failed: {e}");
233            }
234        }
235    } else {
236        let _ = writeln!(
237            out.stderr,
238            "INFO: your ~/.claude.json may still carry an mcpServers env block. \
239             Re-run with `--also-clean-claude-json` to remove it after verifying \
240             the new config.toml works."
241        );
242    }
243
244    Ok(0)
245}
246
247/// Legacy v1 flat-field names that the migrator folds into v2 sections.
248const LEGACY_FIELDS: &[&str] = &[
249    "llm_model",
250    config_keys::OLLAMA_URL,
251    "embed_url",
252    config_keys::EMBEDDING_MODEL,
253    config_keys::CROSS_ENCODER,
254    config_keys::DEFAULT_NAMESPACE,
255    config_keys::ARCHIVE_ON_GC,
256    config_keys::ARCHIVE_MAX_DAYS,
257    config_keys::MAX_MEMORY_MB,
258    config_keys::AUTO_TAG_MODEL,
259];
260
261/// Construct the v2 migrated table from a parsed v1 table. Pure (no
262/// I/O) so the dry-run path and the apply path share one implementation.
263fn build_migrated_table(
264    original: &toml::map::Map<String, toml::Value>,
265) -> toml::map::Map<String, toml::Value> {
266    let mut migrated = original.clone();
267
268    // Remove legacy fields from the top-level.
269    let mut llm_model: Option<toml::Value> = None;
270    let mut ollama_url: Option<toml::Value> = None;
271    let mut embed_url: Option<toml::Value> = None;
272    let mut embedding_model: Option<toml::Value> = None;
273    let mut cross_encoder: Option<toml::Value> = None;
274    let mut default_namespace: Option<toml::Value> = None;
275    let mut archive_on_gc: Option<toml::Value> = None;
276    let mut archive_max_days: Option<toml::Value> = None;
277    let mut max_memory_mb: Option<toml::Value> = None;
278    let mut auto_tag_model: Option<toml::Value> = None;
279
280    macro_rules! take {
281        ($name:expr, $target:ident) => {
282            if let Some(v) = migrated.remove($name) {
283                $target = Some(v);
284            }
285        };
286    }
287
288    take!("llm_model", llm_model);
289    take!(config_keys::OLLAMA_URL, ollama_url);
290    take!("embed_url", embed_url);
291    take!(config_keys::EMBEDDING_MODEL, embedding_model);
292    take!(config_keys::CROSS_ENCODER, cross_encoder);
293    take!(config_keys::DEFAULT_NAMESPACE, default_namespace);
294    take!(config_keys::ARCHIVE_ON_GC, archive_on_gc);
295    take!(config_keys::ARCHIVE_MAX_DAYS, archive_max_days);
296    take!(config_keys::MAX_MEMORY_MB, max_memory_mb);
297    take!(config_keys::AUTO_TAG_MODEL, auto_tag_model);
298
299    // schema_version = 2 (highest priority on insert).
300    migrated.insert(
301        field_names::SCHEMA_VERSION.to_string(),
302        toml::Value::Integer(2),
303    );
304
305    // [llm] section — synthesise only if a legacy LLM field was present
306    // OR the existing [llm] section is missing. (When the existing
307    // [llm] section is present, the v1 legacy llm_model/ollama_url
308    // were either redundant or operator drift; drop them.)
309    if !migrated.contains_key("llm") && llm_model.is_some() {
310        let mut llm = toml::map::Map::new();
311        // Legacy v1 configs implied the Ollama-native backend
312        // (`llm_model` + `ollama_url` were the only LLM knobs).
313        // Reference the canonical backend-name const in `llm.rs`
314        // (issue #1174 PR4 — substrate-vendor cleanup) so the
315        // migrator never re-names the vendor.
316        llm.insert(
317            "backend".to_string(),
318            toml::Value::String(crate::llm::BACKEND_OLLAMA.to_string()),
319        );
320        if let Some(v) = llm_model {
321            llm.insert("model".to_string(), v);
322        }
323        if let Some(v) = ollama_url {
324            llm.insert("base_url".to_string(), v);
325        }
326        // [llm.auto_tag] if legacy `auto_tag_model` was set.
327        if let Some(v) = auto_tag_model {
328            let mut sub = toml::map::Map::new();
329            sub.insert("model".to_string(), v);
330            llm.insert("auto_tag".to_string(), toml::Value::Table(sub));
331        }
332        migrated.insert("llm".to_string(), toml::Value::Table(llm));
333    }
334
335    // [embeddings] section.
336    if !migrated.contains_key(config_keys::SECTION_EMBEDDINGS)
337        && (embed_url.is_some() || embedding_model.is_some())
338    {
339        let mut emb = toml::map::Map::new();
340        // Same legacy implication for embeddings — pre-v0.7.x configs
341        // only spoke to Ollama for embedding generation.
342        emb.insert(
343            "backend".to_string(),
344            toml::Value::String(crate::llm::BACKEND_OLLAMA.to_string()),
345        );
346        if let Some(v) = embed_url {
347            emb.insert("url".to_string(), v);
348        }
349        if let Some(v) = embedding_model {
350            emb.insert("model".to_string(), v);
351        }
352        migrated.insert(
353            config_keys::SECTION_EMBEDDINGS.to_string(),
354            toml::Value::Table(emb),
355        );
356    }
357
358    // [reranker] section.
359    if !migrated.contains_key("reranker") && cross_encoder.is_some() {
360        let mut rerank = toml::map::Map::new();
361        if let Some(v) = cross_encoder.clone() {
362            rerank.insert("enabled".to_string(), v);
363        }
364        rerank.insert(
365            "model".to_string(),
366            toml::Value::String(crate::reranker::DEFAULT_RERANKER_MODEL.to_string()),
367        );
368        migrated.insert("reranker".to_string(), toml::Value::Table(rerank));
369    }
370
371    // [storage] section.
372    if !migrated.contains_key("storage")
373        && (default_namespace.is_some()
374            || archive_on_gc.is_some()
375            || archive_max_days.is_some()
376            || max_memory_mb.is_some())
377    {
378        let mut storage = toml::map::Map::new();
379        if let Some(v) = default_namespace {
380            storage.insert(config_keys::DEFAULT_NAMESPACE.to_string(), v);
381        }
382        if let Some(v) = archive_on_gc {
383            storage.insert(config_keys::ARCHIVE_ON_GC.to_string(), v);
384        }
385        if let Some(v) = archive_max_days {
386            storage.insert(config_keys::ARCHIVE_MAX_DAYS.to_string(), v);
387        }
388        if let Some(v) = max_memory_mb {
389            storage.insert(config_keys::MAX_MEMORY_MB.to_string(), v);
390        }
391        migrated.insert("storage".to_string(), toml::Value::Table(storage));
392    }
393
394    migrated
395}
396
397/// Remove `mcpServers.<*>.env` blocks (the entire `env` key) from any
398/// `mcpServers` entry whose `command` resolves to an `ai-memory`
399/// binary. Returns the backup path on change; `None` when no change
400/// was needed.
401fn clean_claude_json(timestamp: &str) -> Result<Option<String>> {
402    let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("$HOME not set"))?;
403    let path = PathBuf::from(&home).join(".claude.json");
404    if !path.exists() {
405        return Ok(None);
406    }
407
408    let contents = std::fs::read_to_string(&path)?;
409    let mut value: serde_json::Value = serde_json::from_str(&contents)?;
410
411    let mut changed = false;
412    if let Some(servers) = value
413        .get_mut(crate::cli::install::KEY_MCP_SERVERS)
414        .and_then(serde_json::Value::as_object_mut)
415    {
416        for (_name, entry) in servers.iter_mut() {
417            let is_ai_memory = entry
418                .get("command")
419                .and_then(serde_json::Value::as_str)
420                .map(|c| c.ends_with("/ai-memory") || c == "ai-memory")
421                .unwrap_or(false);
422            if !is_ai_memory {
423                continue;
424            }
425            if let Some(obj) = entry.as_object_mut() {
426                if obj.remove("env").is_some() {
427                    changed = true;
428                }
429            }
430        }
431    }
432
433    if !changed {
434        return Ok(None);
435    }
436
437    let backup_path = format!("{}.bak.{}", path.display(), timestamp);
438    std::fs::write(&backup_path, &contents)?;
439    std::fs::write(&path, serde_json::to_string_pretty(&value)?)?;
440
441    Ok(Some(backup_path))
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::cli::CliOutput;
448
449    /// Serialise the `$HOME`-mutating `run`/`migrate` tests — env
450    /// mutation is process-global, so two of these running concurrently
451    /// would race on `AppConfig::config_path()`.
452    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
453        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
454        LOCK.get_or_init(|| std::sync::Mutex::new(()))
455            .lock()
456            .unwrap_or_else(std::sync::PoisonError::into_inner)
457    }
458
459    /// Run `migrate` against a `config.toml` materialised under a
460    /// tempdir `$HOME`. Returns (exit_code, stderr_text). Holds the
461    /// env lock for the duration.
462    fn run_migrate_with_home(
463        config_body: Option<&str>,
464        dry_run: bool,
465        also_clean: bool,
466    ) -> (i32, String) {
467        let _g = env_lock();
468        let home = tempfile::tempdir().expect("tempdir");
469        if let Some(body) = config_body {
470            let dir = home.path().join(".config/ai-memory");
471            std::fs::create_dir_all(&dir).unwrap();
472            std::fs::write(dir.join("config.toml"), body).unwrap();
473        }
474        let prev_home = std::env::var("HOME").ok();
475        // SAFETY: serialised via `env_lock()`; restored before the lock
476        // is released so no other test observes the tempdir HOME.
477        unsafe {
478            std::env::set_var("HOME", home.path());
479        }
480        let mut stdout: Vec<u8> = Vec::new();
481        let mut stderr: Vec<u8> = Vec::new();
482        let code = {
483            let mut out = CliOutput::from_std(&mut stdout, &mut stderr);
484            let args = ConfigCliArgs {
485                action: ConfigAction::Migrate {
486                    dry_run,
487                    also_clean_claude_json: also_clean,
488                },
489            };
490            run(std::path::Path::new("unused.db"), args, &mut out).expect("run ok")
491        };
492        unsafe {
493            match prev_home {
494                Some(h) => std::env::set_var("HOME", h),
495                None => std::env::remove_var("HOME"),
496            }
497        }
498        (code, String::from_utf8(stderr).unwrap())
499    }
500
501    #[test]
502    fn run_migrate_missing_file_returns_two() {
503        let (code, stderr) = run_migrate_with_home(None, false, false);
504        assert_eq!(code, 2);
505        assert!(stderr.contains("no config file"), "got: {stderr}");
506    }
507
508    #[test]
509    fn run_migrate_invalid_toml_returns_three() {
510        let (code, stderr) = run_migrate_with_home(Some("this is { not valid toml"), false, false);
511        assert_eq!(code, 3);
512        assert!(stderr.contains("not valid TOML"), "got: {stderr}");
513    }
514
515    #[test]
516    fn run_migrate_already_v2_is_noop() {
517        let body = "schema_version = 2\ntier = \"autonomous\"\n\n[llm]\nbackend = \"xai\"\n";
518        let (code, stderr) = run_migrate_with_home(Some(body), false, false);
519        assert_eq!(code, 0);
520        assert!(stderr.contains("no migration needed"), "got: {stderr}");
521    }
522
523    #[test]
524    fn run_migrate_dry_run_returns_one() {
525        let body = "llm_model = \"gemma\"\nollama_url = \"http://localhost:11434\"\n";
526        let (code, stderr) = run_migrate_with_home(Some(body), true, true);
527        assert_eq!(code, 1);
528        assert!(stderr.contains("DRY RUN"), "got: {stderr}");
529        assert!(
530            stderr.contains("also-clean-claude-json also skipped"),
531            "got: {stderr}"
532        );
533    }
534
535    #[test]
536    fn run_migrate_apply_writes_backup_and_succeeds() {
537        let body = "llm_model = \"gemma\"\nollama_url = \"http://localhost:11434\"\n";
538        let (code, stderr) = run_migrate_with_home(Some(body), false, false);
539        assert_eq!(code, 0);
540        assert!(stderr.contains("OK: migrated"), "got: {stderr}");
541        assert!(stderr.contains("backup:"), "got: {stderr}");
542        // The non-clean branch advises re-running with the clean flag.
543        assert!(stderr.contains("--also-clean-claude-json"), "got: {stderr}");
544    }
545
546    #[test]
547    fn run_migrate_apply_with_clean_no_claude_json() {
548        let body = "embedding_model = \"nomic_embed_v15\"\n";
549        let (code, stderr) = run_migrate_with_home(Some(body), false, true);
550        assert_eq!(code, 0);
551        // No ~/.claude.json in the tempdir HOME → INFO no-change line.
552        assert!(stderr.contains("no mcpServers env block"), "got: {stderr}");
553    }
554
555    #[test]
556    fn migrate_v1_legacy_fields_to_sections() {
557        let toml_text = r#"
558tier = "autonomous"
559db = "/tmp/test.db"
560llm_model = "gemma4:e4b"
561ollama_url = "http://localhost:11434"
562embed_url = "http://localhost:11434"
563embedding_model = "nomic_embed_v15"
564cross_encoder = true
565default_namespace = "alphaone"
566archive_on_gc = true
567"#;
568        let value: toml::Value = toml::from_str(toml_text).unwrap();
569        let original = value.as_table().unwrap().clone();
570
571        let migrated = build_migrated_table(&original);
572
573        assert_eq!(
574            migrated
575                .get("schema_version")
576                .and_then(toml::Value::as_integer),
577            Some(2),
578            "schema_version must land at 2"
579        );
580
581        // Legacy fields stripped from top-level.
582        for k in LEGACY_FIELDS {
583            assert!(
584                !migrated.contains_key(*k),
585                "legacy field {k} should have been removed"
586            );
587        }
588
589        // [llm] section populated.
590        let llm = migrated.get("llm").and_then(toml::Value::as_table).unwrap();
591        assert_eq!(
592            llm.get("backend").and_then(toml::Value::as_str),
593            Some("ollama")
594        );
595        assert_eq!(
596            llm.get("model").and_then(toml::Value::as_str),
597            Some("gemma4:e4b")
598        );
599        assert_eq!(
600            llm.get("base_url").and_then(toml::Value::as_str),
601            Some("http://localhost:11434")
602        );
603
604        // [embeddings] section populated.
605        let emb = migrated
606            .get("embeddings")
607            .and_then(toml::Value::as_table)
608            .unwrap();
609        assert_eq!(
610            emb.get("model").and_then(toml::Value::as_str),
611            Some("nomic_embed_v15")
612        );
613
614        // [reranker] section populated.
615        let rerank = migrated
616            .get("reranker")
617            .and_then(toml::Value::as_table)
618            .unwrap();
619        assert_eq!(
620            rerank.get("enabled").and_then(toml::Value::as_bool),
621            Some(true)
622        );
623        assert_eq!(
624            rerank.get("model").and_then(toml::Value::as_str),
625            Some("ms-marco-MiniLM-L-6-v2")
626        );
627
628        // [storage] section populated.
629        let storage = migrated
630            .get("storage")
631            .and_then(toml::Value::as_table)
632            .unwrap();
633        assert_eq!(
634            storage
635                .get("default_namespace")
636                .and_then(toml::Value::as_str),
637            Some("alphaone")
638        );
639        assert_eq!(
640            storage.get("archive_on_gc").and_then(toml::Value::as_bool),
641            Some(true)
642        );
643
644        // Top-level non-legacy fields preserved.
645        assert_eq!(
646            migrated.get("tier").and_then(toml::Value::as_str),
647            Some("autonomous")
648        );
649        assert_eq!(
650            migrated.get("db").and_then(toml::Value::as_str),
651            Some("/tmp/test.db")
652        );
653    }
654
655    #[test]
656    fn migrate_idempotent_on_already_v2() {
657        let toml_text = r#"
658schema_version = 2
659tier = "autonomous"
660
661[llm]
662backend = "xai"
663model = "grok-4.3"
664api_key_env = "XAI_API_KEY"
665
666[storage]
667default_namespace = "alphaone"
668"#;
669        let value: toml::Value = toml::from_str(toml_text).unwrap();
670        let original = value.as_table().unwrap().clone();
671
672        let migrated = build_migrated_table(&original);
673
674        // schema_version stays 2.
675        assert_eq!(
676            migrated
677                .get("schema_version")
678                .and_then(toml::Value::as_integer),
679            Some(2)
680        );
681
682        // Existing [llm] preserved verbatim.
683        let llm = migrated.get("llm").and_then(toml::Value::as_table).unwrap();
684        assert_eq!(
685            llm.get("backend").and_then(toml::Value::as_str),
686            Some("xai")
687        );
688        assert_eq!(
689            llm.get("model").and_then(toml::Value::as_str),
690            Some("grok-4.3")
691        );
692    }
693
694    #[test]
695    fn migrate_does_not_overwrite_existing_sections() {
696        // Pathological: operator left both legacy AND v2 fields. The
697        // migrator should preserve the existing [llm] section and drop
698        // the legacy field rather than clobbering.
699        let toml_text = r#"
700llm_model = "legacy-model"
701ollama_url = "http://stale:9999"
702
703[llm]
704backend = "xai"
705model = "grok-4.3"
706"#;
707        let value: toml::Value = toml::from_str(toml_text).unwrap();
708        let original = value.as_table().unwrap().clone();
709
710        let migrated = build_migrated_table(&original);
711
712        // Legacy fields stripped.
713        assert!(!migrated.contains_key("llm_model"));
714        assert!(!migrated.contains_key("ollama_url"));
715
716        // [llm] section preserved verbatim.
717        let llm = migrated.get("llm").and_then(toml::Value::as_table).unwrap();
718        assert_eq!(
719            llm.get("backend").and_then(toml::Value::as_str),
720            Some("xai")
721        );
722        assert_eq!(
723            llm.get("model").and_then(toml::Value::as_str),
724            Some("grok-4.3")
725        );
726    }
727}