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/// Add a commented-out `database_url = ""` entry under `[memory]` if absent.
1381///
1382/// If the `[memory]` section does not exist it is created. This migration surfaces the
1383/// `PostgreSQL` URL option for users upgrading from a pre-postgres config file.
1384///
1385/// # Errors
1386///
1387/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1388pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1389    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1390
1391    // Ensure [memory] section exists.
1392    if !doc.contains_key("memory") {
1393        doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1394    }
1395
1396    let memory = doc
1397        .get_mut("memory")
1398        .and_then(toml_edit::Item::as_table_mut)
1399        .ok_or(MigrateError::InvalidStructure(
1400            "[memory] key exists but is not a table",
1401        ))?;
1402
1403    if memory.contains_key("database_url") {
1404        return Ok(MigrationResult {
1405            output: toml_src.to_owned(),
1406            added_count: 0,
1407            sections_added: Vec::new(),
1408        });
1409    }
1410
1411    // Append as a commented-out line via table suffix decor (same pattern as merge_table_commented).
1412    let comment = "# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1413         # Leave empty and store the actual URL in the vault:\n\
1414         #   zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1415         # database_url = \"\"\n";
1416    append_comment_to_table_suffix(memory, comment);
1417
1418    Ok(MigrationResult {
1419        output: doc.to_string(),
1420        added_count: 1,
1421        sections_added: vec!["memory.database_url".to_owned()],
1422    })
1423}
1424
1425/// No-op migration for `[tools.shell]` transactional fields added in #2414.
1426///
1427/// All 5 new fields have `#[serde(default)]` so existing configs parse without changes.
1428/// This step adds them as commented-out hints in `[tools.shell]` if not already present.
1429///
1430/// # Errors
1431///
1432/// Returns `MigrateError` if the TOML cannot be parsed or `[tools.shell]` is malformed.
1433pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1434    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1435
1436    let tools_shell_exists = doc
1437        .get("tools")
1438        .and_then(toml_edit::Item::as_table)
1439        .is_some_and(|t| t.contains_key("shell"));
1440    if !tools_shell_exists {
1441        // No [tools.shell] section — nothing to annotate; new configs will get defaults.
1442        return Ok(MigrationResult {
1443            output: toml_src.to_owned(),
1444            added_count: 0,
1445            sections_added: Vec::new(),
1446        });
1447    }
1448
1449    let shell = doc
1450        .get_mut("tools")
1451        .and_then(toml_edit::Item::as_table_mut)
1452        .and_then(|t| t.get_mut("shell"))
1453        .and_then(toml_edit::Item::as_table_mut)
1454        .ok_or(MigrateError::InvalidStructure(
1455            "[tools.shell] is not a table",
1456        ))?;
1457
1458    if shell.contains_key("transactional") {
1459        return Ok(MigrationResult {
1460            output: toml_src.to_owned(),
1461            added_count: 0,
1462            sections_added: Vec::new(),
1463        });
1464    }
1465
1466    let comment = "# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1467         # transactional = false\n\
1468         # transaction_scope = []          # glob patterns; empty = all extracted paths\n\
1469         # auto_rollback = false           # rollback when exit code >= 2\n\
1470         # auto_rollback_exit_codes = []   # explicit exit codes; overrides >= 2 heuristic\n\
1471         # snapshot_required = false       # abort if snapshot fails (default: warn and proceed)\n";
1472    append_comment_to_table_suffix(shell, comment);
1473
1474    Ok(MigrationResult {
1475        output: doc.to_string(),
1476        added_count: 1,
1477        sections_added: vec!["tools.shell.transactional".to_owned()],
1478    })
1479}
1480
1481// Helper to create a formatted value (used in tests).
1482#[cfg(test)]
1483fn make_formatted_str(s: &str) -> Value {
1484    use toml_edit::Formatted;
1485    Value::String(Formatted::new(s.to_owned()))
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490    use super::*;
1491
1492    #[test]
1493    fn empty_config_gets_sections_as_comments() {
1494        let migrator = ConfigMigrator::new();
1495        let result = migrator.migrate("").expect("migrate empty");
1496        // Should have added sections since reference is non-empty.
1497        assert!(result.added_count > 0 || !result.sections_added.is_empty());
1498        // Output should mention at least agent section.
1499        assert!(
1500            result.output.contains("[agent]") || result.output.contains("# [agent]"),
1501            "expected agent section in output, got:\n{}",
1502            result.output
1503        );
1504    }
1505
1506    #[test]
1507    fn existing_values_not_overwritten() {
1508        let user = r#"
1509[agent]
1510name = "MyAgent"
1511max_tool_iterations = 5
1512"#;
1513        let migrator = ConfigMigrator::new();
1514        let result = migrator.migrate(user).expect("migrate");
1515        // Original name preserved.
1516        assert!(
1517            result.output.contains("name = \"MyAgent\""),
1518            "user value should be preserved"
1519        );
1520        assert!(
1521            result.output.contains("max_tool_iterations = 5"),
1522            "user value should be preserved"
1523        );
1524        // Should not appear as commented default.
1525        assert!(
1526            !result.output.contains("# max_tool_iterations = 10"),
1527            "already-set key should not appear as comment"
1528        );
1529    }
1530
1531    #[test]
1532    fn missing_nested_key_added_as_comment() {
1533        // User has [memory] but is missing some keys.
1534        let user = r#"
1535[memory]
1536sqlite_path = ".zeph/data/zeph.db"
1537"#;
1538        let migrator = ConfigMigrator::new();
1539        let result = migrator.migrate(user).expect("migrate");
1540        // history_limit should be added as comment since it's in reference.
1541        assert!(
1542            result.output.contains("# history_limit"),
1543            "missing key should be added as comment, got:\n{}",
1544            result.output
1545        );
1546    }
1547
1548    #[test]
1549    fn unknown_user_keys_preserved() {
1550        let user = r#"
1551[agent]
1552name = "Test"
1553my_custom_key = "preserved"
1554"#;
1555        let migrator = ConfigMigrator::new();
1556        let result = migrator.migrate(user).expect("migrate");
1557        assert!(
1558            result.output.contains("my_custom_key = \"preserved\""),
1559            "custom user keys must not be removed"
1560        );
1561    }
1562
1563    #[test]
1564    fn idempotent() {
1565        let migrator = ConfigMigrator::new();
1566        let first = migrator
1567            .migrate("[agent]\nname = \"Zeph\"\n")
1568            .expect("first migrate");
1569        let second = migrator.migrate(&first.output).expect("second migrate");
1570        assert_eq!(
1571            first.output, second.output,
1572            "idempotent: full output must be identical on second run"
1573        );
1574    }
1575
1576    #[test]
1577    fn malformed_input_returns_error() {
1578        let migrator = ConfigMigrator::new();
1579        let err = migrator
1580            .migrate("[[invalid toml [[[")
1581            .expect_err("should error");
1582        assert!(
1583            matches!(err, MigrateError::Parse(_)),
1584            "expected Parse error"
1585        );
1586    }
1587
1588    #[test]
1589    fn array_of_tables_preserved() {
1590        let user = r#"
1591[mcp]
1592allowed_commands = ["npx"]
1593
1594[[mcp.servers]]
1595id = "my-server"
1596command = "npx"
1597args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1598"#;
1599        let migrator = ConfigMigrator::new();
1600        let result = migrator.migrate(user).expect("migrate");
1601        // User's [[mcp.servers]] entry must survive.
1602        assert!(
1603            result.output.contains("[[mcp.servers]]"),
1604            "array-of-tables entries must be preserved"
1605        );
1606        assert!(result.output.contains("id = \"my-server\""));
1607    }
1608
1609    #[test]
1610    fn canonical_ordering_applied() {
1611        // Put memory before agent intentionally.
1612        let user = r#"
1613[memory]
1614sqlite_path = ".zeph/data/zeph.db"
1615
1616[agent]
1617name = "Test"
1618"#;
1619        let migrator = ConfigMigrator::new();
1620        let result = migrator.migrate(user).expect("migrate");
1621        // agent should appear before memory in canonical order.
1622        let agent_pos = result.output.find("[agent]");
1623        let memory_pos = result.output.find("[memory]");
1624        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1625            assert!(a < m, "agent section should precede memory section");
1626        }
1627    }
1628
1629    #[test]
1630    fn value_to_toml_string_formats_correctly() {
1631        use toml_edit::Formatted;
1632
1633        let s = make_formatted_str("hello");
1634        assert_eq!(value_to_toml_string(&s), "\"hello\"");
1635
1636        let i = Value::Integer(Formatted::new(42_i64));
1637        assert_eq!(value_to_toml_string(&i), "42");
1638
1639        let b = Value::Boolean(Formatted::new(true));
1640        assert_eq!(value_to_toml_string(&b), "true");
1641
1642        let f = Value::Float(Formatted::new(1.0_f64));
1643        assert_eq!(value_to_toml_string(&f), "1.0");
1644
1645        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1646        assert_eq!(value_to_toml_string(&f2), "3.14");
1647
1648        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1649        let arr_val = Value::Array(arr);
1650        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1651
1652        let empty_arr = Value::Array(Array::new());
1653        assert_eq!(value_to_toml_string(&empty_arr), "[]");
1654    }
1655
1656    #[test]
1657    fn idempotent_full_output_unchanged() {
1658        // Stronger idempotency: the entire output string must not change on a second pass.
1659        let migrator = ConfigMigrator::new();
1660        let first = migrator
1661            .migrate("[agent]\nname = \"Zeph\"\n")
1662            .expect("first migrate");
1663        let second = migrator.migrate(&first.output).expect("second migrate");
1664        assert_eq!(
1665            first.output, second.output,
1666            "full output string must be identical after second migration pass"
1667        );
1668    }
1669
1670    #[test]
1671    fn full_config_produces_zero_additions() {
1672        // Migrating the reference config itself should add nothing new.
1673        let reference = include_str!("../config/default.toml");
1674        let migrator = ConfigMigrator::new();
1675        let result = migrator.migrate(reference).expect("migrate reference");
1676        assert_eq!(
1677            result.added_count, 0,
1678            "migrating the canonical reference should add nothing (added_count = {})",
1679            result.added_count
1680        );
1681        assert!(
1682            result.sections_added.is_empty(),
1683            "migrating the canonical reference should report no sections_added: {:?}",
1684            result.sections_added
1685        );
1686    }
1687
1688    #[test]
1689    fn empty_config_added_count_is_positive() {
1690        // Stricter variant of empty_config_gets_sections_as_comments.
1691        let migrator = ConfigMigrator::new();
1692        let result = migrator.migrate("").expect("migrate empty");
1693        assert!(
1694            result.added_count > 0,
1695            "empty config must report added_count > 0"
1696        );
1697    }
1698
1699    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
1700    // for a pre-guardrail config that has [security] but no [security.guardrail].
1701    #[test]
1702    fn security_without_guardrail_gets_guardrail_commented() {
1703        let user = "[security]\nredact_secrets = true\n";
1704        let migrator = ConfigMigrator::new();
1705        let result = migrator.migrate(user).expect("migrate");
1706        // The generic diff mechanism must add guardrail keys as commented defaults.
1707        assert!(
1708            result.output.contains("guardrail"),
1709            "migration must add guardrail keys for configs without [security.guardrail]: \
1710             got:\n{}",
1711            result.output
1712        );
1713    }
1714
1715    #[test]
1716    fn migrate_reference_contains_tools_policy() {
1717        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
1718        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
1719        // `zeph --migrate-config` will surface the section to users as a discoverable commented
1720        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
1721        let reference = include_str!("../config/default.toml");
1722        assert!(
1723            reference.contains("[tools.policy]"),
1724            "default.toml must contain [tools.policy] section so migrate-config can surface it"
1725        );
1726        assert!(
1727            reference.contains("enabled = false"),
1728            "tools.policy section must include enabled = false default"
1729        );
1730    }
1731
1732    #[test]
1733    fn migrate_reference_contains_probe_section() {
1734        // default.toml must contain the probe section comment block so users can discover it
1735        // when reading the file directly or after running --migrate-config.
1736        let reference = include_str!("../config/default.toml");
1737        assert!(
1738            reference.contains("[memory.compression.probe]"),
1739            "default.toml must contain [memory.compression.probe] section comment"
1740        );
1741        assert!(
1742            reference.contains("hard_fail_threshold"),
1743            "probe section must include hard_fail_threshold default"
1744        );
1745    }
1746
1747    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
1748
1749    #[test]
1750    fn migrate_llm_no_llm_section_is_noop() {
1751        let src = "[agent]\nname = \"Zeph\"\n";
1752        let result = migrate_llm_to_providers(src).expect("migrate");
1753        assert_eq!(result.added_count, 0);
1754        assert_eq!(result.output, src);
1755    }
1756
1757    #[test]
1758    fn migrate_llm_already_new_format_is_noop() {
1759        let src = r#"
1760[llm]
1761[[llm.providers]]
1762type = "ollama"
1763model = "qwen3:8b"
1764"#;
1765        let result = migrate_llm_to_providers(src).expect("migrate");
1766        assert_eq!(result.added_count, 0);
1767    }
1768
1769    #[test]
1770    fn migrate_llm_ollama_produces_providers_block() {
1771        let src = r#"
1772[llm]
1773provider = "ollama"
1774model = "qwen3:8b"
1775base_url = "http://localhost:11434"
1776embedding_model = "nomic-embed-text"
1777"#;
1778        let result = migrate_llm_to_providers(src).expect("migrate");
1779        assert!(
1780            result.output.contains("[[llm.providers]]"),
1781            "should contain [[llm.providers]]:\n{}",
1782            result.output
1783        );
1784        assert!(
1785            result.output.contains("type = \"ollama\""),
1786            "{}",
1787            result.output
1788        );
1789        assert!(
1790            result.output.contains("model = \"qwen3:8b\""),
1791            "{}",
1792            result.output
1793        );
1794    }
1795
1796    #[test]
1797    fn migrate_llm_claude_produces_providers_block() {
1798        let src = r#"
1799[llm]
1800provider = "claude"
1801
1802[llm.cloud]
1803model = "claude-sonnet-4-6"
1804max_tokens = 8192
1805server_compaction = true
1806"#;
1807        let result = migrate_llm_to_providers(src).expect("migrate");
1808        assert!(
1809            result.output.contains("[[llm.providers]]"),
1810            "{}",
1811            result.output
1812        );
1813        assert!(
1814            result.output.contains("type = \"claude\""),
1815            "{}",
1816            result.output
1817        );
1818        assert!(
1819            result.output.contains("model = \"claude-sonnet-4-6\""),
1820            "{}",
1821            result.output
1822        );
1823        assert!(
1824            result.output.contains("server_compaction = true"),
1825            "{}",
1826            result.output
1827        );
1828    }
1829
1830    #[test]
1831    fn migrate_llm_openai_copies_fields() {
1832        let src = r#"
1833[llm]
1834provider = "openai"
1835
1836[llm.openai]
1837base_url = "https://api.openai.com/v1"
1838model = "gpt-4o"
1839max_tokens = 4096
1840"#;
1841        let result = migrate_llm_to_providers(src).expect("migrate");
1842        assert!(
1843            result.output.contains("type = \"openai\""),
1844            "{}",
1845            result.output
1846        );
1847        assert!(
1848            result
1849                .output
1850                .contains("base_url = \"https://api.openai.com/v1\""),
1851            "{}",
1852            result.output
1853        );
1854    }
1855
1856    #[test]
1857    fn migrate_llm_gemini_copies_fields() {
1858        let src = r#"
1859[llm]
1860provider = "gemini"
1861
1862[llm.gemini]
1863model = "gemini-2.0-flash"
1864max_tokens = 8192
1865base_url = "https://generativelanguage.googleapis.com"
1866"#;
1867        let result = migrate_llm_to_providers(src).expect("migrate");
1868        assert!(
1869            result.output.contains("type = \"gemini\""),
1870            "{}",
1871            result.output
1872        );
1873        assert!(
1874            result.output.contains("model = \"gemini-2.0-flash\""),
1875            "{}",
1876            result.output
1877        );
1878    }
1879
1880    #[test]
1881    fn migrate_llm_compatible_copies_multiple_entries() {
1882        let src = r#"
1883[llm]
1884provider = "compatible"
1885
1886[[llm.compatible]]
1887name = "proxy-a"
1888base_url = "http://proxy-a:8080/v1"
1889model = "llama3"
1890max_tokens = 4096
1891
1892[[llm.compatible]]
1893name = "proxy-b"
1894base_url = "http://proxy-b:8080/v1"
1895model = "mistral"
1896max_tokens = 2048
1897"#;
1898        let result = migrate_llm_to_providers(src).expect("migrate");
1899        // Both compatible entries should be emitted.
1900        let count = result.output.matches("[[llm.providers]]").count();
1901        assert_eq!(
1902            count, 2,
1903            "expected 2 [[llm.providers]] blocks:\n{}",
1904            result.output
1905        );
1906        assert!(
1907            result.output.contains("name = \"proxy-a\""),
1908            "{}",
1909            result.output
1910        );
1911        assert!(
1912            result.output.contains("name = \"proxy-b\""),
1913            "{}",
1914            result.output
1915        );
1916    }
1917
1918    #[test]
1919    fn migrate_llm_mixed_format_errors() {
1920        // Legacy + new format together should produce an error.
1921        let src = r#"
1922[llm]
1923provider = "ollama"
1924
1925[[llm.providers]]
1926type = "ollama"
1927"#;
1928        assert!(
1929            migrate_llm_to_providers(src).is_err(),
1930            "mixed format must return error"
1931        );
1932    }
1933
1934    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
1935
1936    #[test]
1937    fn stt_migration_no_stt_section_returns_unchanged() {
1938        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
1939        let result = migrate_stt_to_provider(src).unwrap();
1940        assert_eq!(result.added_count, 0);
1941        assert_eq!(result.output, src);
1942    }
1943
1944    #[test]
1945    fn stt_migration_no_model_or_base_url_returns_unchanged() {
1946        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
1947        let result = migrate_stt_to_provider(src).unwrap();
1948        assert_eq!(result.added_count, 0);
1949    }
1950
1951    #[test]
1952    fn stt_migration_moves_model_to_provider_entry() {
1953        let src = r#"
1954[llm]
1955
1956[[llm.providers]]
1957type = "openai"
1958name = "quality"
1959model = "gpt-5.4"
1960
1961[llm.stt]
1962provider = "quality"
1963model = "gpt-4o-mini-transcribe"
1964language = "en"
1965"#;
1966        let result = migrate_stt_to_provider(src).unwrap();
1967        assert_eq!(result.added_count, 1);
1968        // stt_model should appear in providers entry.
1969        assert!(
1970            result.output.contains("stt_model"),
1971            "stt_model must be in output"
1972        );
1973        // model should be removed from [llm.stt].
1974        // The output should parse cleanly.
1975        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1976        let stt = doc
1977            .get("llm")
1978            .and_then(toml_edit::Item::as_table)
1979            .and_then(|l| l.get("stt"))
1980            .and_then(toml_edit::Item::as_table)
1981            .unwrap();
1982        assert!(
1983            stt.get("model").is_none(),
1984            "model must be removed from [llm.stt]"
1985        );
1986        assert_eq!(
1987            stt.get("provider").and_then(toml_edit::Item::as_str),
1988            Some("quality")
1989        );
1990    }
1991
1992    #[test]
1993    fn stt_migration_creates_new_provider_when_no_match() {
1994        let src = r#"
1995[llm]
1996
1997[[llm.providers]]
1998type = "ollama"
1999name = "local"
2000model = "qwen3:8b"
2001
2002[llm.stt]
2003provider = "whisper"
2004model = "whisper-1"
2005base_url = "https://api.openai.com/v1"
2006language = "en"
2007"#;
2008        let result = migrate_stt_to_provider(src).unwrap();
2009        assert!(
2010            result.output.contains("openai-stt"),
2011            "new entry name must be openai-stt"
2012        );
2013        assert!(
2014            result.output.contains("stt_model"),
2015            "stt_model must be in output"
2016        );
2017    }
2018
2019    #[test]
2020    fn stt_migration_candle_whisper_creates_candle_entry() {
2021        let src = r#"
2022[llm]
2023
2024[llm.stt]
2025provider = "candle-whisper"
2026model = "openai/whisper-tiny"
2027language = "auto"
2028"#;
2029        let result = migrate_stt_to_provider(src).unwrap();
2030        assert!(
2031            result.output.contains("local-whisper"),
2032            "candle entry name must be local-whisper"
2033        );
2034        assert!(result.output.contains("candle"), "type must be candle");
2035    }
2036
2037    #[test]
2038    fn stt_migration_w2_assigns_explicit_name() {
2039        // Provider has no explicit name (type = "openai") — migration must assign one.
2040        let src = r#"
2041[llm]
2042
2043[[llm.providers]]
2044type = "openai"
2045model = "gpt-5.4"
2046
2047[llm.stt]
2048provider = "openai"
2049model = "whisper-1"
2050language = "auto"
2051"#;
2052        let result = migrate_stt_to_provider(src).unwrap();
2053        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2054        let providers = doc
2055            .get("llm")
2056            .and_then(toml_edit::Item::as_table)
2057            .and_then(|l| l.get("providers"))
2058            .and_then(toml_edit::Item::as_array_of_tables)
2059            .unwrap();
2060        let entry = providers
2061            .iter()
2062            .find(|t| t.get("stt_model").is_some())
2063            .unwrap();
2064        // Must have an explicit `name` field (W2).
2065        assert!(
2066            entry.get("name").is_some(),
2067            "migrated entry must have explicit name"
2068        );
2069    }
2070
2071    #[test]
2072    fn stt_migration_removes_base_url_from_stt_table() {
2073        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
2074        let src = r#"
2075[llm]
2076
2077[[llm.providers]]
2078type = "openai"
2079name = "quality"
2080model = "gpt-5.4"
2081
2082[llm.stt]
2083provider = "quality"
2084model = "whisper-1"
2085base_url = "https://api.openai.com/v1"
2086language = "en"
2087"#;
2088        let result = migrate_stt_to_provider(src).unwrap();
2089        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2090        let stt = doc
2091            .get("llm")
2092            .and_then(toml_edit::Item::as_table)
2093            .and_then(|l| l.get("stt"))
2094            .and_then(toml_edit::Item::as_table)
2095            .unwrap();
2096        assert!(
2097            stt.get("model").is_none(),
2098            "model must be removed from [llm.stt]"
2099        );
2100        assert!(
2101            stt.get("base_url").is_none(),
2102            "base_url must be removed from [llm.stt]"
2103        );
2104    }
2105
2106    #[test]
2107    fn migrate_planner_model_to_provider_with_field() {
2108        let input = r#"
2109[orchestration]
2110enabled = true
2111planner_model = "gpt-4o"
2112max_tasks = 20
2113"#;
2114        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2115        assert_eq!(result.added_count, 1, "added_count must be 1");
2116        assert!(
2117            !result.output.contains("planner_model = "),
2118            "planner_model key must be removed from output"
2119        );
2120        assert!(
2121            result.output.contains("# planner_provider"),
2122            "commented-out planner_provider entry must be present"
2123        );
2124        assert!(
2125            result.output.contains("gpt-4o"),
2126            "old value must appear in the comment"
2127        );
2128        assert!(
2129            result.output.contains("MIGRATED"),
2130            "comment must include MIGRATED marker"
2131        );
2132    }
2133
2134    #[test]
2135    fn migrate_planner_model_to_provider_no_op() {
2136        let input = r"
2137[orchestration]
2138enabled = true
2139max_tasks = 20
2140";
2141        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2142        assert_eq!(
2143            result.added_count, 0,
2144            "added_count must be 0 when field is absent"
2145        );
2146        assert_eq!(
2147            result.output, input,
2148            "output must equal input when nothing to migrate"
2149        );
2150    }
2151
2152    #[test]
2153    fn migrate_error_invalid_structure_formats_correctly() {
2154        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
2155        // produces a human-readable message. The error path is triggered when the [llm] item
2156        // is present but cannot be obtained as a mutable table (defensive guard replacing the
2157        // previous .expect() calls that would have panicked).
2158        let err = MigrateError::InvalidStructure("test sentinel");
2159        assert!(
2160            matches!(err, MigrateError::InvalidStructure(_)),
2161            "variant must match"
2162        );
2163        let msg = err.to_string();
2164        assert!(
2165            msg.contains("invalid TOML structure"),
2166            "error message must mention 'invalid TOML structure', got: {msg}"
2167        );
2168        assert!(
2169            msg.contains("test sentinel"),
2170            "message must include reason: {msg}"
2171        );
2172    }
2173
2174    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
2175
2176    #[test]
2177    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2178        let src = r#"
2179[mcp]
2180allowed_commands = ["npx"]
2181
2182[[mcp.servers]]
2183id = "srv-a"
2184command = "npx"
2185args = ["-y", "some-mcp"]
2186
2187[[mcp.servers]]
2188id = "srv-b"
2189command = "npx"
2190args = ["-y", "other-mcp"]
2191"#;
2192        let result = migrate_mcp_trust_levels(src).expect("migrate");
2193        assert_eq!(
2194            result.added_count, 2,
2195            "both entries must get trust_level added"
2196        );
2197        assert!(
2198            result
2199                .sections_added
2200                .contains(&"mcp.servers.trust_level".to_owned()),
2201            "sections_added must report mcp.servers.trust_level"
2202        );
2203        // Both entries must now contain trust_level = "trusted"
2204        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2205        assert_eq!(
2206            occurrences, 2,
2207            "each entry must have trust_level = \"trusted\""
2208        );
2209    }
2210
2211    #[test]
2212    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2213        let src = r#"
2214[[mcp.servers]]
2215id = "srv-a"
2216command = "npx"
2217trust_level = "sandboxed"
2218tool_allowlist = ["read_file"]
2219
2220[[mcp.servers]]
2221id = "srv-b"
2222command = "npx"
2223"#;
2224        let result = migrate_mcp_trust_levels(src).expect("migrate");
2225        // Only srv-b has no trust_level, so only 1 entry should be updated
2226        assert_eq!(
2227            result.added_count, 1,
2228            "only entry without trust_level gets updated"
2229        );
2230        // srv-a's sandboxed value must not be overwritten
2231        assert!(
2232            result.output.contains("trust_level = \"sandboxed\""),
2233            "existing trust_level must not be overwritten"
2234        );
2235        // srv-b gets trusted
2236        assert!(
2237            result.output.contains("trust_level = \"trusted\""),
2238            "entry without trust_level must get trusted"
2239        );
2240    }
2241
2242    #[test]
2243    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2244        let src = "[agent]\nname = \"Zeph\"\n";
2245        let result = migrate_mcp_trust_levels(src).expect("migrate");
2246        assert_eq!(result.added_count, 0);
2247        assert!(result.sections_added.is_empty());
2248        assert_eq!(result.output, src);
2249    }
2250
2251    #[test]
2252    fn migrate_mcp_trust_levels_no_servers_is_noop() {
2253        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2254        let result = migrate_mcp_trust_levels(src).expect("migrate");
2255        assert_eq!(result.added_count, 0);
2256        assert!(result.sections_added.is_empty());
2257        assert_eq!(result.output, src);
2258    }
2259
2260    #[test]
2261    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2262        let src = r#"
2263[[mcp.servers]]
2264id = "srv-a"
2265trust_level = "trusted"
2266
2267[[mcp.servers]]
2268id = "srv-b"
2269trust_level = "untrusted"
2270"#;
2271        let result = migrate_mcp_trust_levels(src).expect("migrate");
2272        assert_eq!(result.added_count, 0);
2273        assert!(result.sections_added.is_empty());
2274    }
2275
2276    #[test]
2277    fn migrate_database_url_adds_comment_when_absent() {
2278        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2279        let result = migrate_database_url(src).expect("migrate");
2280        assert_eq!(result.added_count, 1);
2281        assert!(
2282            result
2283                .sections_added
2284                .contains(&"memory.database_url".to_owned())
2285        );
2286        assert!(result.output.contains("# database_url = \"\""));
2287    }
2288
2289    #[test]
2290    fn migrate_database_url_is_noop_when_present() {
2291        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2292        let result = migrate_database_url(src).expect("migrate");
2293        assert_eq!(result.added_count, 0);
2294        assert!(result.sections_added.is_empty());
2295        assert_eq!(result.output, src);
2296    }
2297
2298    #[test]
2299    fn migrate_database_url_creates_memory_section_when_absent() {
2300        let src = "[agent]\nname = \"Zeph\"\n";
2301        let result = migrate_database_url(src).expect("migrate");
2302        assert_eq!(result.added_count, 1);
2303        assert!(result.output.contains("# database_url = \"\""));
2304    }
2305}