Skip to main content

zeph_config/
migrate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Config migration: add missing parameters from the canonical reference as commented-out entries.
5//!
6//! The canonical reference is the checked-in `config/default.toml` file embedded at compile time.
7//! Missing sections and keys are added as `# key = default_value` comments so users can discover
8//! and enable them without hunting through documentation.
9
10use toml_edit::{Array, DocumentMut, Item, RawString, Table, Value};
11
12/// Canonical section ordering for top-level keys in the output document.
13static CANONICAL_ORDER: &[&str] = &[
14    "agent",
15    "llm",
16    "skills",
17    "memory",
18    "index",
19    "tools",
20    "mcp",
21    "telegram",
22    "discord",
23    "slack",
24    "a2a",
25    "acp",
26    "gateway",
27    "daemon",
28    "scheduler",
29    "orchestration",
30    "classifiers",
31    "security",
32    "vault",
33    "timeouts",
34    "cost",
35    "observability",
36    "debug",
37    "logging",
38    "tui",
39    "agents",
40    "experiments",
41    "lsp",
42];
43
44/// Error type for migration failures.
45#[derive(Debug, thiserror::Error)]
46pub enum MigrateError {
47    /// Failed to parse the user's config.
48    #[error("failed to parse input config: {0}")]
49    Parse(#[from] toml_edit::TomlError),
50    /// Failed to parse the embedded reference config (should never happen in practice).
51    #[error("failed to parse reference config: {0}")]
52    Reference(toml_edit::TomlError),
53    /// The document structure is inconsistent (e.g. `[llm.stt].model` exists but `[llm]` table
54    /// cannot be obtained as a mutable table — can happen when `[llm]` is absent or not a table).
55    #[error("migration failed: invalid TOML structure — {0}")]
56    InvalidStructure(&'static str),
57}
58
59/// Result of a migration operation.
60#[derive(Debug)]
61pub struct MigrationResult {
62    /// The migrated TOML document as a string.
63    pub output: String,
64    /// Number of top-level keys or sub-keys added as comments.
65    pub added_count: usize,
66    /// Names of top-level sections that were added.
67    pub sections_added: Vec<String>,
68}
69
70/// Migrates a user config by adding missing parameters as commented-out entries.
71///
72/// The canonical reference is embedded from `config/default.toml` at compile time.
73/// User values are never modified; only missing keys are appended as comments.
74pub struct ConfigMigrator {
75    reference_src: &'static str,
76}
77
78impl Default for ConfigMigrator {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl ConfigMigrator {
85    /// Create a new migrator using the embedded canonical reference config.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            reference_src: include_str!("../config/default.toml"),
90        }
91    }
92
93    /// Migrate `user_toml`: add missing parameters from the reference as commented-out entries.
94    ///
95    /// # Errors
96    ///
97    /// Returns `MigrateError::Parse` if the user's TOML is invalid.
98    /// Returns `MigrateError::Reference` if the embedded reference TOML cannot be parsed.
99    ///
100    /// # Panics
101    ///
102    /// Never panics in practice; `.expect("checked")` is unreachable because `is_table()` is
103    /// verified on the same `ref_item` immediately before calling `as_table()`.
104    pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
105        let reference_doc = self
106            .reference_src
107            .parse::<DocumentMut>()
108            .map_err(MigrateError::Reference)?;
109        let mut user_doc = user_toml.parse::<DocumentMut>()?;
110
111        let mut added_count = 0usize;
112        let mut sections_added: Vec<String> = Vec::new();
113
114        // Walk the reference top-level keys.
115        for (key, ref_item) in reference_doc.as_table() {
116            if ref_item.is_table() {
117                let ref_table = ref_item.as_table().expect("is_table checked above");
118                if user_doc.contains_key(key) {
119                    // Section exists — merge missing sub-keys.
120                    if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
121                        added_count += merge_table_commented(user_table, ref_table, key);
122                    }
123                } else {
124                    // Entire section is missing — record for textual append after rendering.
125                    // Idempotency: skip if a commented block for this section was already appended.
126                    if user_toml.contains(&format!("# [{key}]")) {
127                        continue;
128                    }
129                    let commented = commented_table_block(key, ref_table);
130                    if !commented.is_empty() {
131                        sections_added.push(key.to_owned());
132                    }
133                    added_count += 1;
134                }
135            } else {
136                // Top-level scalar/array key.
137                if !user_doc.contains_key(key) {
138                    let raw = format_commented_item(key, ref_item);
139                    if !raw.is_empty() {
140                        sections_added.push(format!("__scalar__{key}"));
141                        added_count += 1;
142                    }
143                }
144            }
145        }
146
147        // Render the user doc as-is first.
148        let user_str = user_doc.to_string();
149
150        // Append missing sections as raw commented text at the end.
151        let mut output = user_str;
152        for key in &sections_added {
153            if let Some(scalar_key) = key.strip_prefix("__scalar__") {
154                if let Some(ref_item) = reference_doc.get(scalar_key) {
155                    let raw = format_commented_item(scalar_key, ref_item);
156                    if !raw.is_empty() {
157                        output.push('\n');
158                        output.push_str(&raw);
159                        output.push('\n');
160                    }
161                }
162            } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
163            {
164                let block = commented_table_block(key, ref_table);
165                if !block.is_empty() {
166                    output.push('\n');
167                    output.push_str(&block);
168                }
169            }
170        }
171
172        // Reorder top-level sections by canonical order.
173        output = reorder_sections(&output, CANONICAL_ORDER);
174
175        // Resolve sections_added to only real section names (not scalars).
176        let sections_added_clean: Vec<String> = sections_added
177            .into_iter()
178            .filter(|k| !k.starts_with("__scalar__"))
179            .collect();
180
181        Ok(MigrationResult {
182            output,
183            added_count,
184            sections_added: sections_added_clean,
185        })
186    }
187}
188
189/// Merge missing keys from `ref_table` into `user_table` as commented-out entries.
190///
191/// Returns the number of keys added.
192fn merge_table_commented(user_table: &mut Table, ref_table: &Table, section_key: &str) -> usize {
193    let mut count = 0usize;
194    for (key, ref_item) in ref_table {
195        if ref_item.is_table() {
196            if user_table.contains_key(key) {
197                let pair = (
198                    user_table.get_mut(key).and_then(Item::as_table_mut),
199                    ref_item.as_table(),
200                );
201                if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
202                    let sub_key = format!("{section_key}.{key}");
203                    count += merge_table_commented(user_sub_table, ref_sub_table, &sub_key);
204                }
205            } else if let Some(ref_sub_table) = ref_item.as_table() {
206                // Sub-table missing from user config — append as commented block.
207                let dotted = format!("{section_key}.{key}");
208                let marker = format!("# [{dotted}]");
209                let existing = user_table
210                    .decor()
211                    .suffix()
212                    .and_then(RawString::as_str)
213                    .unwrap_or("");
214                if !existing.contains(&marker) {
215                    let block = commented_table_block(&dotted, ref_sub_table);
216                    if !block.is_empty() {
217                        let new_suffix = format!("{existing}\n{block}");
218                        user_table.decor_mut().set_suffix(new_suffix);
219                        count += 1;
220                    }
221                }
222            }
223        } else if ref_item.is_array_of_tables() {
224            // Never inject array-of-tables entries — they are user-defined.
225        } else {
226            // Scalar/array value — check if already present (as value or as comment).
227            if !user_table.contains_key(key) {
228                let raw_value = ref_item
229                    .as_value()
230                    .map(value_to_toml_string)
231                    .unwrap_or_default();
232                if !raw_value.is_empty() {
233                    let comment_line = format!("# {key} = {raw_value}\n");
234                    append_comment_to_table_suffix(user_table, &comment_line);
235                    count += 1;
236                }
237            }
238        }
239    }
240    count
241}
242
243/// Append a comment line to a table's trailing whitespace/decor.
244fn append_comment_to_table_suffix(table: &mut Table, comment_line: &str) {
245    let existing: String = table
246        .decor()
247        .suffix()
248        .and_then(RawString::as_str)
249        .unwrap_or("")
250        .to_owned();
251    // Only append if this exact comment_line is not already present (idempotency).
252    if !existing.contains(comment_line.trim()) {
253        let new_suffix = format!("{existing}{comment_line}");
254        table.decor_mut().set_suffix(new_suffix);
255    }
256}
257
258/// Format a reference item as a commented TOML line: `# key = value`.
259fn format_commented_item(key: &str, item: &Item) -> String {
260    if let Some(val) = item.as_value() {
261        let raw = value_to_toml_string(val);
262        if !raw.is_empty() {
263            return format!("# {key} = {raw}\n");
264        }
265    }
266    String::new()
267}
268
269/// Render a table as a commented-out TOML block with arbitrary nesting depth.
270///
271/// `section_name` is the full dotted path (e.g. `security.content_isolation`).
272/// Returns an empty string if the table has no renderable content.
273fn commented_table_block(section_name: &str, table: &Table) -> String {
274    use std::fmt::Write as _;
275
276    let mut lines = format!("# [{section_name}]\n");
277
278    for (key, item) in table {
279        if item.is_table() {
280            if let Some(sub_table) = item.as_table() {
281                let sub_name = format!("{section_name}.{key}");
282                let sub_block = commented_table_block(&sub_name, sub_table);
283                if !sub_block.is_empty() {
284                    lines.push('\n');
285                    lines.push_str(&sub_block);
286                }
287            }
288        } else if item.is_array_of_tables() {
289            // Skip — user configures these manually (e.g. `[[mcp.servers]]`).
290        } else if let Some(val) = item.as_value() {
291            let raw = value_to_toml_string(val);
292            if !raw.is_empty() {
293                let _ = writeln!(lines, "# {key} = {raw}");
294            }
295        }
296    }
297
298    // Return empty if we only wrote the section header with no content.
299    if lines.trim() == format!("[{section_name}]") {
300        return String::new();
301    }
302    lines
303}
304
305/// Convert a `toml_edit::Value` to its TOML string representation.
306fn value_to_toml_string(val: &Value) -> String {
307    match val {
308        Value::String(s) => {
309            let inner = s.value();
310            format!("\"{inner}\"")
311        }
312        Value::Integer(i) => i.value().to_string(),
313        Value::Float(f) => {
314            let v = f.value();
315            // Use representation that round-trips exactly.
316            if v.fract() == 0.0 {
317                format!("{v:.1}")
318            } else {
319                format!("{v}")
320            }
321        }
322        Value::Boolean(b) => b.value().to_string(),
323        Value::Array(arr) => format_array(arr),
324        Value::InlineTable(t) => {
325            let pairs: Vec<String> = t
326                .iter()
327                .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
328                .collect();
329            format!("{{ {} }}", pairs.join(", "))
330        }
331        Value::Datetime(dt) => dt.value().to_string(),
332    }
333}
334
335fn format_array(arr: &Array) -> String {
336    if arr.is_empty() {
337        return "[]".to_owned();
338    }
339    let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
340    format!("[{}]", items.join(", "))
341}
342
343/// Reorder top-level sections of a TOML document string by the canonical order.
344///
345/// Sections not in the canonical list are placed at the end, preserving their relative order.
346/// This operates on the raw string rather than the parsed document to preserve comments that
347/// would otherwise be dropped by `toml_edit`'s round-trip.
348fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
349    let sections = split_into_sections(toml_str);
350    if sections.is_empty() {
351        return toml_str.to_owned();
352    }
353
354    // Each entry is (header, content). Empty header = preamble block.
355    let preamble_block = sections
356        .iter()
357        .find(|(h, _)| h.is_empty())
358        .map_or("", |(_, c)| c.as_str());
359
360    let section_map: Vec<(&str, &str)> = sections
361        .iter()
362        .filter(|(h, _)| !h.is_empty())
363        .map(|(h, c)| (h.as_str(), c.as_str()))
364        .collect();
365
366    let mut out = String::new();
367    if !preamble_block.is_empty() {
368        out.push_str(preamble_block);
369    }
370
371    let mut emitted: Vec<bool> = vec![false; section_map.len()];
372
373    for &canon in canonical_order {
374        for (idx, &(header, content)) in section_map.iter().enumerate() {
375            let section_name = extract_section_name(header);
376            let top_level = section_name
377                .split('.')
378                .next()
379                .unwrap_or("")
380                .trim_start_matches('#')
381                .trim();
382            if top_level == canon && !emitted[idx] {
383                out.push_str(content);
384                emitted[idx] = true;
385            }
386        }
387    }
388
389    // Append sections not in canonical order.
390    for (idx, &(_, content)) in section_map.iter().enumerate() {
391        if !emitted[idx] {
392            out.push_str(content);
393        }
394    }
395
396    out
397}
398
399/// Extract the section name from a section header line (e.g. `[agent]` → `agent`).
400fn extract_section_name(header: &str) -> &str {
401    // Strip leading `# ` for commented headers.
402    let trimmed = header.trim().trim_start_matches("# ");
403    // Strip `[` and `]`.
404    if trimmed.starts_with('[') && trimmed.contains(']') {
405        let inner = &trimmed[1..];
406        if let Some(end) = inner.find(']') {
407            return &inner[..end];
408        }
409    }
410    trimmed
411}
412
413/// Split a TOML string into `(header_line, full_block)` pairs.
414///
415/// The first element may have an empty header representing the preamble.
416fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
417    let mut sections: Vec<(String, String)> = Vec::new();
418    let mut current_header = String::new();
419    let mut current_content = String::new();
420
421    for line in toml_str.lines() {
422        let trimmed = line.trim();
423        if is_top_level_section_header(trimmed) {
424            sections.push((current_header.clone(), current_content.clone()));
425            trimmed.clone_into(&mut current_header);
426            line.clone_into(&mut current_content);
427            current_content.push('\n');
428        } else {
429            current_content.push_str(line);
430            current_content.push('\n');
431        }
432    }
433
434    // Push the last section.
435    if !current_header.is_empty() || !current_content.is_empty() {
436        sections.push((current_header, current_content));
437    }
438
439    sections
440}
441
442/// Determine if a line is a real (non-commented) top-level section header.
443///
444/// Top-level means `[name]` with no dots. Commented headers like `# [name]`
445/// are NOT treated as section boundaries — they are migrator-generated hints.
446fn is_top_level_section_header(line: &str) -> bool {
447    if line.starts_with('[')
448        && !line.starts_with("[[")
449        && let Some(end) = line.find(']')
450    {
451        return !line[1..end].contains('.');
452    }
453    false
454}
455
456/// Migrate a TOML config string from the old `[llm]` format (with `provider`, `[llm.cloud]`,
457/// `[llm.openai]`, `[llm.orchestrator]`, `[llm.router]` sections) to the new
458/// `[[llm.providers]]` array format.
459///
460/// If the config does not contain legacy LLM keys, it is returned unchanged.
461/// Creates a `.bak` backup at `backup_path` before writing.
462///
463/// # Errors
464///
465/// Returns `MigrateError::Parse` if the input TOML is invalid.
466#[allow(
467    clippy::too_many_lines,
468    clippy::format_push_string,
469    clippy::manual_let_else,
470    clippy::op_ref,
471    clippy::collapsible_if
472)]
473pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
474    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
475
476    // Detect whether this is a legacy-format config.
477    let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
478        Some(t) => t,
479        None => {
480            // No [llm] section at all — nothing to migrate.
481            return Ok(MigrationResult {
482                output: toml_src.to_owned(),
483                added_count: 0,
484                sections_added: Vec::new(),
485            });
486        }
487    };
488
489    let has_provider_field = llm.contains_key("provider");
490    let has_cloud = llm.contains_key("cloud");
491    let has_openai = llm.contains_key("openai");
492    let has_gemini = llm.contains_key("gemini");
493    let has_orchestrator = llm.contains_key("orchestrator");
494    let has_router = llm.contains_key("router");
495    let has_providers = llm.contains_key("providers");
496
497    if !has_provider_field
498        && !has_cloud
499        && !has_openai
500        && !has_orchestrator
501        && !has_router
502        && !has_gemini
503    {
504        // Already in new format (or empty).
505        return Ok(MigrationResult {
506            output: toml_src.to_owned(),
507            added_count: 0,
508            sections_added: Vec::new(),
509        });
510    }
511
512    if has_providers {
513        // Mixed format — refuse to migrate, let the caller handle the error.
514        return Err(MigrateError::Parse(
515            "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
516                .parse::<toml_edit::DocumentMut>()
517                .unwrap_err(),
518        ));
519    }
520
521    // Build new [[llm.providers]] entries from legacy sections.
522    let provider_str = llm
523        .get("provider")
524        .and_then(toml_edit::Item::as_str)
525        .unwrap_or("ollama");
526    let base_url = llm
527        .get("base_url")
528        .and_then(toml_edit::Item::as_str)
529        .map(str::to_owned);
530    let model = llm
531        .get("model")
532        .and_then(toml_edit::Item::as_str)
533        .map(str::to_owned);
534    let embedding_model = llm
535        .get("embedding_model")
536        .and_then(toml_edit::Item::as_str)
537        .map(str::to_owned);
538
539    // Collect provider entries as inline TOML strings.
540    let mut provider_blocks: Vec<String> = Vec::new();
541    let mut routing: Option<String> = None;
542    let mut routes_block: Option<String> = None;
543
544    match provider_str {
545        "ollama" => {
546            let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
547            if let Some(ref m) = model {
548                block.push_str(&format!("model = \"{m}\"\n"));
549            }
550            if let Some(ref em) = embedding_model {
551                block.push_str(&format!("embedding_model = \"{em}\"\n"));
552            }
553            if let Some(ref u) = base_url {
554                block.push_str(&format!("base_url = \"{u}\"\n"));
555            }
556            provider_blocks.push(block);
557        }
558        "claude" => {
559            let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
560            if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
561                if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
562                    block.push_str(&format!("model = \"{m}\"\n"));
563                }
564                if let Some(t) = cloud
565                    .get("max_tokens")
566                    .and_then(toml_edit::Item::as_integer)
567                {
568                    block.push_str(&format!("max_tokens = {t}\n"));
569                }
570                if cloud
571                    .get("server_compaction")
572                    .and_then(toml_edit::Item::as_bool)
573                    == Some(true)
574                {
575                    block.push_str("server_compaction = true\n");
576                }
577                if cloud
578                    .get("enable_extended_context")
579                    .and_then(toml_edit::Item::as_bool)
580                    == Some(true)
581                {
582                    block.push_str("enable_extended_context = true\n");
583                }
584                // H1: migrate thinking config as TOML inline table
585                if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
586                    let pairs: Vec<String> =
587                        thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
588                    block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
589                }
590            } else if let Some(ref m) = model {
591                block.push_str(&format!("model = \"{m}\"\n"));
592            }
593            provider_blocks.push(block);
594        }
595        "openai" => {
596            let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
597            if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
598                copy_str_field(openai, "model", &mut block);
599                copy_str_field(openai, "base_url", &mut block);
600                copy_int_field(openai, "max_tokens", &mut block);
601                copy_str_field(openai, "embedding_model", &mut block);
602                copy_str_field(openai, "reasoning_effort", &mut block);
603            } else if let Some(ref m) = model {
604                block.push_str(&format!("model = \"{m}\"\n"));
605            }
606            provider_blocks.push(block);
607        }
608        "gemini" => {
609            let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
610            if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
611                copy_str_field(gemini, "model", &mut block);
612                copy_int_field(gemini, "max_tokens", &mut block);
613                copy_str_field(gemini, "base_url", &mut block);
614                copy_str_field(gemini, "embedding_model", &mut block);
615                // H2: migrate thinking_level, thinking_budget, include_thoughts
616                copy_str_field(gemini, "thinking_level", &mut block);
617                copy_int_field(gemini, "thinking_budget", &mut block);
618                if let Some(v) = gemini
619                    .get("include_thoughts")
620                    .and_then(toml_edit::Item::as_bool)
621                {
622                    block.push_str(&format!("include_thoughts = {v}\n"));
623                }
624            } else if let Some(ref m) = model {
625                block.push_str(&format!("model = \"{m}\"\n"));
626            }
627            provider_blocks.push(block);
628        }
629        "compatible" => {
630            // [[llm.compatible]] → [[llm.providers]] with type="compatible"
631            if let Some(compat_arr) = llm
632                .get("compatible")
633                .and_then(toml_edit::Item::as_array_of_tables)
634            {
635                for entry in compat_arr {
636                    let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
637                    copy_str_field(entry, "name", &mut block);
638                    copy_str_field(entry, "base_url", &mut block);
639                    copy_str_field(entry, "model", &mut block);
640                    copy_int_field(entry, "max_tokens", &mut block);
641                    copy_str_field(entry, "embedding_model", &mut block);
642                    provider_blocks.push(block);
643                }
644            }
645        }
646        "orchestrator" => {
647            // B3: dereference router chain entries from orchestrator sections.
648            routing = Some("task".to_owned());
649            if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
650                let default_name = orch
651                    .get("default")
652                    .and_then(toml_edit::Item::as_str)
653                    .unwrap_or("")
654                    .to_owned();
655                let embed_name = orch
656                    .get("embed")
657                    .and_then(toml_edit::Item::as_str)
658                    .unwrap_or("")
659                    .to_owned();
660
661                // Build routes block.
662                if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
663                    let mut rb = "[llm.routes]\n".to_owned();
664                    for (key, val) in routes {
665                        if let Some(arr) = val.as_array() {
666                            let items: Vec<String> = arr
667                                .iter()
668                                .filter_map(toml_edit::Value::as_str)
669                                .map(|s| format!("\"{s}\""))
670                                .collect();
671                            rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
672                        }
673                    }
674                    routes_block = Some(rb);
675                }
676
677                // Build provider entries.
678                if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
679                    for (name, pcfg_item) in providers {
680                        let Some(pcfg) = pcfg_item.as_table() else {
681                            continue;
682                        };
683                        let ptype = pcfg
684                            .get("type")
685                            .and_then(toml_edit::Item::as_str)
686                            .unwrap_or("ollama");
687                        let mut block =
688                            format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
689                        if name == &default_name {
690                            block.push_str("default = true\n");
691                        }
692                        if name == &embed_name {
693                            block.push_str("embed = true\n");
694                        }
695                        // Copy provider-specific fields; for claude also copy from [llm.cloud].
696                        copy_str_field(pcfg, "model", &mut block);
697                        copy_str_field(pcfg, "base_url", &mut block);
698                        copy_str_field(pcfg, "embedding_model", &mut block);
699                        // If claude and no model in pcfg, pull from [llm.cloud].
700                        if ptype == "claude" && !pcfg.contains_key("model") {
701                            if let Some(cloud) =
702                                llm.get("cloud").and_then(toml_edit::Item::as_table)
703                            {
704                                copy_str_field(cloud, "model", &mut block);
705                                copy_int_field(cloud, "max_tokens", &mut block);
706                            }
707                        }
708                        // If openai and no model in pcfg, pull from [llm.openai].
709                        if ptype == "openai" && !pcfg.contains_key("model") {
710                            if let Some(openai) =
711                                llm.get("openai").and_then(toml_edit::Item::as_table)
712                            {
713                                copy_str_field(openai, "model", &mut block);
714                                copy_str_field(openai, "base_url", &mut block);
715                                copy_int_field(openai, "max_tokens", &mut block);
716                                copy_str_field(openai, "embedding_model", &mut block);
717                            }
718                        }
719                        // Ollama default fields.
720                        if ptype == "ollama" && !pcfg.contains_key("base_url") {
721                            if let Some(ref u) = base_url {
722                                block.push_str(&format!("base_url = \"{u}\"\n"));
723                            }
724                        }
725                        if ptype == "ollama" && !pcfg.contains_key("model") {
726                            if let Some(ref m) = model {
727                                block.push_str(&format!("model = \"{m}\"\n"));
728                            }
729                        }
730                        if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
731                            if let Some(ref em) = embedding_model {
732                                block.push_str(&format!("embedding_model = \"{em}\"\n"));
733                            }
734                        }
735                        provider_blocks.push(block);
736                    }
737                }
738            }
739        }
740        "router" => {
741            // B3: router chain entries → providers pool with routing strategy.
742            if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
743                let strategy = router
744                    .get("strategy")
745                    .and_then(toml_edit::Item::as_str)
746                    .unwrap_or("ema");
747                routing = Some(strategy.to_owned());
748
749                if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
750                    for item in chain {
751                        let name = item.as_str().unwrap_or_default();
752                        // Try to dereference from legacy sections.
753                        let ptype = infer_provider_type(name, llm);
754                        let mut block =
755                            format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
756                        match ptype {
757                            "claude" => {
758                                if let Some(cloud) =
759                                    llm.get("cloud").and_then(toml_edit::Item::as_table)
760                                {
761                                    copy_str_field(cloud, "model", &mut block);
762                                    copy_int_field(cloud, "max_tokens", &mut block);
763                                }
764                            }
765                            "openai" => {
766                                if let Some(openai) =
767                                    llm.get("openai").and_then(toml_edit::Item::as_table)
768                                {
769                                    copy_str_field(openai, "model", &mut block);
770                                    copy_str_field(openai, "base_url", &mut block);
771                                    copy_int_field(openai, "max_tokens", &mut block);
772                                    copy_str_field(openai, "embedding_model", &mut block);
773                                } else {
774                                    if let Some(ref m) = model {
775                                        block.push_str(&format!("model = \"{m}\"\n"));
776                                    }
777                                    if let Some(ref u) = base_url {
778                                        block.push_str(&format!("base_url = \"{u}\"\n"));
779                                    }
780                                }
781                            }
782                            "ollama" => {
783                                if let Some(ref m) = model {
784                                    block.push_str(&format!("model = \"{m}\"\n"));
785                                }
786                                if let Some(ref em) = embedding_model {
787                                    block.push_str(&format!("embedding_model = \"{em}\"\n"));
788                                }
789                                if let Some(ref u) = base_url {
790                                    block.push_str(&format!("base_url = \"{u}\"\n"));
791                                }
792                            }
793                            _ => {
794                                if let Some(ref m) = model {
795                                    block.push_str(&format!("model = \"{m}\"\n"));
796                                }
797                            }
798                        }
799                        provider_blocks.push(block);
800                    }
801                }
802            }
803        }
804        other => {
805            // Unknown provider — create a minimal entry.
806            let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
807            if let Some(ref m) = model {
808                block.push_str(&format!("model = \"{m}\"\n"));
809            }
810            provider_blocks.push(block);
811        }
812    }
813
814    if provider_blocks.is_empty() {
815        // Nothing to convert; return as-is.
816        return Ok(MigrationResult {
817            output: toml_src.to_owned(),
818            added_count: 0,
819            sections_added: Vec::new(),
820        });
821    }
822
823    // Build the replacement [llm] section.
824    let mut new_llm = "[llm]\n".to_owned();
825    if let Some(ref r) = routing {
826        new_llm.push_str(&format!("routing = \"{r}\"\n"));
827    }
828    // Carry over cross-cutting LLM settings.
829    for key in &[
830        "response_cache_enabled",
831        "response_cache_ttl_secs",
832        "semantic_cache_enabled",
833        "semantic_cache_threshold",
834        "semantic_cache_max_candidates",
835        "summary_model",
836        "instruction_file",
837    ] {
838        if let Some(val) = llm.get(key) {
839            if let Some(v) = val.as_value() {
840                let raw = value_to_toml_string(v);
841                if !raw.is_empty() {
842                    new_llm.push_str(&format!("{key} = {raw}\n"));
843                }
844            }
845        }
846    }
847    new_llm.push('\n');
848
849    if let Some(rb) = routes_block {
850        new_llm.push_str(&rb);
851        new_llm.push('\n');
852    }
853
854    for block in &provider_blocks {
855        new_llm.push_str(block);
856        new_llm.push('\n');
857    }
858
859    // Remove old [llm] section and all its sub-sections from the source,
860    // then prepend the new section.
861    let output = replace_llm_section(toml_src, &new_llm);
862
863    Ok(MigrationResult {
864        output,
865        added_count: provider_blocks.len(),
866        sections_added: vec!["llm.providers".to_owned()],
867    })
868}
869
870/// Infer provider type from a name used in router chain.
871fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
872    match name {
873        "claude" => "claude",
874        "openai" => "openai",
875        "gemini" => "gemini",
876        "ollama" => "ollama",
877        "candle" => "candle",
878        _ => {
879            // Check if there's a compatible entry with this name.
880            if llm.contains_key("compatible") {
881                "compatible"
882            } else if llm.contains_key("openai") {
883                "openai"
884            } else {
885                "ollama"
886            }
887        }
888    }
889}
890
891fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
892    use std::fmt::Write as _;
893    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
894        let _ = writeln!(out, "{key} = \"{v}\"");
895    }
896}
897
898fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
899    use std::fmt::Write as _;
900    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
901        let _ = writeln!(out, "{key} = {v}");
902    }
903}
904
905/// Replace the entire [llm] section (including all [llm.*] sub-sections and
906/// [[llm.*]] array-of-table entries) with `new_llm_section`.
907fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
908    let mut out = String::new();
909    let mut in_llm = false;
910    let mut skip_until_next_top = false;
911
912    for line in toml_str.lines() {
913        let trimmed = line.trim();
914
915        // Check if this is a top-level section header [something] or [[something]].
916        let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
917            && trimmed.ends_with(']')
918            && !trimmed[1..trimmed.len() - 1].contains('.');
919        let is_top_aot = trimmed.starts_with("[[")
920            && trimmed.ends_with("]]")
921            && !trimmed[2..trimmed.len() - 2].contains('.');
922        let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
923            && (trimmed.contains(']'));
924
925        if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
926            in_llm = true;
927            skip_until_next_top = true;
928            continue;
929        }
930
931        if is_top_section || is_top_aot {
932            if skip_until_next_top {
933                // Emit the new LLM section before the next top-level section.
934                out.push_str(new_llm_section);
935                skip_until_next_top = false;
936            }
937            in_llm = false;
938        }
939
940        if !skip_until_next_top {
941            out.push_str(line);
942            out.push('\n');
943        }
944    }
945
946    // If [llm] was the last section, append now.
947    if skip_until_next_top {
948        out.push_str(new_llm_section);
949    }
950
951    out
952}
953
954/// Migrate an old `[llm.stt]` section (with `model` / `base_url` fields) to the new format
955/// where those fields live on a `[[llm.providers]]` entry via `stt_model`.
956///
957/// Transformations:
958/// - `[llm.stt].model` → `stt_model` on the matching or new `[[llm.providers]]` entry
959/// - `[llm.stt].base_url` → `base_url` on that entry (skipped when already present)
960/// - `[llm.stt].provider` is updated to the provider name; the entry is assigned an explicit
961///   `name` when it lacked one (W2 guard).
962/// - Old `model` and `base_url` keys are stripped from `[llm.stt]`.
963///
964/// If `[llm.stt]` is absent or already uses the new format (no `model` / `base_url`), the
965/// input is returned unchanged.
966///
967/// # Errors
968///
969/// Returns `MigrateError::Parse` if the input TOML is invalid.
970/// Returns `MigrateError::InvalidStructure` if `[llm.stt].model` is present but the `[llm]`
971/// key is absent or not a table, making mutation impossible.
972#[allow(clippy::too_many_lines)]
973pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
974    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
975
976    // Extract fields from [llm.stt] if present.
977    let stt_model = doc
978        .get("llm")
979        .and_then(toml_edit::Item::as_table)
980        .and_then(|llm| llm.get("stt"))
981        .and_then(toml_edit::Item::as_table)
982        .and_then(|stt| stt.get("model"))
983        .and_then(toml_edit::Item::as_str)
984        .map(ToOwned::to_owned);
985
986    let stt_base_url = doc
987        .get("llm")
988        .and_then(toml_edit::Item::as_table)
989        .and_then(|llm| llm.get("stt"))
990        .and_then(toml_edit::Item::as_table)
991        .and_then(|stt| stt.get("base_url"))
992        .and_then(toml_edit::Item::as_str)
993        .map(ToOwned::to_owned);
994
995    let stt_provider_hint = doc
996        .get("llm")
997        .and_then(toml_edit::Item::as_table)
998        .and_then(|llm| llm.get("stt"))
999        .and_then(toml_edit::Item::as_table)
1000        .and_then(|stt| stt.get("provider"))
1001        .and_then(toml_edit::Item::as_str)
1002        .map(ToOwned::to_owned)
1003        .unwrap_or_default();
1004
1005    // Nothing to migrate if [llm.stt] does not exist or already lacks the old fields.
1006    if stt_model.is_none() && stt_base_url.is_none() {
1007        return Ok(MigrationResult {
1008            output: toml_src.to_owned(),
1009            added_count: 0,
1010            sections_added: Vec::new(),
1011        });
1012    }
1013
1014    let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1015
1016    // Determine the target provider type based on provider hint.
1017    let target_type = match stt_provider_hint.as_str() {
1018        "candle-whisper" | "candle" => "candle",
1019        _ => "openai",
1020    };
1021
1022    // Find or create a [[llm.providers]] entry to attach stt_model to.
1023    // Priority: entry whose effective name matches the hint, else first entry of matching type.
1024    let providers = doc
1025        .get("llm")
1026        .and_then(toml_edit::Item::as_table)
1027        .and_then(|llm| llm.get("providers"))
1028        .and_then(toml_edit::Item::as_array_of_tables);
1029
1030    let matching_idx = providers.and_then(|arr| {
1031        arr.iter().enumerate().find_map(|(i, t)| {
1032            let name = t
1033                .get("name")
1034                .and_then(toml_edit::Item::as_str)
1035                .unwrap_or("");
1036            let ptype = t
1037                .get("type")
1038                .and_then(toml_edit::Item::as_str)
1039                .unwrap_or("");
1040            // Match by explicit name hint or by type when hint is a legacy backend string.
1041            let name_match = !stt_provider_hint.is_empty()
1042                && (name == stt_provider_hint || ptype == stt_provider_hint);
1043            let type_match = ptype == target_type;
1044            if name_match || type_match {
1045                Some(i)
1046            } else {
1047                None
1048            }
1049        })
1050    });
1051
1052    // Determine the final provider name to write into [llm.stt].provider.
1053    let resolved_provider_name: String;
1054
1055    if let Some(idx) = matching_idx {
1056        // Attach stt_model to the existing entry.
1057        let llm_mut = doc
1058            .get_mut("llm")
1059            .and_then(toml_edit::Item::as_table_mut)
1060            .ok_or(MigrateError::InvalidStructure(
1061                "[llm] table not accessible for mutation",
1062            ))?;
1063        let providers_mut = llm_mut
1064            .get_mut("providers")
1065            .and_then(toml_edit::Item::as_array_of_tables_mut)
1066            .ok_or(MigrateError::InvalidStructure(
1067                "[[llm.providers]] array not accessible for mutation",
1068            ))?;
1069        let entry = providers_mut
1070            .iter_mut()
1071            .nth(idx)
1072            .ok_or(MigrateError::InvalidStructure(
1073                "[[llm.providers]] entry index out of range during mutation",
1074            ))?;
1075
1076        // W2: ensure explicit name.
1077        let existing_name = entry
1078            .get("name")
1079            .and_then(toml_edit::Item::as_str)
1080            .map(ToOwned::to_owned);
1081        let entry_name = existing_name.unwrap_or_else(|| {
1082            let t = entry
1083                .get("type")
1084                .and_then(toml_edit::Item::as_str)
1085                .unwrap_or("openai");
1086            format!("{t}-stt")
1087        });
1088        entry.insert("name", toml_edit::value(entry_name.clone()));
1089        entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1090        if stt_base_url.is_some() && entry.get("base_url").is_none() {
1091            entry.insert(
1092                "base_url",
1093                toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1094            );
1095        }
1096        resolved_provider_name = entry_name;
1097    } else {
1098        // No matching entry — append a new [[llm.providers]] block.
1099        let new_name = if target_type == "candle" {
1100            "local-whisper".to_owned()
1101        } else {
1102            "openai-stt".to_owned()
1103        };
1104        let mut new_entry = toml_edit::Table::new();
1105        new_entry.insert("name", toml_edit::value(new_name.clone()));
1106        new_entry.insert("type", toml_edit::value(target_type));
1107        new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1108        if let Some(ref url) = stt_base_url {
1109            new_entry.insert("base_url", toml_edit::value(url.clone()));
1110        }
1111        // Ensure [[llm.providers]] array exists.
1112        let llm_mut = doc
1113            .get_mut("llm")
1114            .and_then(toml_edit::Item::as_table_mut)
1115            .ok_or(MigrateError::InvalidStructure(
1116                "[llm] table not accessible for mutation",
1117            ))?;
1118        if let Some(item) = llm_mut.get_mut("providers") {
1119            if let Some(arr) = item.as_array_of_tables_mut() {
1120                arr.push(new_entry);
1121            }
1122        } else {
1123            let mut arr = toml_edit::ArrayOfTables::new();
1124            arr.push(new_entry);
1125            llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1126        }
1127        resolved_provider_name = new_name;
1128    }
1129
1130    // Update [llm.stt]: set provider name, remove old fields.
1131    if let Some(stt_table) = doc
1132        .get_mut("llm")
1133        .and_then(toml_edit::Item::as_table_mut)
1134        .and_then(|llm| llm.get_mut("stt"))
1135        .and_then(toml_edit::Item::as_table_mut)
1136    {
1137        stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1138        stt_table.remove("model");
1139        stt_table.remove("base_url");
1140    }
1141
1142    Ok(MigrationResult {
1143        output: doc.to_string(),
1144        added_count: 1,
1145        sections_added: vec!["llm.providers.stt_model".to_owned()],
1146    })
1147}
1148
1149/// Migrate `[orchestration] planner_model` to `planner_provider`.
1150///
1151/// The namespaces differ: `planner_model` held a raw model name (e.g. `"gpt-4o"`),
1152/// while `planner_provider` must reference a `[[llm.providers]]` `name` field. A migrated
1153/// value would cause a silent `warn!` from `build_planner_provider()` when resolution fails,
1154/// so the old value is commented out and a warning is emitted.
1155///
1156/// If `planner_model` is absent, the input is returned unchanged.
1157///
1158/// # Errors
1159///
1160/// Returns `MigrateError::Parse` if the input TOML is invalid.
1161pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1162    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1163
1164    let old_value = doc
1165        .get("orchestration")
1166        .and_then(toml_edit::Item::as_table)
1167        .and_then(|t| t.get("planner_model"))
1168        .and_then(toml_edit::Item::as_value)
1169        .and_then(toml_edit::Value::as_str)
1170        .map(ToOwned::to_owned);
1171
1172    let Some(old_model) = old_value else {
1173        return Ok(MigrationResult {
1174            output: toml_src.to_owned(),
1175            added_count: 0,
1176            sections_added: Vec::new(),
1177        });
1178    };
1179
1180    // Remove the old key via text substitution to preserve surrounding comments/formatting.
1181    // We rebuild the section comment in the output rather than using toml_edit mutations,
1182    // following the same line-oriented approach used elsewhere in this file.
1183    let commented_out = format!(
1184        "# planner_provider = \"{old_model}\"  \
1185         # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1186    );
1187
1188    let orch_table = doc
1189        .get_mut("orchestration")
1190        .and_then(toml_edit::Item::as_table_mut)
1191        .ok_or(MigrateError::InvalidStructure(
1192            "[orchestration] is not a table",
1193        ))?;
1194    orch_table.remove("planner_model");
1195    let decor = orch_table.decor_mut();
1196    let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1197    // Append the commented-out entry as a trailing comment on the section.
1198    let new_suffix = if existing_suffix.trim().is_empty() {
1199        format!("\n{commented_out}\n")
1200    } else {
1201        format!("{existing_suffix}\n{commented_out}\n")
1202    };
1203    decor.set_suffix(new_suffix);
1204
1205    eprintln!(
1206        "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1207         and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1208         `name` field, not a raw model name. Update or remove the commented line."
1209    );
1210
1211    Ok(MigrationResult {
1212        output: doc.to_string(),
1213        added_count: 1,
1214        sections_added: vec!["orchestration.planner_provider".to_owned()],
1215    })
1216}
1217
1218/// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry
1219/// that lacks an explicit `trust_level`.
1220///
1221/// Before this PR all config-defined servers skipped SSRF validation (equivalent to
1222/// `trust_level = "trusted"`). Without migration, upgrading to the new default
1223/// (`Untrusted`) would silently break remote servers on private networks.
1224///
1225/// This function adds `trust_level = "trusted"` only to entries that are missing the
1226/// field, preserving entries that already have it set.
1227///
1228/// # Errors
1229///
1230/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1231pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1232    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1233    let mut added = 0usize;
1234
1235    let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1236        return Ok(MigrationResult {
1237            output: toml_src.to_owned(),
1238            added_count: 0,
1239            sections_added: Vec::new(),
1240        });
1241    };
1242
1243    let Some(servers) = mcp
1244        .get_mut("servers")
1245        .and_then(toml_edit::Item::as_array_of_tables_mut)
1246    else {
1247        return Ok(MigrationResult {
1248            output: toml_src.to_owned(),
1249            added_count: 0,
1250            sections_added: Vec::new(),
1251        });
1252    };
1253
1254    for entry in servers.iter_mut() {
1255        if !entry.contains_key("trust_level") {
1256            entry.insert(
1257                "trust_level",
1258                toml_edit::value(toml_edit::Value::from("trusted")),
1259            );
1260            added += 1;
1261        }
1262    }
1263
1264    if added > 0 {
1265        eprintln!(
1266            "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1267             entr{} (preserving previous SSRF-skip behavior). \
1268             Review and adjust trust levels as needed.",
1269            if added == 1 { "y" } else { "ies" }
1270        );
1271    }
1272
1273    Ok(MigrationResult {
1274        output: doc.to_string(),
1275        added_count: added,
1276        sections_added: if added > 0 {
1277            vec!["mcp.servers.trust_level".to_owned()]
1278        } else {
1279            Vec::new()
1280        },
1281    })
1282}
1283
1284/// Migrate `[agent].max_tool_retries` → `[tools.retry].max_attempts` and
1285/// `[agent].max_retry_duration_secs` → `[tools.retry].budget_secs`.
1286///
1287/// Old fields are preserved (not removed) to avoid breaking configs that rely on them
1288/// until they are officially deprecated in a future release. The new `[tools.retry]` section
1289/// is added if missing, populated with the migrated values.
1290///
1291/// # Errors
1292///
1293/// Returns `MigrateError::Parse` if the TOML is invalid.
1294pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1295    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1296
1297    let max_retries = doc
1298        .get("agent")
1299        .and_then(toml_edit::Item::as_table)
1300        .and_then(|t| t.get("max_tool_retries"))
1301        .and_then(toml_edit::Item::as_value)
1302        .and_then(toml_edit::Value::as_integer)
1303        .map(i64::cast_unsigned);
1304
1305    let budget_secs = doc
1306        .get("agent")
1307        .and_then(toml_edit::Item::as_table)
1308        .and_then(|t| t.get("max_retry_duration_secs"))
1309        .and_then(toml_edit::Item::as_value)
1310        .and_then(toml_edit::Value::as_integer)
1311        .map(i64::cast_unsigned);
1312
1313    if max_retries.is_none() && budget_secs.is_none() {
1314        return Ok(MigrationResult {
1315            output: toml_src.to_owned(),
1316            added_count: 0,
1317            sections_added: Vec::new(),
1318        });
1319    }
1320
1321    // Ensure [tools.retry] section exists.
1322    if !doc.contains_key("tools") {
1323        doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1324    }
1325    let tools_table = doc
1326        .get_mut("tools")
1327        .and_then(toml_edit::Item::as_table_mut)
1328        .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1329
1330    if !tools_table.contains_key("retry") {
1331        tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1332    }
1333    let retry_table = tools_table
1334        .get_mut("retry")
1335        .and_then(toml_edit::Item::as_table_mut)
1336        .ok_or(MigrateError::InvalidStructure(
1337            "[tools.retry] is not a table",
1338        ))?;
1339
1340    let mut added_count = 0usize;
1341
1342    if let Some(retries) = max_retries
1343        && !retry_table.contains_key("max_attempts")
1344    {
1345        retry_table.insert(
1346            "max_attempts",
1347            toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1348        );
1349        added_count += 1;
1350    }
1351
1352    if let Some(secs) = budget_secs
1353        && !retry_table.contains_key("budget_secs")
1354    {
1355        retry_table.insert(
1356            "budget_secs",
1357            toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1358        );
1359        added_count += 1;
1360    }
1361
1362    if added_count > 0 {
1363        eprintln!(
1364            "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1365             [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1366        );
1367    }
1368
1369    Ok(MigrationResult {
1370        output: doc.to_string(),
1371        added_count,
1372        sections_added: if added_count > 0 {
1373            vec!["tools.retry".to_owned()]
1374        } else {
1375            Vec::new()
1376        },
1377    })
1378}
1379
1380// Helper to create a formatted value (used in tests).
1381#[cfg(test)]
1382fn make_formatted_str(s: &str) -> Value {
1383    use toml_edit::Formatted;
1384    Value::String(Formatted::new(s.to_owned()))
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390
1391    #[test]
1392    fn empty_config_gets_sections_as_comments() {
1393        let migrator = ConfigMigrator::new();
1394        let result = migrator.migrate("").expect("migrate empty");
1395        // Should have added sections since reference is non-empty.
1396        assert!(result.added_count > 0 || !result.sections_added.is_empty());
1397        // Output should mention at least agent section.
1398        assert!(
1399            result.output.contains("[agent]") || result.output.contains("# [agent]"),
1400            "expected agent section in output, got:\n{}",
1401            result.output
1402        );
1403    }
1404
1405    #[test]
1406    fn existing_values_not_overwritten() {
1407        let user = r#"
1408[agent]
1409name = "MyAgent"
1410max_tool_iterations = 5
1411"#;
1412        let migrator = ConfigMigrator::new();
1413        let result = migrator.migrate(user).expect("migrate");
1414        // Original name preserved.
1415        assert!(
1416            result.output.contains("name = \"MyAgent\""),
1417            "user value should be preserved"
1418        );
1419        assert!(
1420            result.output.contains("max_tool_iterations = 5"),
1421            "user value should be preserved"
1422        );
1423        // Should not appear as commented default.
1424        assert!(
1425            !result.output.contains("# max_tool_iterations = 10"),
1426            "already-set key should not appear as comment"
1427        );
1428    }
1429
1430    #[test]
1431    fn missing_nested_key_added_as_comment() {
1432        // User has [memory] but is missing some keys.
1433        let user = r#"
1434[memory]
1435sqlite_path = ".zeph/data/zeph.db"
1436"#;
1437        let migrator = ConfigMigrator::new();
1438        let result = migrator.migrate(user).expect("migrate");
1439        // history_limit should be added as comment since it's in reference.
1440        assert!(
1441            result.output.contains("# history_limit"),
1442            "missing key should be added as comment, got:\n{}",
1443            result.output
1444        );
1445    }
1446
1447    #[test]
1448    fn unknown_user_keys_preserved() {
1449        let user = r#"
1450[agent]
1451name = "Test"
1452my_custom_key = "preserved"
1453"#;
1454        let migrator = ConfigMigrator::new();
1455        let result = migrator.migrate(user).expect("migrate");
1456        assert!(
1457            result.output.contains("my_custom_key = \"preserved\""),
1458            "custom user keys must not be removed"
1459        );
1460    }
1461
1462    #[test]
1463    fn idempotent() {
1464        let migrator = ConfigMigrator::new();
1465        let first = migrator
1466            .migrate("[agent]\nname = \"Zeph\"\n")
1467            .expect("first migrate");
1468        let second = migrator.migrate(&first.output).expect("second migrate");
1469        assert_eq!(
1470            first.output, second.output,
1471            "idempotent: full output must be identical on second run"
1472        );
1473    }
1474
1475    #[test]
1476    fn malformed_input_returns_error() {
1477        let migrator = ConfigMigrator::new();
1478        let err = migrator
1479            .migrate("[[invalid toml [[[")
1480            .expect_err("should error");
1481        assert!(
1482            matches!(err, MigrateError::Parse(_)),
1483            "expected Parse error"
1484        );
1485    }
1486
1487    #[test]
1488    fn array_of_tables_preserved() {
1489        let user = r#"
1490[mcp]
1491allowed_commands = ["npx"]
1492
1493[[mcp.servers]]
1494id = "my-server"
1495command = "npx"
1496args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1497"#;
1498        let migrator = ConfigMigrator::new();
1499        let result = migrator.migrate(user).expect("migrate");
1500        // User's [[mcp.servers]] entry must survive.
1501        assert!(
1502            result.output.contains("[[mcp.servers]]"),
1503            "array-of-tables entries must be preserved"
1504        );
1505        assert!(result.output.contains("id = \"my-server\""));
1506    }
1507
1508    #[test]
1509    fn canonical_ordering_applied() {
1510        // Put memory before agent intentionally.
1511        let user = r#"
1512[memory]
1513sqlite_path = ".zeph/data/zeph.db"
1514
1515[agent]
1516name = "Test"
1517"#;
1518        let migrator = ConfigMigrator::new();
1519        let result = migrator.migrate(user).expect("migrate");
1520        // agent should appear before memory in canonical order.
1521        let agent_pos = result.output.find("[agent]");
1522        let memory_pos = result.output.find("[memory]");
1523        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1524            assert!(a < m, "agent section should precede memory section");
1525        }
1526    }
1527
1528    #[test]
1529    fn value_to_toml_string_formats_correctly() {
1530        use toml_edit::Formatted;
1531
1532        let s = make_formatted_str("hello");
1533        assert_eq!(value_to_toml_string(&s), "\"hello\"");
1534
1535        let i = Value::Integer(Formatted::new(42_i64));
1536        assert_eq!(value_to_toml_string(&i), "42");
1537
1538        let b = Value::Boolean(Formatted::new(true));
1539        assert_eq!(value_to_toml_string(&b), "true");
1540
1541        let f = Value::Float(Formatted::new(1.0_f64));
1542        assert_eq!(value_to_toml_string(&f), "1.0");
1543
1544        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1545        assert_eq!(value_to_toml_string(&f2), "3.14");
1546
1547        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1548        let arr_val = Value::Array(arr);
1549        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1550
1551        let empty_arr = Value::Array(Array::new());
1552        assert_eq!(value_to_toml_string(&empty_arr), "[]");
1553    }
1554
1555    #[test]
1556    fn idempotent_full_output_unchanged() {
1557        // Stronger idempotency: the entire output string must not change on a second pass.
1558        let migrator = ConfigMigrator::new();
1559        let first = migrator
1560            .migrate("[agent]\nname = \"Zeph\"\n")
1561            .expect("first migrate");
1562        let second = migrator.migrate(&first.output).expect("second migrate");
1563        assert_eq!(
1564            first.output, second.output,
1565            "full output string must be identical after second migration pass"
1566        );
1567    }
1568
1569    #[test]
1570    fn full_config_produces_zero_additions() {
1571        // Migrating the reference config itself should add nothing new.
1572        let reference = include_str!("../config/default.toml");
1573        let migrator = ConfigMigrator::new();
1574        let result = migrator.migrate(reference).expect("migrate reference");
1575        assert_eq!(
1576            result.added_count, 0,
1577            "migrating the canonical reference should add nothing (added_count = {})",
1578            result.added_count
1579        );
1580        assert!(
1581            result.sections_added.is_empty(),
1582            "migrating the canonical reference should report no sections_added: {:?}",
1583            result.sections_added
1584        );
1585    }
1586
1587    #[test]
1588    fn empty_config_added_count_is_positive() {
1589        // Stricter variant of empty_config_gets_sections_as_comments.
1590        let migrator = ConfigMigrator::new();
1591        let result = migrator.migrate("").expect("migrate empty");
1592        assert!(
1593            result.added_count > 0,
1594            "empty config must report added_count > 0"
1595        );
1596    }
1597
1598    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
1599    // for a pre-guardrail config that has [security] but no [security.guardrail].
1600    #[test]
1601    fn security_without_guardrail_gets_guardrail_commented() {
1602        let user = "[security]\nredact_secrets = true\n";
1603        let migrator = ConfigMigrator::new();
1604        let result = migrator.migrate(user).expect("migrate");
1605        // The generic diff mechanism must add guardrail keys as commented defaults.
1606        assert!(
1607            result.output.contains("guardrail"),
1608            "migration must add guardrail keys for configs without [security.guardrail]: \
1609             got:\n{}",
1610            result.output
1611        );
1612    }
1613
1614    #[test]
1615    fn migrate_reference_contains_tools_policy() {
1616        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
1617        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
1618        // `zeph --migrate-config` will surface the section to users as a discoverable commented
1619        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
1620        let reference = include_str!("../config/default.toml");
1621        assert!(
1622            reference.contains("[tools.policy]"),
1623            "default.toml must contain [tools.policy] section so migrate-config can surface it"
1624        );
1625        assert!(
1626            reference.contains("enabled = false"),
1627            "tools.policy section must include enabled = false default"
1628        );
1629    }
1630
1631    #[test]
1632    fn migrate_reference_contains_probe_section() {
1633        // default.toml must contain the probe section comment block so users can discover it
1634        // when reading the file directly or after running --migrate-config.
1635        let reference = include_str!("../config/default.toml");
1636        assert!(
1637            reference.contains("[memory.compression.probe]"),
1638            "default.toml must contain [memory.compression.probe] section comment"
1639        );
1640        assert!(
1641            reference.contains("hard_fail_threshold"),
1642            "probe section must include hard_fail_threshold default"
1643        );
1644    }
1645
1646    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
1647
1648    #[test]
1649    fn migrate_llm_no_llm_section_is_noop() {
1650        let src = "[agent]\nname = \"Zeph\"\n";
1651        let result = migrate_llm_to_providers(src).expect("migrate");
1652        assert_eq!(result.added_count, 0);
1653        assert_eq!(result.output, src);
1654    }
1655
1656    #[test]
1657    fn migrate_llm_already_new_format_is_noop() {
1658        let src = r#"
1659[llm]
1660[[llm.providers]]
1661type = "ollama"
1662model = "qwen3:8b"
1663"#;
1664        let result = migrate_llm_to_providers(src).expect("migrate");
1665        assert_eq!(result.added_count, 0);
1666    }
1667
1668    #[test]
1669    fn migrate_llm_ollama_produces_providers_block() {
1670        let src = r#"
1671[llm]
1672provider = "ollama"
1673model = "qwen3:8b"
1674base_url = "http://localhost:11434"
1675embedding_model = "nomic-embed-text"
1676"#;
1677        let result = migrate_llm_to_providers(src).expect("migrate");
1678        assert!(
1679            result.output.contains("[[llm.providers]]"),
1680            "should contain [[llm.providers]]:\n{}",
1681            result.output
1682        );
1683        assert!(
1684            result.output.contains("type = \"ollama\""),
1685            "{}",
1686            result.output
1687        );
1688        assert!(
1689            result.output.contains("model = \"qwen3:8b\""),
1690            "{}",
1691            result.output
1692        );
1693    }
1694
1695    #[test]
1696    fn migrate_llm_claude_produces_providers_block() {
1697        let src = r#"
1698[llm]
1699provider = "claude"
1700
1701[llm.cloud]
1702model = "claude-sonnet-4-6"
1703max_tokens = 8192
1704server_compaction = true
1705"#;
1706        let result = migrate_llm_to_providers(src).expect("migrate");
1707        assert!(
1708            result.output.contains("[[llm.providers]]"),
1709            "{}",
1710            result.output
1711        );
1712        assert!(
1713            result.output.contains("type = \"claude\""),
1714            "{}",
1715            result.output
1716        );
1717        assert!(
1718            result.output.contains("model = \"claude-sonnet-4-6\""),
1719            "{}",
1720            result.output
1721        );
1722        assert!(
1723            result.output.contains("server_compaction = true"),
1724            "{}",
1725            result.output
1726        );
1727    }
1728
1729    #[test]
1730    fn migrate_llm_openai_copies_fields() {
1731        let src = r#"
1732[llm]
1733provider = "openai"
1734
1735[llm.openai]
1736base_url = "https://api.openai.com/v1"
1737model = "gpt-4o"
1738max_tokens = 4096
1739"#;
1740        let result = migrate_llm_to_providers(src).expect("migrate");
1741        assert!(
1742            result.output.contains("type = \"openai\""),
1743            "{}",
1744            result.output
1745        );
1746        assert!(
1747            result
1748                .output
1749                .contains("base_url = \"https://api.openai.com/v1\""),
1750            "{}",
1751            result.output
1752        );
1753    }
1754
1755    #[test]
1756    fn migrate_llm_gemini_copies_fields() {
1757        let src = r#"
1758[llm]
1759provider = "gemini"
1760
1761[llm.gemini]
1762model = "gemini-2.0-flash"
1763max_tokens = 8192
1764base_url = "https://generativelanguage.googleapis.com"
1765"#;
1766        let result = migrate_llm_to_providers(src).expect("migrate");
1767        assert!(
1768            result.output.contains("type = \"gemini\""),
1769            "{}",
1770            result.output
1771        );
1772        assert!(
1773            result.output.contains("model = \"gemini-2.0-flash\""),
1774            "{}",
1775            result.output
1776        );
1777    }
1778
1779    #[test]
1780    fn migrate_llm_compatible_copies_multiple_entries() {
1781        let src = r#"
1782[llm]
1783provider = "compatible"
1784
1785[[llm.compatible]]
1786name = "proxy-a"
1787base_url = "http://proxy-a:8080/v1"
1788model = "llama3"
1789max_tokens = 4096
1790
1791[[llm.compatible]]
1792name = "proxy-b"
1793base_url = "http://proxy-b:8080/v1"
1794model = "mistral"
1795max_tokens = 2048
1796"#;
1797        let result = migrate_llm_to_providers(src).expect("migrate");
1798        // Both compatible entries should be emitted.
1799        let count = result.output.matches("[[llm.providers]]").count();
1800        assert_eq!(
1801            count, 2,
1802            "expected 2 [[llm.providers]] blocks:\n{}",
1803            result.output
1804        );
1805        assert!(
1806            result.output.contains("name = \"proxy-a\""),
1807            "{}",
1808            result.output
1809        );
1810        assert!(
1811            result.output.contains("name = \"proxy-b\""),
1812            "{}",
1813            result.output
1814        );
1815    }
1816
1817    #[test]
1818    fn migrate_llm_mixed_format_errors() {
1819        // Legacy + new format together should produce an error.
1820        let src = r#"
1821[llm]
1822provider = "ollama"
1823
1824[[llm.providers]]
1825type = "ollama"
1826"#;
1827        assert!(
1828            migrate_llm_to_providers(src).is_err(),
1829            "mixed format must return error"
1830        );
1831    }
1832
1833    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
1834
1835    #[test]
1836    fn stt_migration_no_stt_section_returns_unchanged() {
1837        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
1838        let result = migrate_stt_to_provider(src).unwrap();
1839        assert_eq!(result.added_count, 0);
1840        assert_eq!(result.output, src);
1841    }
1842
1843    #[test]
1844    fn stt_migration_no_model_or_base_url_returns_unchanged() {
1845        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
1846        let result = migrate_stt_to_provider(src).unwrap();
1847        assert_eq!(result.added_count, 0);
1848    }
1849
1850    #[test]
1851    fn stt_migration_moves_model_to_provider_entry() {
1852        let src = r#"
1853[llm]
1854
1855[[llm.providers]]
1856type = "openai"
1857name = "quality"
1858model = "gpt-5.4"
1859
1860[llm.stt]
1861provider = "quality"
1862model = "gpt-4o-mini-transcribe"
1863language = "en"
1864"#;
1865        let result = migrate_stt_to_provider(src).unwrap();
1866        assert_eq!(result.added_count, 1);
1867        // stt_model should appear in providers entry.
1868        assert!(
1869            result.output.contains("stt_model"),
1870            "stt_model must be in output"
1871        );
1872        // model should be removed from [llm.stt].
1873        // The output should parse cleanly.
1874        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1875        let stt = doc
1876            .get("llm")
1877            .and_then(toml_edit::Item::as_table)
1878            .and_then(|l| l.get("stt"))
1879            .and_then(toml_edit::Item::as_table)
1880            .unwrap();
1881        assert!(
1882            stt.get("model").is_none(),
1883            "model must be removed from [llm.stt]"
1884        );
1885        assert_eq!(
1886            stt.get("provider").and_then(toml_edit::Item::as_str),
1887            Some("quality")
1888        );
1889    }
1890
1891    #[test]
1892    fn stt_migration_creates_new_provider_when_no_match() {
1893        let src = r#"
1894[llm]
1895
1896[[llm.providers]]
1897type = "ollama"
1898name = "local"
1899model = "qwen3:8b"
1900
1901[llm.stt]
1902provider = "whisper"
1903model = "whisper-1"
1904base_url = "https://api.openai.com/v1"
1905language = "en"
1906"#;
1907        let result = migrate_stt_to_provider(src).unwrap();
1908        assert!(
1909            result.output.contains("openai-stt"),
1910            "new entry name must be openai-stt"
1911        );
1912        assert!(
1913            result.output.contains("stt_model"),
1914            "stt_model must be in output"
1915        );
1916    }
1917
1918    #[test]
1919    fn stt_migration_candle_whisper_creates_candle_entry() {
1920        let src = r#"
1921[llm]
1922
1923[llm.stt]
1924provider = "candle-whisper"
1925model = "openai/whisper-tiny"
1926language = "auto"
1927"#;
1928        let result = migrate_stt_to_provider(src).unwrap();
1929        assert!(
1930            result.output.contains("local-whisper"),
1931            "candle entry name must be local-whisper"
1932        );
1933        assert!(result.output.contains("candle"), "type must be candle");
1934    }
1935
1936    #[test]
1937    fn stt_migration_w2_assigns_explicit_name() {
1938        // Provider has no explicit name (type = "openai") — migration must assign one.
1939        let src = r#"
1940[llm]
1941
1942[[llm.providers]]
1943type = "openai"
1944model = "gpt-5.4"
1945
1946[llm.stt]
1947provider = "openai"
1948model = "whisper-1"
1949language = "auto"
1950"#;
1951        let result = migrate_stt_to_provider(src).unwrap();
1952        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1953        let providers = doc
1954            .get("llm")
1955            .and_then(toml_edit::Item::as_table)
1956            .and_then(|l| l.get("providers"))
1957            .and_then(toml_edit::Item::as_array_of_tables)
1958            .unwrap();
1959        let entry = providers
1960            .iter()
1961            .find(|t| t.get("stt_model").is_some())
1962            .unwrap();
1963        // Must have an explicit `name` field (W2).
1964        assert!(
1965            entry.get("name").is_some(),
1966            "migrated entry must have explicit name"
1967        );
1968    }
1969
1970    #[test]
1971    fn stt_migration_removes_base_url_from_stt_table() {
1972        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
1973        let src = r#"
1974[llm]
1975
1976[[llm.providers]]
1977type = "openai"
1978name = "quality"
1979model = "gpt-5.4"
1980
1981[llm.stt]
1982provider = "quality"
1983model = "whisper-1"
1984base_url = "https://api.openai.com/v1"
1985language = "en"
1986"#;
1987        let result = migrate_stt_to_provider(src).unwrap();
1988        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1989        let stt = doc
1990            .get("llm")
1991            .and_then(toml_edit::Item::as_table)
1992            .and_then(|l| l.get("stt"))
1993            .and_then(toml_edit::Item::as_table)
1994            .unwrap();
1995        assert!(
1996            stt.get("model").is_none(),
1997            "model must be removed from [llm.stt]"
1998        );
1999        assert!(
2000            stt.get("base_url").is_none(),
2001            "base_url must be removed from [llm.stt]"
2002        );
2003    }
2004
2005    #[test]
2006    fn migrate_planner_model_to_provider_with_field() {
2007        let input = r#"
2008[orchestration]
2009enabled = true
2010planner_model = "gpt-4o"
2011max_tasks = 20
2012"#;
2013        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2014        assert_eq!(result.added_count, 1, "added_count must be 1");
2015        assert!(
2016            !result.output.contains("planner_model = "),
2017            "planner_model key must be removed from output"
2018        );
2019        assert!(
2020            result.output.contains("# planner_provider"),
2021            "commented-out planner_provider entry must be present"
2022        );
2023        assert!(
2024            result.output.contains("gpt-4o"),
2025            "old value must appear in the comment"
2026        );
2027        assert!(
2028            result.output.contains("MIGRATED"),
2029            "comment must include MIGRATED marker"
2030        );
2031    }
2032
2033    #[test]
2034    fn migrate_planner_model_to_provider_no_op() {
2035        let input = r"
2036[orchestration]
2037enabled = true
2038max_tasks = 20
2039";
2040        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2041        assert_eq!(
2042            result.added_count, 0,
2043            "added_count must be 0 when field is absent"
2044        );
2045        assert_eq!(
2046            result.output, input,
2047            "output must equal input when nothing to migrate"
2048        );
2049    }
2050
2051    #[test]
2052    fn migrate_error_invalid_structure_formats_correctly() {
2053        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
2054        // produces a human-readable message. The error path is triggered when the [llm] item
2055        // is present but cannot be obtained as a mutable table (defensive guard replacing the
2056        // previous .expect() calls that would have panicked).
2057        let err = MigrateError::InvalidStructure("test sentinel");
2058        assert!(
2059            matches!(err, MigrateError::InvalidStructure(_)),
2060            "variant must match"
2061        );
2062        let msg = err.to_string();
2063        assert!(
2064            msg.contains("invalid TOML structure"),
2065            "error message must mention 'invalid TOML structure', got: {msg}"
2066        );
2067        assert!(
2068            msg.contains("test sentinel"),
2069            "message must include reason: {msg}"
2070        );
2071    }
2072
2073    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
2074
2075    #[test]
2076    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2077        let src = r#"
2078[mcp]
2079allowed_commands = ["npx"]
2080
2081[[mcp.servers]]
2082id = "srv-a"
2083command = "npx"
2084args = ["-y", "some-mcp"]
2085
2086[[mcp.servers]]
2087id = "srv-b"
2088command = "npx"
2089args = ["-y", "other-mcp"]
2090"#;
2091        let result = migrate_mcp_trust_levels(src).expect("migrate");
2092        assert_eq!(
2093            result.added_count, 2,
2094            "both entries must get trust_level added"
2095        );
2096        assert!(
2097            result
2098                .sections_added
2099                .contains(&"mcp.servers.trust_level".to_owned()),
2100            "sections_added must report mcp.servers.trust_level"
2101        );
2102        // Both entries must now contain trust_level = "trusted"
2103        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2104        assert_eq!(
2105            occurrences, 2,
2106            "each entry must have trust_level = \"trusted\""
2107        );
2108    }
2109
2110    #[test]
2111    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2112        let src = r#"
2113[[mcp.servers]]
2114id = "srv-a"
2115command = "npx"
2116trust_level = "sandboxed"
2117tool_allowlist = ["read_file"]
2118
2119[[mcp.servers]]
2120id = "srv-b"
2121command = "npx"
2122"#;
2123        let result = migrate_mcp_trust_levels(src).expect("migrate");
2124        // Only srv-b has no trust_level, so only 1 entry should be updated
2125        assert_eq!(
2126            result.added_count, 1,
2127            "only entry without trust_level gets updated"
2128        );
2129        // srv-a's sandboxed value must not be overwritten
2130        assert!(
2131            result.output.contains("trust_level = \"sandboxed\""),
2132            "existing trust_level must not be overwritten"
2133        );
2134        // srv-b gets trusted
2135        assert!(
2136            result.output.contains("trust_level = \"trusted\""),
2137            "entry without trust_level must get trusted"
2138        );
2139    }
2140
2141    #[test]
2142    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2143        let src = "[agent]\nname = \"Zeph\"\n";
2144        let result = migrate_mcp_trust_levels(src).expect("migrate");
2145        assert_eq!(result.added_count, 0);
2146        assert!(result.sections_added.is_empty());
2147        assert_eq!(result.output, src);
2148    }
2149
2150    #[test]
2151    fn migrate_mcp_trust_levels_no_servers_is_noop() {
2152        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2153        let result = migrate_mcp_trust_levels(src).expect("migrate");
2154        assert_eq!(result.added_count, 0);
2155        assert!(result.sections_added.is_empty());
2156        assert_eq!(result.output, src);
2157    }
2158
2159    #[test]
2160    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2161        let src = r#"
2162[[mcp.servers]]
2163id = "srv-a"
2164trust_level = "trusted"
2165
2166[[mcp.servers]]
2167id = "srv-b"
2168trust_level = "untrusted"
2169"#;
2170        let result = migrate_mcp_trust_levels(src).expect("migrate");
2171        assert_eq!(result.added_count, 0);
2172        assert!(result.sections_added.is_empty());
2173    }
2174}