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#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
457fn migrate_ollama_provider(
458    llm: &toml_edit::Table,
459    model: &Option<String>,
460    base_url: &Option<String>,
461    embedding_model: &Option<String>,
462) -> Vec<String> {
463    let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
464    if let Some(m) = model {
465        block.push_str(&format!("model = \"{m}\"\n"));
466    }
467    if let Some(em) = embedding_model {
468        block.push_str(&format!("embedding_model = \"{em}\"\n"));
469    }
470    if let Some(u) = base_url {
471        block.push_str(&format!("base_url = \"{u}\"\n"));
472    }
473    let _ = llm; // not needed for simple ollama case
474    vec![block]
475}
476
477#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
478fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
479    let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
480    if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
481        if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
482            block.push_str(&format!("model = \"{m}\"\n"));
483        }
484        if let Some(t) = cloud
485            .get("max_tokens")
486            .and_then(toml_edit::Item::as_integer)
487        {
488            block.push_str(&format!("max_tokens = {t}\n"));
489        }
490        if cloud
491            .get("server_compaction")
492            .and_then(toml_edit::Item::as_bool)
493            == Some(true)
494        {
495            block.push_str("server_compaction = true\n");
496        }
497        if cloud
498            .get("enable_extended_context")
499            .and_then(toml_edit::Item::as_bool)
500            == Some(true)
501        {
502            block.push_str("enable_extended_context = true\n");
503        }
504        if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
505            let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
506            block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
507        }
508    } else if let Some(m) = model {
509        block.push_str(&format!("model = \"{m}\"\n"));
510    }
511    vec![block]
512}
513
514#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
515fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
516    let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
517    if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
518        copy_str_field(openai, "model", &mut block);
519        copy_str_field(openai, "base_url", &mut block);
520        copy_int_field(openai, "max_tokens", &mut block);
521        copy_str_field(openai, "embedding_model", &mut block);
522        copy_str_field(openai, "reasoning_effort", &mut block);
523    } else if let Some(m) = model {
524        block.push_str(&format!("model = \"{m}\"\n"));
525    }
526    vec![block]
527}
528
529#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
530fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
531    let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
532    if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
533        copy_str_field(gemini, "model", &mut block);
534        copy_int_field(gemini, "max_tokens", &mut block);
535        copy_str_field(gemini, "base_url", &mut block);
536        copy_str_field(gemini, "embedding_model", &mut block);
537        copy_str_field(gemini, "thinking_level", &mut block);
538        copy_int_field(gemini, "thinking_budget", &mut block);
539        if let Some(v) = gemini
540            .get("include_thoughts")
541            .and_then(toml_edit::Item::as_bool)
542        {
543            block.push_str(&format!("include_thoughts = {v}\n"));
544        }
545    } else if let Some(m) = model {
546        block.push_str(&format!("model = \"{m}\"\n"));
547    }
548    vec![block]
549}
550
551#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
552fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
553    let mut blocks = Vec::new();
554    if let Some(compat_arr) = llm
555        .get("compatible")
556        .and_then(toml_edit::Item::as_array_of_tables)
557    {
558        for entry in compat_arr {
559            let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
560            copy_str_field(entry, "name", &mut block);
561            copy_str_field(entry, "base_url", &mut block);
562            copy_str_field(entry, "model", &mut block);
563            copy_int_field(entry, "max_tokens", &mut block);
564            copy_str_field(entry, "embedding_model", &mut block);
565            blocks.push(block);
566        }
567    }
568    blocks
569}
570
571// Returns (provider_blocks, routing, routes_block)
572#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
573fn migrate_orchestrator_provider(
574    llm: &toml_edit::Table,
575    model: &Option<String>,
576    base_url: &Option<String>,
577    embedding_model: &Option<String>,
578) -> (Vec<String>, Option<String>, Option<String>) {
579    let mut blocks = Vec::new();
580    let routing = Some("task".to_owned());
581    let mut routes_block = None;
582    if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
583        let default_name = orch
584            .get("default")
585            .and_then(toml_edit::Item::as_str)
586            .unwrap_or("")
587            .to_owned();
588        let embed_name = orch
589            .get("embed")
590            .and_then(toml_edit::Item::as_str)
591            .unwrap_or("")
592            .to_owned();
593        if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
594            let mut rb = "[llm.routes]\n".to_owned();
595            for (key, val) in routes {
596                if let Some(arr) = val.as_array() {
597                    let items: Vec<String> = arr
598                        .iter()
599                        .filter_map(toml_edit::Value::as_str)
600                        .map(|s| format!("\"{s}\""))
601                        .collect();
602                    rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
603                }
604            }
605            routes_block = Some(rb);
606        }
607        if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
608            for (name, pcfg_item) in providers {
609                let Some(pcfg) = pcfg_item.as_table() else {
610                    continue;
611                };
612                let ptype = pcfg
613                    .get("type")
614                    .and_then(toml_edit::Item::as_str)
615                    .unwrap_or("ollama");
616                let mut block =
617                    format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
618                if name == default_name {
619                    block.push_str("default = true\n");
620                }
621                if name == embed_name {
622                    block.push_str("embed = true\n");
623                }
624                copy_str_field(pcfg, "model", &mut block);
625                copy_str_field(pcfg, "base_url", &mut block);
626                copy_str_field(pcfg, "embedding_model", &mut block);
627                if ptype == "claude" && !pcfg.contains_key("model") {
628                    if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
629                        copy_str_field(cloud, "model", &mut block);
630                        copy_int_field(cloud, "max_tokens", &mut block);
631                    }
632                }
633                if ptype == "openai" && !pcfg.contains_key("model") {
634                    if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
635                        copy_str_field(openai, "model", &mut block);
636                        copy_str_field(openai, "base_url", &mut block);
637                        copy_int_field(openai, "max_tokens", &mut block);
638                        copy_str_field(openai, "embedding_model", &mut block);
639                    }
640                }
641                if ptype == "ollama" && !pcfg.contains_key("base_url") {
642                    if let Some(u) = base_url {
643                        block.push_str(&format!("base_url = \"{u}\"\n"));
644                    }
645                }
646                if ptype == "ollama" && !pcfg.contains_key("model") {
647                    if let Some(m) = model {
648                        block.push_str(&format!("model = \"{m}\"\n"));
649                    }
650                }
651                if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
652                    if let Some(em) = embedding_model {
653                        block.push_str(&format!("embedding_model = \"{em}\"\n"));
654                    }
655                }
656                blocks.push(block);
657            }
658        }
659    }
660    (blocks, routing, routes_block)
661}
662
663// Returns (provider_blocks, routing)
664#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
665fn migrate_router_provider(
666    llm: &toml_edit::Table,
667    model: &Option<String>,
668    base_url: &Option<String>,
669    embedding_model: &Option<String>,
670) -> (Vec<String>, Option<String>) {
671    let mut blocks = Vec::new();
672    let mut routing = None;
673    if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
674        let strategy = router
675            .get("strategy")
676            .and_then(toml_edit::Item::as_str)
677            .unwrap_or("ema");
678        routing = Some(strategy.to_owned());
679        if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
680            for item in chain {
681                let name = item.as_str().unwrap_or_default();
682                let ptype = infer_provider_type(name, llm);
683                let mut block =
684                    format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
685                match ptype {
686                    "claude" => {
687                        if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
688                            copy_str_field(cloud, "model", &mut block);
689                            copy_int_field(cloud, "max_tokens", &mut block);
690                        }
691                    }
692                    "openai" => {
693                        if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
694                        {
695                            copy_str_field(openai, "model", &mut block);
696                            copy_str_field(openai, "base_url", &mut block);
697                            copy_int_field(openai, "max_tokens", &mut block);
698                            copy_str_field(openai, "embedding_model", &mut block);
699                        } else {
700                            if let Some(m) = model {
701                                block.push_str(&format!("model = \"{m}\"\n"));
702                            }
703                            if let Some(u) = base_url {
704                                block.push_str(&format!("base_url = \"{u}\"\n"));
705                            }
706                        }
707                    }
708                    "ollama" => {
709                        if let Some(m) = model {
710                            block.push_str(&format!("model = \"{m}\"\n"));
711                        }
712                        if let Some(em) = embedding_model {
713                            block.push_str(&format!("embedding_model = \"{em}\"\n"));
714                        }
715                        if let Some(u) = base_url {
716                            block.push_str(&format!("base_url = \"{u}\"\n"));
717                        }
718                    }
719                    _ => {
720                        if let Some(m) = model {
721                            block.push_str(&format!("model = \"{m}\"\n"));
722                        }
723                    }
724                }
725                blocks.push(block);
726            }
727        }
728    }
729    (blocks, routing)
730}
731
732/// Migrate a TOML config string from the old `[llm]` format (with `provider`, `[llm.cloud]`,
733/// `[llm.openai]`, `[llm.orchestrator]`, `[llm.router]` sections) to the new
734/// `[[llm.providers]]` array format.
735///
736/// If the config does not contain legacy LLM keys, it is returned unchanged.
737/// Creates a `.bak` backup at `backup_path` before writing.
738///
739/// # Errors
740///
741/// Returns `MigrateError::Parse` if the input TOML is invalid.
742#[allow(
743    clippy::too_many_lines,
744    clippy::format_push_string,
745    clippy::manual_let_else,
746    clippy::op_ref,
747    clippy::collapsible_if
748)]
749pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
750    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
751
752    // Detect whether this is a legacy-format config.
753    let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
754        Some(t) => t,
755        None => {
756            // No [llm] section at all — nothing to migrate.
757            return Ok(MigrationResult {
758                output: toml_src.to_owned(),
759                added_count: 0,
760                sections_added: Vec::new(),
761            });
762        }
763    };
764
765    let has_provider_field = llm.contains_key("provider");
766    let has_cloud = llm.contains_key("cloud");
767    let has_openai = llm.contains_key("openai");
768    let has_gemini = llm.contains_key("gemini");
769    let has_orchestrator = llm.contains_key("orchestrator");
770    let has_router = llm.contains_key("router");
771    let has_providers = llm.contains_key("providers");
772
773    if !has_provider_field
774        && !has_cloud
775        && !has_openai
776        && !has_orchestrator
777        && !has_router
778        && !has_gemini
779    {
780        // Already in new format (or empty).
781        return Ok(MigrationResult {
782            output: toml_src.to_owned(),
783            added_count: 0,
784            sections_added: Vec::new(),
785        });
786    }
787
788    if has_providers {
789        // Mixed format — refuse to migrate, let the caller handle the error.
790        return Err(MigrateError::Parse(
791            "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
792                .parse::<toml_edit::DocumentMut>()
793                .unwrap_err(),
794        ));
795    }
796
797    // Build new [[llm.providers]] entries from legacy sections.
798    let provider_str = llm
799        .get("provider")
800        .and_then(toml_edit::Item::as_str)
801        .unwrap_or("ollama");
802    let base_url = llm
803        .get("base_url")
804        .and_then(toml_edit::Item::as_str)
805        .map(str::to_owned);
806    let model = llm
807        .get("model")
808        .and_then(toml_edit::Item::as_str)
809        .map(str::to_owned);
810    let embedding_model = llm
811        .get("embedding_model")
812        .and_then(toml_edit::Item::as_str)
813        .map(str::to_owned);
814
815    // Collect provider entries as inline TOML strings.
816    let mut provider_blocks: Vec<String> = Vec::new();
817    let mut routing: Option<String> = None;
818    let mut routes_block: Option<String> = None;
819
820    match provider_str {
821        "ollama" => {
822            provider_blocks.extend(migrate_ollama_provider(
823                llm,
824                &model,
825                &base_url,
826                &embedding_model,
827            ));
828        }
829        "claude" => {
830            provider_blocks.extend(migrate_claude_provider(llm, &model));
831        }
832        "openai" => {
833            provider_blocks.extend(migrate_openai_provider(llm, &model));
834        }
835        "gemini" => {
836            provider_blocks.extend(migrate_gemini_provider(llm, &model));
837        }
838        "compatible" => {
839            provider_blocks.extend(migrate_compatible_provider(llm));
840        }
841        "orchestrator" => {
842            let (blocks, r, rb) =
843                migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
844            provider_blocks.extend(blocks);
845            routing = r;
846            routes_block = rb;
847        }
848        "router" => {
849            let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
850            provider_blocks.extend(blocks);
851            routing = r;
852        }
853        other => {
854            let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
855            if let Some(ref m) = model {
856                block.push_str(&format!("model = \"{m}\"\n"));
857            }
858            provider_blocks.push(block);
859        }
860    }
861
862    if provider_blocks.is_empty() {
863        // Nothing to convert; return as-is.
864        return Ok(MigrationResult {
865            output: toml_src.to_owned(),
866            added_count: 0,
867            sections_added: Vec::new(),
868        });
869    }
870
871    // Build the replacement [llm] section.
872    let mut new_llm = "[llm]\n".to_owned();
873    if let Some(ref r) = routing {
874        new_llm.push_str(&format!("routing = \"{r}\"\n"));
875    }
876    // Carry over cross-cutting LLM settings.
877    for key in &[
878        "response_cache_enabled",
879        "response_cache_ttl_secs",
880        "semantic_cache_enabled",
881        "semantic_cache_threshold",
882        "semantic_cache_max_candidates",
883        "summary_model",
884        "instruction_file",
885    ] {
886        if let Some(val) = llm.get(key) {
887            if let Some(v) = val.as_value() {
888                let raw = value_to_toml_string(v);
889                if !raw.is_empty() {
890                    new_llm.push_str(&format!("{key} = {raw}\n"));
891                }
892            }
893        }
894    }
895    new_llm.push('\n');
896
897    if let Some(rb) = routes_block {
898        new_llm.push_str(&rb);
899        new_llm.push('\n');
900    }
901
902    for block in &provider_blocks {
903        new_llm.push_str(block);
904        new_llm.push('\n');
905    }
906
907    // Remove old [llm] section and all its sub-sections from the source,
908    // then prepend the new section.
909    let output = replace_llm_section(toml_src, &new_llm);
910
911    Ok(MigrationResult {
912        output,
913        added_count: provider_blocks.len(),
914        sections_added: vec!["llm.providers".to_owned()],
915    })
916}
917
918/// Infer provider type from a name used in router chain.
919fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
920    match name {
921        "claude" => "claude",
922        "openai" => "openai",
923        "gemini" => "gemini",
924        "ollama" => "ollama",
925        "candle" => "candle",
926        _ => {
927            // Check if there's a compatible entry with this name.
928            if llm.contains_key("compatible") {
929                "compatible"
930            } else if llm.contains_key("openai") {
931                "openai"
932            } else {
933                "ollama"
934            }
935        }
936    }
937}
938
939fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
940    use std::fmt::Write as _;
941    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
942        let _ = writeln!(out, "{key} = \"{v}\"");
943    }
944}
945
946fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
947    use std::fmt::Write as _;
948    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
949        let _ = writeln!(out, "{key} = {v}");
950    }
951}
952
953/// Replace the entire [llm] section (including all [llm.*] sub-sections and
954/// [[llm.*]] array-of-table entries) with `new_llm_section`.
955fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
956    let mut out = String::new();
957    let mut in_llm = false;
958    let mut skip_until_next_top = false;
959
960    for line in toml_str.lines() {
961        let trimmed = line.trim();
962
963        // Check if this is a top-level section header [something] or [[something]].
964        let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
965            && trimmed.ends_with(']')
966            && !trimmed[1..trimmed.len() - 1].contains('.');
967        let is_top_aot = trimmed.starts_with("[[")
968            && trimmed.ends_with("]]")
969            && !trimmed[2..trimmed.len() - 2].contains('.');
970        let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
971            && (trimmed.contains(']'));
972
973        if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
974            in_llm = true;
975            skip_until_next_top = true;
976            continue;
977        }
978
979        if is_top_section || is_top_aot {
980            if skip_until_next_top {
981                // Emit the new LLM section before the next top-level section.
982                out.push_str(new_llm_section);
983                skip_until_next_top = false;
984            }
985            in_llm = false;
986        }
987
988        if !skip_until_next_top {
989            out.push_str(line);
990            out.push('\n');
991        }
992    }
993
994    // If [llm] was the last section, append now.
995    if skip_until_next_top {
996        out.push_str(new_llm_section);
997    }
998
999    out
1000}
1001
1002/// Migrate an old `[llm.stt]` section (with `model` / `base_url` fields) to the new format
1003/// where those fields live on a `[[llm.providers]]` entry via `stt_model`.
1004///
1005/// Transformations:
1006/// - `[llm.stt].model` → `stt_model` on the matching or new `[[llm.providers]]` entry
1007/// - `[llm.stt].base_url` → `base_url` on that entry (skipped when already present)
1008/// - `[llm.stt].provider` is updated to the provider name; the entry is assigned an explicit
1009///   `name` when it lacked one (W2 guard).
1010/// - Old `model` and `base_url` keys are stripped from `[llm.stt]`.
1011///
1012/// If `[llm.stt]` is absent or already uses the new format (no `model` / `base_url`), the
1013/// input is returned unchanged.
1014///
1015/// # Errors
1016///
1017/// Returns `MigrateError::Parse` if the input TOML is invalid.
1018/// Returns `MigrateError::InvalidStructure` if `[llm.stt].model` is present but the `[llm]`
1019/// key is absent or not a table, making mutation impossible.
1020#[allow(clippy::too_many_lines)]
1021pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1022    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1023
1024    // Extract fields from [llm.stt] if present.
1025    let stt_model = doc
1026        .get("llm")
1027        .and_then(toml_edit::Item::as_table)
1028        .and_then(|llm| llm.get("stt"))
1029        .and_then(toml_edit::Item::as_table)
1030        .and_then(|stt| stt.get("model"))
1031        .and_then(toml_edit::Item::as_str)
1032        .map(ToOwned::to_owned);
1033
1034    let stt_base_url = doc
1035        .get("llm")
1036        .and_then(toml_edit::Item::as_table)
1037        .and_then(|llm| llm.get("stt"))
1038        .and_then(toml_edit::Item::as_table)
1039        .and_then(|stt| stt.get("base_url"))
1040        .and_then(toml_edit::Item::as_str)
1041        .map(ToOwned::to_owned);
1042
1043    let stt_provider_hint = doc
1044        .get("llm")
1045        .and_then(toml_edit::Item::as_table)
1046        .and_then(|llm| llm.get("stt"))
1047        .and_then(toml_edit::Item::as_table)
1048        .and_then(|stt| stt.get("provider"))
1049        .and_then(toml_edit::Item::as_str)
1050        .map(ToOwned::to_owned)
1051        .unwrap_or_default();
1052
1053    // Nothing to migrate if [llm.stt] does not exist or already lacks the old fields.
1054    if stt_model.is_none() && stt_base_url.is_none() {
1055        return Ok(MigrationResult {
1056            output: toml_src.to_owned(),
1057            added_count: 0,
1058            sections_added: Vec::new(),
1059        });
1060    }
1061
1062    let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1063
1064    // Determine the target provider type based on provider hint.
1065    let target_type = match stt_provider_hint.as_str() {
1066        "candle-whisper" | "candle" => "candle",
1067        _ => "openai",
1068    };
1069
1070    // Find or create a [[llm.providers]] entry to attach stt_model to.
1071    // Priority: entry whose effective name matches the hint, else first entry of matching type.
1072    let providers = doc
1073        .get("llm")
1074        .and_then(toml_edit::Item::as_table)
1075        .and_then(|llm| llm.get("providers"))
1076        .and_then(toml_edit::Item::as_array_of_tables);
1077
1078    let matching_idx = providers.and_then(|arr| {
1079        arr.iter().enumerate().find_map(|(i, t)| {
1080            let name = t
1081                .get("name")
1082                .and_then(toml_edit::Item::as_str)
1083                .unwrap_or("");
1084            let ptype = t
1085                .get("type")
1086                .and_then(toml_edit::Item::as_str)
1087                .unwrap_or("");
1088            // Match by explicit name hint or by type when hint is a legacy backend string.
1089            let name_match = !stt_provider_hint.is_empty()
1090                && (name == stt_provider_hint || ptype == stt_provider_hint);
1091            let type_match = ptype == target_type;
1092            if name_match || type_match {
1093                Some(i)
1094            } else {
1095                None
1096            }
1097        })
1098    });
1099
1100    // Determine the final provider name to write into [llm.stt].provider.
1101    let resolved_provider_name: String;
1102
1103    if let Some(idx) = matching_idx {
1104        // Attach stt_model to the existing entry.
1105        let llm_mut = doc
1106            .get_mut("llm")
1107            .and_then(toml_edit::Item::as_table_mut)
1108            .ok_or(MigrateError::InvalidStructure(
1109                "[llm] table not accessible for mutation",
1110            ))?;
1111        let providers_mut = llm_mut
1112            .get_mut("providers")
1113            .and_then(toml_edit::Item::as_array_of_tables_mut)
1114            .ok_or(MigrateError::InvalidStructure(
1115                "[[llm.providers]] array not accessible for mutation",
1116            ))?;
1117        let entry = providers_mut
1118            .iter_mut()
1119            .nth(idx)
1120            .ok_or(MigrateError::InvalidStructure(
1121                "[[llm.providers]] entry index out of range during mutation",
1122            ))?;
1123
1124        // W2: ensure explicit name.
1125        let existing_name = entry
1126            .get("name")
1127            .and_then(toml_edit::Item::as_str)
1128            .map(ToOwned::to_owned);
1129        let entry_name = existing_name.unwrap_or_else(|| {
1130            let t = entry
1131                .get("type")
1132                .and_then(toml_edit::Item::as_str)
1133                .unwrap_or("openai");
1134            format!("{t}-stt")
1135        });
1136        entry.insert("name", toml_edit::value(entry_name.clone()));
1137        entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1138        if stt_base_url.is_some() && entry.get("base_url").is_none() {
1139            entry.insert(
1140                "base_url",
1141                toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1142            );
1143        }
1144        resolved_provider_name = entry_name;
1145    } else {
1146        // No matching entry — append a new [[llm.providers]] block.
1147        let new_name = if target_type == "candle" {
1148            "local-whisper".to_owned()
1149        } else {
1150            "openai-stt".to_owned()
1151        };
1152        let mut new_entry = toml_edit::Table::new();
1153        new_entry.insert("name", toml_edit::value(new_name.clone()));
1154        new_entry.insert("type", toml_edit::value(target_type));
1155        new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1156        if let Some(ref url) = stt_base_url {
1157            new_entry.insert("base_url", toml_edit::value(url.clone()));
1158        }
1159        // Ensure [[llm.providers]] array exists.
1160        let llm_mut = doc
1161            .get_mut("llm")
1162            .and_then(toml_edit::Item::as_table_mut)
1163            .ok_or(MigrateError::InvalidStructure(
1164                "[llm] table not accessible for mutation",
1165            ))?;
1166        if let Some(item) = llm_mut.get_mut("providers") {
1167            if let Some(arr) = item.as_array_of_tables_mut() {
1168                arr.push(new_entry);
1169            }
1170        } else {
1171            let mut arr = toml_edit::ArrayOfTables::new();
1172            arr.push(new_entry);
1173            llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1174        }
1175        resolved_provider_name = new_name;
1176    }
1177
1178    // Update [llm.stt]: set provider name, remove old fields.
1179    if let Some(stt_table) = doc
1180        .get_mut("llm")
1181        .and_then(toml_edit::Item::as_table_mut)
1182        .and_then(|llm| llm.get_mut("stt"))
1183        .and_then(toml_edit::Item::as_table_mut)
1184    {
1185        stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1186        stt_table.remove("model");
1187        stt_table.remove("base_url");
1188    }
1189
1190    Ok(MigrationResult {
1191        output: doc.to_string(),
1192        added_count: 1,
1193        sections_added: vec!["llm.providers.stt_model".to_owned()],
1194    })
1195}
1196
1197/// Migrate `[orchestration] planner_model` to `planner_provider`.
1198///
1199/// The namespaces differ: `planner_model` held a raw model name (e.g. `"gpt-4o"`),
1200/// while `planner_provider` must reference a `[[llm.providers]]` `name` field. A migrated
1201/// value would cause a silent `warn!` from `build_planner_provider()` when resolution fails,
1202/// so the old value is commented out and a warning is emitted.
1203///
1204/// If `planner_model` is absent, the input is returned unchanged.
1205///
1206/// # Errors
1207///
1208/// Returns `MigrateError::Parse` if the input TOML is invalid.
1209pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1210    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1211
1212    let old_value = doc
1213        .get("orchestration")
1214        .and_then(toml_edit::Item::as_table)
1215        .and_then(|t| t.get("planner_model"))
1216        .and_then(toml_edit::Item::as_value)
1217        .and_then(toml_edit::Value::as_str)
1218        .map(ToOwned::to_owned);
1219
1220    let Some(old_model) = old_value else {
1221        return Ok(MigrationResult {
1222            output: toml_src.to_owned(),
1223            added_count: 0,
1224            sections_added: Vec::new(),
1225        });
1226    };
1227
1228    // Remove the old key via text substitution to preserve surrounding comments/formatting.
1229    // We rebuild the section comment in the output rather than using toml_edit mutations,
1230    // following the same line-oriented approach used elsewhere in this file.
1231    let commented_out = format!(
1232        "# planner_provider = \"{old_model}\"  \
1233         # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1234    );
1235
1236    let orch_table = doc
1237        .get_mut("orchestration")
1238        .and_then(toml_edit::Item::as_table_mut)
1239        .ok_or(MigrateError::InvalidStructure(
1240            "[orchestration] is not a table",
1241        ))?;
1242    orch_table.remove("planner_model");
1243    let decor = orch_table.decor_mut();
1244    let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1245    // Append the commented-out entry as a trailing comment on the section.
1246    let new_suffix = if existing_suffix.trim().is_empty() {
1247        format!("\n{commented_out}\n")
1248    } else {
1249        format!("{existing_suffix}\n{commented_out}\n")
1250    };
1251    decor.set_suffix(new_suffix);
1252
1253    eprintln!(
1254        "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1255         and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1256         `name` field, not a raw model name. Update or remove the commented line."
1257    );
1258
1259    Ok(MigrationResult {
1260        output: doc.to_string(),
1261        added_count: 1,
1262        sections_added: vec!["orchestration.planner_provider".to_owned()],
1263    })
1264}
1265
1266/// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry
1267/// that lacks an explicit `trust_level`.
1268///
1269/// Before this PR all config-defined servers skipped SSRF validation (equivalent to
1270/// `trust_level = "trusted"`). Without migration, upgrading to the new default
1271/// (`Untrusted`) would silently break remote servers on private networks.
1272///
1273/// This function adds `trust_level = "trusted"` only to entries that are missing the
1274/// field, preserving entries that already have it set.
1275///
1276/// # Errors
1277///
1278/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1279pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1280    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1281    let mut added = 0usize;
1282
1283    let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1284        return Ok(MigrationResult {
1285            output: toml_src.to_owned(),
1286            added_count: 0,
1287            sections_added: Vec::new(),
1288        });
1289    };
1290
1291    let Some(servers) = mcp
1292        .get_mut("servers")
1293        .and_then(toml_edit::Item::as_array_of_tables_mut)
1294    else {
1295        return Ok(MigrationResult {
1296            output: toml_src.to_owned(),
1297            added_count: 0,
1298            sections_added: Vec::new(),
1299        });
1300    };
1301
1302    for entry in servers.iter_mut() {
1303        if !entry.contains_key("trust_level") {
1304            entry.insert(
1305                "trust_level",
1306                toml_edit::value(toml_edit::Value::from("trusted")),
1307            );
1308            added += 1;
1309        }
1310    }
1311
1312    if added > 0 {
1313        eprintln!(
1314            "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1315             entr{} (preserving previous SSRF-skip behavior). \
1316             Review and adjust trust levels as needed.",
1317            if added == 1 { "y" } else { "ies" }
1318        );
1319    }
1320
1321    Ok(MigrationResult {
1322        output: doc.to_string(),
1323        added_count: added,
1324        sections_added: if added > 0 {
1325            vec!["mcp.servers.trust_level".to_owned()]
1326        } else {
1327            Vec::new()
1328        },
1329    })
1330}
1331
1332/// Migrate `[agent].max_tool_retries` → `[tools.retry].max_attempts` and
1333/// `[agent].max_retry_duration_secs` → `[tools.retry].budget_secs`.
1334///
1335/// Old fields are preserved (not removed) to avoid breaking configs that rely on them
1336/// until they are officially deprecated in a future release. The new `[tools.retry]` section
1337/// is added if missing, populated with the migrated values.
1338///
1339/// # Errors
1340///
1341/// Returns `MigrateError::Parse` if the TOML is invalid.
1342pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1343    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1344
1345    let max_retries = doc
1346        .get("agent")
1347        .and_then(toml_edit::Item::as_table)
1348        .and_then(|t| t.get("max_tool_retries"))
1349        .and_then(toml_edit::Item::as_value)
1350        .and_then(toml_edit::Value::as_integer)
1351        .map(i64::cast_unsigned);
1352
1353    let budget_secs = doc
1354        .get("agent")
1355        .and_then(toml_edit::Item::as_table)
1356        .and_then(|t| t.get("max_retry_duration_secs"))
1357        .and_then(toml_edit::Item::as_value)
1358        .and_then(toml_edit::Value::as_integer)
1359        .map(i64::cast_unsigned);
1360
1361    if max_retries.is_none() && budget_secs.is_none() {
1362        return Ok(MigrationResult {
1363            output: toml_src.to_owned(),
1364            added_count: 0,
1365            sections_added: Vec::new(),
1366        });
1367    }
1368
1369    // Ensure [tools.retry] section exists.
1370    if !doc.contains_key("tools") {
1371        doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1372    }
1373    let tools_table = doc
1374        .get_mut("tools")
1375        .and_then(toml_edit::Item::as_table_mut)
1376        .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1377
1378    if !tools_table.contains_key("retry") {
1379        tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1380    }
1381    let retry_table = tools_table
1382        .get_mut("retry")
1383        .and_then(toml_edit::Item::as_table_mut)
1384        .ok_or(MigrateError::InvalidStructure(
1385            "[tools.retry] is not a table",
1386        ))?;
1387
1388    let mut added_count = 0usize;
1389
1390    if let Some(retries) = max_retries
1391        && !retry_table.contains_key("max_attempts")
1392    {
1393        retry_table.insert(
1394            "max_attempts",
1395            toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1396        );
1397        added_count += 1;
1398    }
1399
1400    if let Some(secs) = budget_secs
1401        && !retry_table.contains_key("budget_secs")
1402    {
1403        retry_table.insert(
1404            "budget_secs",
1405            toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1406        );
1407        added_count += 1;
1408    }
1409
1410    if added_count > 0 {
1411        eprintln!(
1412            "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1413             [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1414        );
1415    }
1416
1417    Ok(MigrationResult {
1418        output: doc.to_string(),
1419        added_count,
1420        sections_added: if added_count > 0 {
1421            vec!["tools.retry".to_owned()]
1422        } else {
1423            Vec::new()
1424        },
1425    })
1426}
1427
1428/// Add a commented-out `database_url = ""` entry under `[memory]` if absent.
1429///
1430/// If the `[memory]` section does not exist it is created. This migration surfaces the
1431/// `PostgreSQL` URL option for users upgrading from a pre-postgres config file.
1432///
1433/// # Errors
1434///
1435/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1436pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1437    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1438
1439    // Ensure [memory] section exists.
1440    if !doc.contains_key("memory") {
1441        doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1442    }
1443
1444    let memory = doc
1445        .get_mut("memory")
1446        .and_then(toml_edit::Item::as_table_mut)
1447        .ok_or(MigrateError::InvalidStructure(
1448            "[memory] key exists but is not a table",
1449        ))?;
1450
1451    if memory.contains_key("database_url") {
1452        return Ok(MigrationResult {
1453            output: toml_src.to_owned(),
1454            added_count: 0,
1455            sections_added: Vec::new(),
1456        });
1457    }
1458
1459    // Append as a commented-out line via table suffix decor (same pattern as merge_table_commented).
1460    let comment = "# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1461         # Leave empty and store the actual URL in the vault:\n\
1462         #   zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1463         # database_url = \"\"\n";
1464    append_comment_to_table_suffix(memory, comment);
1465
1466    Ok(MigrationResult {
1467        output: doc.to_string(),
1468        added_count: 1,
1469        sections_added: vec!["memory.database_url".to_owned()],
1470    })
1471}
1472
1473/// No-op migration for `[tools.shell]` transactional fields added in #2414.
1474///
1475/// All 5 new fields have `#[serde(default)]` so existing configs parse without changes.
1476/// This step adds them as commented-out hints in `[tools.shell]` if not already present.
1477///
1478/// # Errors
1479///
1480/// Returns `MigrateError` if the TOML cannot be parsed or `[tools.shell]` is malformed.
1481pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1482    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1483
1484    let tools_shell_exists = doc
1485        .get("tools")
1486        .and_then(toml_edit::Item::as_table)
1487        .is_some_and(|t| t.contains_key("shell"));
1488    if !tools_shell_exists {
1489        // No [tools.shell] section — nothing to annotate; new configs will get defaults.
1490        return Ok(MigrationResult {
1491            output: toml_src.to_owned(),
1492            added_count: 0,
1493            sections_added: Vec::new(),
1494        });
1495    }
1496
1497    let shell = doc
1498        .get_mut("tools")
1499        .and_then(toml_edit::Item::as_table_mut)
1500        .and_then(|t| t.get_mut("shell"))
1501        .and_then(toml_edit::Item::as_table_mut)
1502        .ok_or(MigrateError::InvalidStructure(
1503            "[tools.shell] is not a table",
1504        ))?;
1505
1506    if shell.contains_key("transactional") {
1507        return Ok(MigrationResult {
1508            output: toml_src.to_owned(),
1509            added_count: 0,
1510            sections_added: Vec::new(),
1511        });
1512    }
1513
1514    let comment = "# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1515         # transactional = false\n\
1516         # transaction_scope = []          # glob patterns; empty = all extracted paths\n\
1517         # auto_rollback = false           # rollback when exit code >= 2\n\
1518         # auto_rollback_exit_codes = []   # explicit exit codes; overrides >= 2 heuristic\n\
1519         # snapshot_required = false       # abort if snapshot fails (default: warn and proceed)\n";
1520    append_comment_to_table_suffix(shell, comment);
1521
1522    Ok(MigrationResult {
1523        output: doc.to_string(),
1524        added_count: 1,
1525        sections_added: vec!["tools.shell.transactional".to_owned()],
1526    })
1527}
1528
1529/// Migration step: add `budget_hint_enabled` as a commented-out entry under `[agent]` if absent.
1530///
1531/// # Errors
1532///
1533/// Returns an error if the config cannot be parsed or the `[agent]` section is malformed.
1534pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1535    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1536
1537    let agent_exists = doc.contains_key("agent");
1538    if !agent_exists {
1539        return Ok(MigrationResult {
1540            output: toml_src.to_owned(),
1541            added_count: 0,
1542            sections_added: Vec::new(),
1543        });
1544    }
1545
1546    let agent = doc
1547        .get_mut("agent")
1548        .and_then(toml_edit::Item::as_table_mut)
1549        .ok_or(MigrateError::InvalidStructure("[agent] is not a table"))?;
1550
1551    if agent.contains_key("budget_hint_enabled") {
1552        return Ok(MigrationResult {
1553            output: toml_src.to_owned(),
1554            added_count: 0,
1555            sections_added: Vec::new(),
1556        });
1557    }
1558
1559    let comment = "# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1560         # budget_hint_enabled = true\n";
1561    append_comment_to_table_suffix(agent, comment);
1562
1563    Ok(MigrationResult {
1564        output: doc.to_string(),
1565        added_count: 1,
1566        sections_added: vec!["agent.budget_hint_enabled".to_owned()],
1567    })
1568}
1569
1570/// Add a commented-out `[memory.forgetting]` section if absent (#2397).
1571///
1572/// All forgetting fields have `#[serde(default)]` so existing configs parse without changes.
1573/// This step surfaces the new section for users upgrading from older configs.
1574///
1575/// # Errors
1576///
1577/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1578pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1579    use toml_edit::{Item, Table};
1580
1581    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1582
1583    // If [memory] does not exist, create it so we can check for [memory.forgetting].
1584    if !doc.contains_key("memory") {
1585        doc.insert("memory", Item::Table(Table::new()));
1586    }
1587
1588    let memory = doc
1589        .get_mut("memory")
1590        .and_then(Item::as_table_mut)
1591        .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1592
1593    if memory.contains_key("forgetting") {
1594        return Ok(MigrationResult {
1595            output: toml_src.to_owned(),
1596            added_count: 0,
1597            sections_added: Vec::new(),
1598        });
1599    }
1600
1601    let comment = "# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1602         # [memory.forgetting]\n\
1603         # enabled = false\n\
1604         # decay_rate = 0.1                   # per-sweep importance decay\n\
1605         # forgetting_floor = 0.05            # prune below this score\n\
1606         # sweep_interval_secs = 7200         # run every 2 hours\n\
1607         # sweep_batch_size = 500\n\
1608         # protect_recent_hours = 24\n\
1609         # protect_min_access_count = 3\n";
1610    append_comment_to_table_suffix(memory, comment);
1611
1612    Ok(MigrationResult {
1613        output: doc.to_string(),
1614        added_count: 1,
1615        sections_added: vec!["memory.forgetting".to_owned()],
1616    })
1617}
1618
1619/// Add a commented-out `[memory.compression.predictor]` block if absent (#2460).
1620///
1621/// All predictor fields have `#[serde(default)]` so existing configs parse without changes.
1622///
1623/// # Errors
1624///
1625/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1626pub fn migrate_compression_predictor_config(
1627    toml_src: &str,
1628) -> Result<MigrationResult, MigrateError> {
1629    use toml_edit::{Item, Table};
1630
1631    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1632
1633    // Ensure [memory] and [memory.compression] exist.
1634    if !doc.contains_key("memory") {
1635        doc.insert("memory", Item::Table(Table::new()));
1636    }
1637    let memory = doc
1638        .get_mut("memory")
1639        .and_then(Item::as_table_mut)
1640        .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1641
1642    if !memory.contains_key("compression") {
1643        memory.insert("compression", Item::Table(Table::new()));
1644    }
1645    let compression = memory
1646        .get_mut("compression")
1647        .and_then(Item::as_table_mut)
1648        .ok_or(MigrateError::InvalidStructure(
1649            "[memory.compression] is not a table",
1650        ))?;
1651
1652    if compression.contains_key("predictor") {
1653        return Ok(MigrationResult {
1654            output: toml_src.to_owned(),
1655            added_count: 0,
1656            sections_added: Vec::new(),
1657        });
1658    }
1659
1660    let comment = "# Performance-floor compression ratio predictor (#2460). Disabled by default.\n\
1661         # [memory.compression.predictor]\n\
1662         # enabled = false\n\
1663         # min_samples = 10                                             # cold-start threshold\n\
1664         # candidate_ratios = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]\n\
1665         # retrain_interval = 5\n\
1666         # max_training_samples = 200\n";
1667    append_comment_to_table_suffix(compression, comment);
1668
1669    Ok(MigrationResult {
1670        output: doc.to_string(),
1671        added_count: 1,
1672        sections_added: vec!["memory.compression.predictor".to_owned()],
1673    })
1674}
1675
1676/// Add a commented-out `[memory.microcompact]` block if absent (#2699).
1677///
1678/// # Errors
1679///
1680/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1681pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1682    use toml_edit::{Item, Table};
1683
1684    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1685
1686    if !doc.contains_key("memory") {
1687        doc.insert("memory", Item::Table(Table::new()));
1688    }
1689    let memory = doc
1690        .get_mut("memory")
1691        .and_then(Item::as_table_mut)
1692        .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1693
1694    if memory.contains_key("microcompact") {
1695        return Ok(MigrationResult {
1696            output: toml_src.to_owned(),
1697            added_count: 0,
1698            sections_added: Vec::new(),
1699        });
1700    }
1701
1702    let comment = "# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1703         # [memory.microcompact]\n\
1704         # enabled = false\n\
1705         # gap_threshold_minutes = 60   # idle gap before clearing stale outputs\n\
1706         # keep_recent = 3              # always keep this many recent outputs intact\n";
1707    append_comment_to_table_suffix(memory, comment);
1708
1709    Ok(MigrationResult {
1710        output: doc.to_string(),
1711        added_count: 1,
1712        sections_added: vec!["memory.microcompact".to_owned()],
1713    })
1714}
1715
1716/// Add a commented-out `[memory.autodream]` block if absent (#2697).
1717///
1718/// # Errors
1719///
1720/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1721pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1722    use toml_edit::{Item, Table};
1723
1724    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1725
1726    if !doc.contains_key("memory") {
1727        doc.insert("memory", Item::Table(Table::new()));
1728    }
1729    let memory = doc
1730        .get_mut("memory")
1731        .and_then(Item::as_table_mut)
1732        .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1733
1734    if memory.contains_key("autodream") {
1735        return Ok(MigrationResult {
1736            output: toml_src.to_owned(),
1737            added_count: 0,
1738            sections_added: Vec::new(),
1739        });
1740    }
1741
1742    let comment = "# autoDream background memory consolidation (#2697). Disabled by default.\n\
1743         # [memory.autodream]\n\
1744         # enabled = false\n\
1745         # min_sessions = 5             # sessions since last consolidation\n\
1746         # min_hours = 8                # hours since last consolidation\n\
1747         # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1748         # max_iterations = 5\n";
1749    append_comment_to_table_suffix(memory, comment);
1750
1751    Ok(MigrationResult {
1752        output: doc.to_string(),
1753        added_count: 1,
1754        sections_added: vec!["memory.autodream".to_owned()],
1755    })
1756}
1757
1758/// Add a commented-out `[magic_docs]` block if absent (#2702).
1759///
1760/// # Errors
1761///
1762/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1763pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1764    use toml_edit::{Item, Table};
1765
1766    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1767
1768    if doc.contains_key("magic_docs") {
1769        return Ok(MigrationResult {
1770            output: toml_src.to_owned(),
1771            added_count: 0,
1772            sections_added: Vec::new(),
1773        });
1774    }
1775
1776    doc.insert("magic_docs", Item::Table(Table::new()));
1777    let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1778         # [magic_docs]\n\
1779         # enabled = false\n\
1780         # min_turns_between_updates = 10\n\
1781         # update_provider = \"\"         # provider name from [[llm.providers]]; empty = primary\n\
1782         # max_iterations = 3\n";
1783    // Remove the just-inserted empty table and replace with a comment.
1784    doc.remove("magic_docs");
1785    // Append as a trailing comment on the document root.
1786    let raw = doc.to_string();
1787    let output = format!("{raw}\n{comment}");
1788
1789    Ok(MigrationResult {
1790        output,
1791        added_count: 1,
1792        sections_added: vec!["magic_docs".to_owned()],
1793    })
1794}
1795
1796// Helper to create a formatted value (used in tests).
1797#[cfg(test)]
1798fn make_formatted_str(s: &str) -> Value {
1799    use toml_edit::Formatted;
1800    Value::String(Formatted::new(s.to_owned()))
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805    use super::*;
1806
1807    #[test]
1808    fn empty_config_gets_sections_as_comments() {
1809        let migrator = ConfigMigrator::new();
1810        let result = migrator.migrate("").expect("migrate empty");
1811        // Should have added sections since reference is non-empty.
1812        assert!(result.added_count > 0 || !result.sections_added.is_empty());
1813        // Output should mention at least agent section.
1814        assert!(
1815            result.output.contains("[agent]") || result.output.contains("# [agent]"),
1816            "expected agent section in output, got:\n{}",
1817            result.output
1818        );
1819    }
1820
1821    #[test]
1822    fn existing_values_not_overwritten() {
1823        let user = r#"
1824[agent]
1825name = "MyAgent"
1826max_tool_iterations = 5
1827"#;
1828        let migrator = ConfigMigrator::new();
1829        let result = migrator.migrate(user).expect("migrate");
1830        // Original name preserved.
1831        assert!(
1832            result.output.contains("name = \"MyAgent\""),
1833            "user value should be preserved"
1834        );
1835        assert!(
1836            result.output.contains("max_tool_iterations = 5"),
1837            "user value should be preserved"
1838        );
1839        // Should not appear as commented default.
1840        assert!(
1841            !result.output.contains("# max_tool_iterations = 10"),
1842            "already-set key should not appear as comment"
1843        );
1844    }
1845
1846    #[test]
1847    fn missing_nested_key_added_as_comment() {
1848        // User has [memory] but is missing some keys.
1849        let user = r#"
1850[memory]
1851sqlite_path = ".zeph/data/zeph.db"
1852"#;
1853        let migrator = ConfigMigrator::new();
1854        let result = migrator.migrate(user).expect("migrate");
1855        // history_limit should be added as comment since it's in reference.
1856        assert!(
1857            result.output.contains("# history_limit"),
1858            "missing key should be added as comment, got:\n{}",
1859            result.output
1860        );
1861    }
1862
1863    #[test]
1864    fn unknown_user_keys_preserved() {
1865        let user = r#"
1866[agent]
1867name = "Test"
1868my_custom_key = "preserved"
1869"#;
1870        let migrator = ConfigMigrator::new();
1871        let result = migrator.migrate(user).expect("migrate");
1872        assert!(
1873            result.output.contains("my_custom_key = \"preserved\""),
1874            "custom user keys must not be removed"
1875        );
1876    }
1877
1878    #[test]
1879    fn idempotent() {
1880        let migrator = ConfigMigrator::new();
1881        let first = migrator
1882            .migrate("[agent]\nname = \"Zeph\"\n")
1883            .expect("first migrate");
1884        let second = migrator.migrate(&first.output).expect("second migrate");
1885        assert_eq!(
1886            first.output, second.output,
1887            "idempotent: full output must be identical on second run"
1888        );
1889    }
1890
1891    #[test]
1892    fn malformed_input_returns_error() {
1893        let migrator = ConfigMigrator::new();
1894        let err = migrator
1895            .migrate("[[invalid toml [[[")
1896            .expect_err("should error");
1897        assert!(
1898            matches!(err, MigrateError::Parse(_)),
1899            "expected Parse error"
1900        );
1901    }
1902
1903    #[test]
1904    fn array_of_tables_preserved() {
1905        let user = r#"
1906[mcp]
1907allowed_commands = ["npx"]
1908
1909[[mcp.servers]]
1910id = "my-server"
1911command = "npx"
1912args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1913"#;
1914        let migrator = ConfigMigrator::new();
1915        let result = migrator.migrate(user).expect("migrate");
1916        // User's [[mcp.servers]] entry must survive.
1917        assert!(
1918            result.output.contains("[[mcp.servers]]"),
1919            "array-of-tables entries must be preserved"
1920        );
1921        assert!(result.output.contains("id = \"my-server\""));
1922    }
1923
1924    #[test]
1925    fn canonical_ordering_applied() {
1926        // Put memory before agent intentionally.
1927        let user = r#"
1928[memory]
1929sqlite_path = ".zeph/data/zeph.db"
1930
1931[agent]
1932name = "Test"
1933"#;
1934        let migrator = ConfigMigrator::new();
1935        let result = migrator.migrate(user).expect("migrate");
1936        // agent should appear before memory in canonical order.
1937        let agent_pos = result.output.find("[agent]");
1938        let memory_pos = result.output.find("[memory]");
1939        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1940            assert!(a < m, "agent section should precede memory section");
1941        }
1942    }
1943
1944    #[test]
1945    fn value_to_toml_string_formats_correctly() {
1946        use toml_edit::Formatted;
1947
1948        let s = make_formatted_str("hello");
1949        assert_eq!(value_to_toml_string(&s), "\"hello\"");
1950
1951        let i = Value::Integer(Formatted::new(42_i64));
1952        assert_eq!(value_to_toml_string(&i), "42");
1953
1954        let b = Value::Boolean(Formatted::new(true));
1955        assert_eq!(value_to_toml_string(&b), "true");
1956
1957        let f = Value::Float(Formatted::new(1.0_f64));
1958        assert_eq!(value_to_toml_string(&f), "1.0");
1959
1960        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1961        assert_eq!(value_to_toml_string(&f2), "3.14");
1962
1963        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1964        let arr_val = Value::Array(arr);
1965        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1966
1967        let empty_arr = Value::Array(Array::new());
1968        assert_eq!(value_to_toml_string(&empty_arr), "[]");
1969    }
1970
1971    #[test]
1972    fn idempotent_full_output_unchanged() {
1973        // Stronger idempotency: the entire output string must not change on a second pass.
1974        let migrator = ConfigMigrator::new();
1975        let first = migrator
1976            .migrate("[agent]\nname = \"Zeph\"\n")
1977            .expect("first migrate");
1978        let second = migrator.migrate(&first.output).expect("second migrate");
1979        assert_eq!(
1980            first.output, second.output,
1981            "full output string must be identical after second migration pass"
1982        );
1983    }
1984
1985    #[test]
1986    fn full_config_produces_zero_additions() {
1987        // Migrating the reference config itself should add nothing new.
1988        let reference = include_str!("../config/default.toml");
1989        let migrator = ConfigMigrator::new();
1990        let result = migrator.migrate(reference).expect("migrate reference");
1991        assert_eq!(
1992            result.added_count, 0,
1993            "migrating the canonical reference should add nothing (added_count = {})",
1994            result.added_count
1995        );
1996        assert!(
1997            result.sections_added.is_empty(),
1998            "migrating the canonical reference should report no sections_added: {:?}",
1999            result.sections_added
2000        );
2001    }
2002
2003    #[test]
2004    fn empty_config_added_count_is_positive() {
2005        // Stricter variant of empty_config_gets_sections_as_comments.
2006        let migrator = ConfigMigrator::new();
2007        let result = migrator.migrate("").expect("migrate empty");
2008        assert!(
2009            result.added_count > 0,
2010            "empty config must report added_count > 0"
2011        );
2012    }
2013
2014    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
2015    // for a pre-guardrail config that has [security] but no [security.guardrail].
2016    #[test]
2017    fn security_without_guardrail_gets_guardrail_commented() {
2018        let user = "[security]\nredact_secrets = true\n";
2019        let migrator = ConfigMigrator::new();
2020        let result = migrator.migrate(user).expect("migrate");
2021        // The generic diff mechanism must add guardrail keys as commented defaults.
2022        assert!(
2023            result.output.contains("guardrail"),
2024            "migration must add guardrail keys for configs without [security.guardrail]: \
2025             got:\n{}",
2026            result.output
2027        );
2028    }
2029
2030    #[test]
2031    fn migrate_reference_contains_tools_policy() {
2032        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
2033        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
2034        // `zeph --migrate-config` will surface the section to users as a discoverable commented
2035        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
2036        let reference = include_str!("../config/default.toml");
2037        assert!(
2038            reference.contains("[tools.policy]"),
2039            "default.toml must contain [tools.policy] section so migrate-config can surface it"
2040        );
2041        assert!(
2042            reference.contains("enabled = false"),
2043            "tools.policy section must include enabled = false default"
2044        );
2045    }
2046
2047    #[test]
2048    fn migrate_reference_contains_probe_section() {
2049        // default.toml must contain the probe section comment block so users can discover it
2050        // when reading the file directly or after running --migrate-config.
2051        let reference = include_str!("../config/default.toml");
2052        assert!(
2053            reference.contains("[memory.compression.probe]"),
2054            "default.toml must contain [memory.compression.probe] section comment"
2055        );
2056        assert!(
2057            reference.contains("hard_fail_threshold"),
2058            "probe section must include hard_fail_threshold default"
2059        );
2060    }
2061
2062    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
2063
2064    #[test]
2065    fn migrate_llm_no_llm_section_is_noop() {
2066        let src = "[agent]\nname = \"Zeph\"\n";
2067        let result = migrate_llm_to_providers(src).expect("migrate");
2068        assert_eq!(result.added_count, 0);
2069        assert_eq!(result.output, src);
2070    }
2071
2072    #[test]
2073    fn migrate_llm_already_new_format_is_noop() {
2074        let src = r#"
2075[llm]
2076[[llm.providers]]
2077type = "ollama"
2078model = "qwen3:8b"
2079"#;
2080        let result = migrate_llm_to_providers(src).expect("migrate");
2081        assert_eq!(result.added_count, 0);
2082    }
2083
2084    #[test]
2085    fn migrate_llm_ollama_produces_providers_block() {
2086        let src = r#"
2087[llm]
2088provider = "ollama"
2089model = "qwen3:8b"
2090base_url = "http://localhost:11434"
2091embedding_model = "nomic-embed-text"
2092"#;
2093        let result = migrate_llm_to_providers(src).expect("migrate");
2094        assert!(
2095            result.output.contains("[[llm.providers]]"),
2096            "should contain [[llm.providers]]:\n{}",
2097            result.output
2098        );
2099        assert!(
2100            result.output.contains("type = \"ollama\""),
2101            "{}",
2102            result.output
2103        );
2104        assert!(
2105            result.output.contains("model = \"qwen3:8b\""),
2106            "{}",
2107            result.output
2108        );
2109    }
2110
2111    #[test]
2112    fn migrate_llm_claude_produces_providers_block() {
2113        let src = r#"
2114[llm]
2115provider = "claude"
2116
2117[llm.cloud]
2118model = "claude-sonnet-4-6"
2119max_tokens = 8192
2120server_compaction = true
2121"#;
2122        let result = migrate_llm_to_providers(src).expect("migrate");
2123        assert!(
2124            result.output.contains("[[llm.providers]]"),
2125            "{}",
2126            result.output
2127        );
2128        assert!(
2129            result.output.contains("type = \"claude\""),
2130            "{}",
2131            result.output
2132        );
2133        assert!(
2134            result.output.contains("model = \"claude-sonnet-4-6\""),
2135            "{}",
2136            result.output
2137        );
2138        assert!(
2139            result.output.contains("server_compaction = true"),
2140            "{}",
2141            result.output
2142        );
2143    }
2144
2145    #[test]
2146    fn migrate_llm_openai_copies_fields() {
2147        let src = r#"
2148[llm]
2149provider = "openai"
2150
2151[llm.openai]
2152base_url = "https://api.openai.com/v1"
2153model = "gpt-4o"
2154max_tokens = 4096
2155"#;
2156        let result = migrate_llm_to_providers(src).expect("migrate");
2157        assert!(
2158            result.output.contains("type = \"openai\""),
2159            "{}",
2160            result.output
2161        );
2162        assert!(
2163            result
2164                .output
2165                .contains("base_url = \"https://api.openai.com/v1\""),
2166            "{}",
2167            result.output
2168        );
2169    }
2170
2171    #[test]
2172    fn migrate_llm_gemini_copies_fields() {
2173        let src = r#"
2174[llm]
2175provider = "gemini"
2176
2177[llm.gemini]
2178model = "gemini-2.0-flash"
2179max_tokens = 8192
2180base_url = "https://generativelanguage.googleapis.com"
2181"#;
2182        let result = migrate_llm_to_providers(src).expect("migrate");
2183        assert!(
2184            result.output.contains("type = \"gemini\""),
2185            "{}",
2186            result.output
2187        );
2188        assert!(
2189            result.output.contains("model = \"gemini-2.0-flash\""),
2190            "{}",
2191            result.output
2192        );
2193    }
2194
2195    #[test]
2196    fn migrate_llm_compatible_copies_multiple_entries() {
2197        let src = r#"
2198[llm]
2199provider = "compatible"
2200
2201[[llm.compatible]]
2202name = "proxy-a"
2203base_url = "http://proxy-a:8080/v1"
2204model = "llama3"
2205max_tokens = 4096
2206
2207[[llm.compatible]]
2208name = "proxy-b"
2209base_url = "http://proxy-b:8080/v1"
2210model = "mistral"
2211max_tokens = 2048
2212"#;
2213        let result = migrate_llm_to_providers(src).expect("migrate");
2214        // Both compatible entries should be emitted.
2215        let count = result.output.matches("[[llm.providers]]").count();
2216        assert_eq!(
2217            count, 2,
2218            "expected 2 [[llm.providers]] blocks:\n{}",
2219            result.output
2220        );
2221        assert!(
2222            result.output.contains("name = \"proxy-a\""),
2223            "{}",
2224            result.output
2225        );
2226        assert!(
2227            result.output.contains("name = \"proxy-b\""),
2228            "{}",
2229            result.output
2230        );
2231    }
2232
2233    #[test]
2234    fn migrate_llm_mixed_format_errors() {
2235        // Legacy + new format together should produce an error.
2236        let src = r#"
2237[llm]
2238provider = "ollama"
2239
2240[[llm.providers]]
2241type = "ollama"
2242"#;
2243        assert!(
2244            migrate_llm_to_providers(src).is_err(),
2245            "mixed format must return error"
2246        );
2247    }
2248
2249    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
2250
2251    #[test]
2252    fn stt_migration_no_stt_section_returns_unchanged() {
2253        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2254        let result = migrate_stt_to_provider(src).unwrap();
2255        assert_eq!(result.added_count, 0);
2256        assert_eq!(result.output, src);
2257    }
2258
2259    #[test]
2260    fn stt_migration_no_model_or_base_url_returns_unchanged() {
2261        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2262        let result = migrate_stt_to_provider(src).unwrap();
2263        assert_eq!(result.added_count, 0);
2264    }
2265
2266    #[test]
2267    fn stt_migration_moves_model_to_provider_entry() {
2268        let src = r#"
2269[llm]
2270
2271[[llm.providers]]
2272type = "openai"
2273name = "quality"
2274model = "gpt-5.4"
2275
2276[llm.stt]
2277provider = "quality"
2278model = "gpt-4o-mini-transcribe"
2279language = "en"
2280"#;
2281        let result = migrate_stt_to_provider(src).unwrap();
2282        assert_eq!(result.added_count, 1);
2283        // stt_model should appear in providers entry.
2284        assert!(
2285            result.output.contains("stt_model"),
2286            "stt_model must be in output"
2287        );
2288        // model should be removed from [llm.stt].
2289        // The output should parse cleanly.
2290        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2291        let stt = doc
2292            .get("llm")
2293            .and_then(toml_edit::Item::as_table)
2294            .and_then(|l| l.get("stt"))
2295            .and_then(toml_edit::Item::as_table)
2296            .unwrap();
2297        assert!(
2298            stt.get("model").is_none(),
2299            "model must be removed from [llm.stt]"
2300        );
2301        assert_eq!(
2302            stt.get("provider").and_then(toml_edit::Item::as_str),
2303            Some("quality")
2304        );
2305    }
2306
2307    #[test]
2308    fn stt_migration_creates_new_provider_when_no_match() {
2309        let src = r#"
2310[llm]
2311
2312[[llm.providers]]
2313type = "ollama"
2314name = "local"
2315model = "qwen3:8b"
2316
2317[llm.stt]
2318provider = "whisper"
2319model = "whisper-1"
2320base_url = "https://api.openai.com/v1"
2321language = "en"
2322"#;
2323        let result = migrate_stt_to_provider(src).unwrap();
2324        assert!(
2325            result.output.contains("openai-stt"),
2326            "new entry name must be openai-stt"
2327        );
2328        assert!(
2329            result.output.contains("stt_model"),
2330            "stt_model must be in output"
2331        );
2332    }
2333
2334    #[test]
2335    fn stt_migration_candle_whisper_creates_candle_entry() {
2336        let src = r#"
2337[llm]
2338
2339[llm.stt]
2340provider = "candle-whisper"
2341model = "openai/whisper-tiny"
2342language = "auto"
2343"#;
2344        let result = migrate_stt_to_provider(src).unwrap();
2345        assert!(
2346            result.output.contains("local-whisper"),
2347            "candle entry name must be local-whisper"
2348        );
2349        assert!(result.output.contains("candle"), "type must be candle");
2350    }
2351
2352    #[test]
2353    fn stt_migration_w2_assigns_explicit_name() {
2354        // Provider has no explicit name (type = "openai") — migration must assign one.
2355        let src = r#"
2356[llm]
2357
2358[[llm.providers]]
2359type = "openai"
2360model = "gpt-5.4"
2361
2362[llm.stt]
2363provider = "openai"
2364model = "whisper-1"
2365language = "auto"
2366"#;
2367        let result = migrate_stt_to_provider(src).unwrap();
2368        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2369        let providers = doc
2370            .get("llm")
2371            .and_then(toml_edit::Item::as_table)
2372            .and_then(|l| l.get("providers"))
2373            .and_then(toml_edit::Item::as_array_of_tables)
2374            .unwrap();
2375        let entry = providers
2376            .iter()
2377            .find(|t| t.get("stt_model").is_some())
2378            .unwrap();
2379        // Must have an explicit `name` field (W2).
2380        assert!(
2381            entry.get("name").is_some(),
2382            "migrated entry must have explicit name"
2383        );
2384    }
2385
2386    #[test]
2387    fn stt_migration_removes_base_url_from_stt_table() {
2388        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
2389        let src = r#"
2390[llm]
2391
2392[[llm.providers]]
2393type = "openai"
2394name = "quality"
2395model = "gpt-5.4"
2396
2397[llm.stt]
2398provider = "quality"
2399model = "whisper-1"
2400base_url = "https://api.openai.com/v1"
2401language = "en"
2402"#;
2403        let result = migrate_stt_to_provider(src).unwrap();
2404        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2405        let stt = doc
2406            .get("llm")
2407            .and_then(toml_edit::Item::as_table)
2408            .and_then(|l| l.get("stt"))
2409            .and_then(toml_edit::Item::as_table)
2410            .unwrap();
2411        assert!(
2412            stt.get("model").is_none(),
2413            "model must be removed from [llm.stt]"
2414        );
2415        assert!(
2416            stt.get("base_url").is_none(),
2417            "base_url must be removed from [llm.stt]"
2418        );
2419    }
2420
2421    #[test]
2422    fn migrate_planner_model_to_provider_with_field() {
2423        let input = r#"
2424[orchestration]
2425enabled = true
2426planner_model = "gpt-4o"
2427max_tasks = 20
2428"#;
2429        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2430        assert_eq!(result.added_count, 1, "added_count must be 1");
2431        assert!(
2432            !result.output.contains("planner_model = "),
2433            "planner_model key must be removed from output"
2434        );
2435        assert!(
2436            result.output.contains("# planner_provider"),
2437            "commented-out planner_provider entry must be present"
2438        );
2439        assert!(
2440            result.output.contains("gpt-4o"),
2441            "old value must appear in the comment"
2442        );
2443        assert!(
2444            result.output.contains("MIGRATED"),
2445            "comment must include MIGRATED marker"
2446        );
2447    }
2448
2449    #[test]
2450    fn migrate_planner_model_to_provider_no_op() {
2451        let input = r"
2452[orchestration]
2453enabled = true
2454max_tasks = 20
2455";
2456        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2457        assert_eq!(
2458            result.added_count, 0,
2459            "added_count must be 0 when field is absent"
2460        );
2461        assert_eq!(
2462            result.output, input,
2463            "output must equal input when nothing to migrate"
2464        );
2465    }
2466
2467    #[test]
2468    fn migrate_error_invalid_structure_formats_correctly() {
2469        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
2470        // produces a human-readable message. The error path is triggered when the [llm] item
2471        // is present but cannot be obtained as a mutable table (defensive guard replacing the
2472        // previous .expect() calls that would have panicked).
2473        let err = MigrateError::InvalidStructure("test sentinel");
2474        assert!(
2475            matches!(err, MigrateError::InvalidStructure(_)),
2476            "variant must match"
2477        );
2478        let msg = err.to_string();
2479        assert!(
2480            msg.contains("invalid TOML structure"),
2481            "error message must mention 'invalid TOML structure', got: {msg}"
2482        );
2483        assert!(
2484            msg.contains("test sentinel"),
2485            "message must include reason: {msg}"
2486        );
2487    }
2488
2489    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
2490
2491    #[test]
2492    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2493        let src = r#"
2494[mcp]
2495allowed_commands = ["npx"]
2496
2497[[mcp.servers]]
2498id = "srv-a"
2499command = "npx"
2500args = ["-y", "some-mcp"]
2501
2502[[mcp.servers]]
2503id = "srv-b"
2504command = "npx"
2505args = ["-y", "other-mcp"]
2506"#;
2507        let result = migrate_mcp_trust_levels(src).expect("migrate");
2508        assert_eq!(
2509            result.added_count, 2,
2510            "both entries must get trust_level added"
2511        );
2512        assert!(
2513            result
2514                .sections_added
2515                .contains(&"mcp.servers.trust_level".to_owned()),
2516            "sections_added must report mcp.servers.trust_level"
2517        );
2518        // Both entries must now contain trust_level = "trusted"
2519        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2520        assert_eq!(
2521            occurrences, 2,
2522            "each entry must have trust_level = \"trusted\""
2523        );
2524    }
2525
2526    #[test]
2527    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2528        let src = r#"
2529[[mcp.servers]]
2530id = "srv-a"
2531command = "npx"
2532trust_level = "sandboxed"
2533tool_allowlist = ["read_file"]
2534
2535[[mcp.servers]]
2536id = "srv-b"
2537command = "npx"
2538"#;
2539        let result = migrate_mcp_trust_levels(src).expect("migrate");
2540        // Only srv-b has no trust_level, so only 1 entry should be updated
2541        assert_eq!(
2542            result.added_count, 1,
2543            "only entry without trust_level gets updated"
2544        );
2545        // srv-a's sandboxed value must not be overwritten
2546        assert!(
2547            result.output.contains("trust_level = \"sandboxed\""),
2548            "existing trust_level must not be overwritten"
2549        );
2550        // srv-b gets trusted
2551        assert!(
2552            result.output.contains("trust_level = \"trusted\""),
2553            "entry without trust_level must get trusted"
2554        );
2555    }
2556
2557    #[test]
2558    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2559        let src = "[agent]\nname = \"Zeph\"\n";
2560        let result = migrate_mcp_trust_levels(src).expect("migrate");
2561        assert_eq!(result.added_count, 0);
2562        assert!(result.sections_added.is_empty());
2563        assert_eq!(result.output, src);
2564    }
2565
2566    #[test]
2567    fn migrate_mcp_trust_levels_no_servers_is_noop() {
2568        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2569        let result = migrate_mcp_trust_levels(src).expect("migrate");
2570        assert_eq!(result.added_count, 0);
2571        assert!(result.sections_added.is_empty());
2572        assert_eq!(result.output, src);
2573    }
2574
2575    #[test]
2576    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2577        let src = r#"
2578[[mcp.servers]]
2579id = "srv-a"
2580trust_level = "trusted"
2581
2582[[mcp.servers]]
2583id = "srv-b"
2584trust_level = "untrusted"
2585"#;
2586        let result = migrate_mcp_trust_levels(src).expect("migrate");
2587        assert_eq!(result.added_count, 0);
2588        assert!(result.sections_added.is_empty());
2589    }
2590
2591    #[test]
2592    fn migrate_database_url_adds_comment_when_absent() {
2593        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2594        let result = migrate_database_url(src).expect("migrate");
2595        assert_eq!(result.added_count, 1);
2596        assert!(
2597            result
2598                .sections_added
2599                .contains(&"memory.database_url".to_owned())
2600        );
2601        assert!(result.output.contains("# database_url = \"\""));
2602    }
2603
2604    #[test]
2605    fn migrate_database_url_is_noop_when_present() {
2606        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2607        let result = migrate_database_url(src).expect("migrate");
2608        assert_eq!(result.added_count, 0);
2609        assert!(result.sections_added.is_empty());
2610        assert_eq!(result.output, src);
2611    }
2612
2613    #[test]
2614    fn migrate_database_url_creates_memory_section_when_absent() {
2615        let src = "[agent]\nname = \"Zeph\"\n";
2616        let result = migrate_database_url(src).expect("migrate");
2617        assert_eq!(result.added_count, 1);
2618        assert!(result.output.contains("# database_url = \"\""));
2619    }
2620
2621    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
2622
2623    #[test]
2624    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2625        let src = "[agent]\nname = \"Zeph\"\n";
2626        let result = migrate_agent_budget_hint(src).expect("migrate");
2627        assert_eq!(result.added_count, 1);
2628        assert!(result.output.contains("budget_hint_enabled"));
2629        assert!(
2630            result
2631                .sections_added
2632                .contains(&"agent.budget_hint_enabled".to_owned())
2633        );
2634    }
2635
2636    #[test]
2637    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2638        let src = "[llm]\nmodel = \"gpt-4o\"\n";
2639        let result = migrate_agent_budget_hint(src).expect("migrate");
2640        assert_eq!(result.added_count, 0);
2641        assert_eq!(result.output, src);
2642    }
2643
2644    #[test]
2645    fn migrate_agent_budget_hint_already_present_is_noop() {
2646        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2647        let result = migrate_agent_budget_hint(src).expect("migrate");
2648        assert_eq!(result.added_count, 0);
2649        assert_eq!(result.output, src);
2650    }
2651}