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