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 = false\n\
1824         # service_name = \"zeph-agent\"\n\
1825         # sample_rate = 1.0\n\
1826         # otel_filter = \"info\"     # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1827
1828    let raw = doc.to_string();
1829    let output = format!("{raw}{comment}");
1830
1831    Ok(MigrationResult {
1832        output,
1833        added_count: 1,
1834        sections_added: vec!["telemetry".to_owned()],
1835    })
1836}
1837
1838/// Add a commented-out `[agent.supervisor]` block if the sub-table is absent (#2883).
1839///
1840/// Appended as comments under `[agent]` so users can discover and tune supervisor limits
1841/// without manual hunting. Safe to call on configs that already have the section.
1842///
1843/// # Errors
1844///
1845/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1846pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1847    // Idempotency: skip if already present (either as real section or commented-out block).
1848    if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1849        return Ok(MigrationResult {
1850            output: toml_src.to_owned(),
1851            added_count: 0,
1852            sections_added: Vec::new(),
1853        });
1854    }
1855
1856    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1857
1858    // Only inject the comment block when an [agent] section is already present so we don't
1859    // pollute configs that have no [agent] at all.
1860    if !doc.contains_key("agent") {
1861        return Ok(MigrationResult {
1862            output: toml_src.to_owned(),
1863            added_count: 0,
1864            sections_added: Vec::new(),
1865        });
1866    }
1867
1868    let comment = "\n\
1869         # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1870         # [agent.supervisor]\n\
1871         # enrichment_limit = 4\n\
1872         # telemetry_limit = 8\n\
1873         # abort_enrichment_on_turn = false\n";
1874
1875    let raw = doc.to_string();
1876    let output = format!("{raw}{comment}");
1877
1878    Ok(MigrationResult {
1879        output,
1880        added_count: 1,
1881        sections_added: vec!["agent.supervisor".to_owned()],
1882    })
1883}
1884
1885/// Add a commented-out `otel_filter` entry under `[telemetry]` if the key is absent (#2997).
1886///
1887/// When `[telemetry]` exists but lacks `otel_filter`, appends the key as a comment so users
1888/// can discover it without manual hunting. Safe to call when the key is already present
1889/// (real or commented-out).
1890///
1891/// # Errors
1892///
1893/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1894pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1895    // Idempotency: skip if key already present (real or commented-out).
1896    if toml_src.contains("otel_filter") {
1897        return Ok(MigrationResult {
1898            output: toml_src.to_owned(),
1899            added_count: 0,
1900            sections_added: Vec::new(),
1901        });
1902    }
1903
1904    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1905
1906    // Only inject when [telemetry] section exists; otherwise the field will be added
1907    // by migrate_telemetry_config which already includes it in the commented block.
1908    if !doc.contains_key("telemetry") {
1909        return Ok(MigrationResult {
1910            output: toml_src.to_owned(),
1911            added_count: 0,
1912            sections_added: Vec::new(),
1913        });
1914    }
1915
1916    let telemetry = doc
1917        .get_mut("telemetry")
1918        .and_then(toml_edit::Item::as_table_mut)
1919        .ok_or(MigrateError::InvalidStructure("[telemetry] is not a table"))?;
1920
1921    // Insert within the [telemetry] section via suffix decor so the comment appears
1922    // adjacent to its section even when other sections follow.
1923    let comment = "# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
1924        (tonic=warn etc.) are always appended (#2997).\n\
1925        # otel_filter = \"info\"\n";
1926    append_comment_to_table_suffix(telemetry, comment);
1927
1928    Ok(MigrationResult {
1929        output: doc.to_string(),
1930        added_count: 1,
1931        sections_added: vec!["telemetry.otel_filter".to_owned()],
1932    })
1933}
1934
1935// Helper to create a formatted value (used in tests).
1936#[cfg(test)]
1937fn make_formatted_str(s: &str) -> Value {
1938    use toml_edit::Formatted;
1939    Value::String(Formatted::new(s.to_owned()))
1940}
1941
1942#[cfg(test)]
1943mod tests {
1944    use super::*;
1945
1946    #[test]
1947    fn empty_config_gets_sections_as_comments() {
1948        let migrator = ConfigMigrator::new();
1949        let result = migrator.migrate("").expect("migrate empty");
1950        // Should have added sections since reference is non-empty.
1951        assert!(result.added_count > 0 || !result.sections_added.is_empty());
1952        // Output should mention at least agent section.
1953        assert!(
1954            result.output.contains("[agent]") || result.output.contains("# [agent]"),
1955            "expected agent section in output, got:\n{}",
1956            result.output
1957        );
1958    }
1959
1960    #[test]
1961    fn existing_values_not_overwritten() {
1962        let user = r#"
1963[agent]
1964name = "MyAgent"
1965max_tool_iterations = 5
1966"#;
1967        let migrator = ConfigMigrator::new();
1968        let result = migrator.migrate(user).expect("migrate");
1969        // Original name preserved.
1970        assert!(
1971            result.output.contains("name = \"MyAgent\""),
1972            "user value should be preserved"
1973        );
1974        assert!(
1975            result.output.contains("max_tool_iterations = 5"),
1976            "user value should be preserved"
1977        );
1978        // Should not appear as commented default.
1979        assert!(
1980            !result.output.contains("# max_tool_iterations = 10"),
1981            "already-set key should not appear as comment"
1982        );
1983    }
1984
1985    #[test]
1986    fn missing_nested_key_added_as_comment() {
1987        // User has [memory] but is missing some keys.
1988        let user = r#"
1989[memory]
1990sqlite_path = ".zeph/data/zeph.db"
1991"#;
1992        let migrator = ConfigMigrator::new();
1993        let result = migrator.migrate(user).expect("migrate");
1994        // history_limit should be added as comment since it's in reference.
1995        assert!(
1996            result.output.contains("# history_limit"),
1997            "missing key should be added as comment, got:\n{}",
1998            result.output
1999        );
2000    }
2001
2002    #[test]
2003    fn unknown_user_keys_preserved() {
2004        let user = r#"
2005[agent]
2006name = "Test"
2007my_custom_key = "preserved"
2008"#;
2009        let migrator = ConfigMigrator::new();
2010        let result = migrator.migrate(user).expect("migrate");
2011        assert!(
2012            result.output.contains("my_custom_key = \"preserved\""),
2013            "custom user keys must not be removed"
2014        );
2015    }
2016
2017    #[test]
2018    fn idempotent() {
2019        let migrator = ConfigMigrator::new();
2020        let first = migrator
2021            .migrate("[agent]\nname = \"Zeph\"\n")
2022            .expect("first migrate");
2023        let second = migrator.migrate(&first.output).expect("second migrate");
2024        assert_eq!(
2025            first.output, second.output,
2026            "idempotent: full output must be identical on second run"
2027        );
2028    }
2029
2030    #[test]
2031    fn malformed_input_returns_error() {
2032        let migrator = ConfigMigrator::new();
2033        let err = migrator
2034            .migrate("[[invalid toml [[[")
2035            .expect_err("should error");
2036        assert!(
2037            matches!(err, MigrateError::Parse(_)),
2038            "expected Parse error"
2039        );
2040    }
2041
2042    #[test]
2043    fn array_of_tables_preserved() {
2044        let user = r#"
2045[mcp]
2046allowed_commands = ["npx"]
2047
2048[[mcp.servers]]
2049id = "my-server"
2050command = "npx"
2051args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2052"#;
2053        let migrator = ConfigMigrator::new();
2054        let result = migrator.migrate(user).expect("migrate");
2055        // User's [[mcp.servers]] entry must survive.
2056        assert!(
2057            result.output.contains("[[mcp.servers]]"),
2058            "array-of-tables entries must be preserved"
2059        );
2060        assert!(result.output.contains("id = \"my-server\""));
2061    }
2062
2063    #[test]
2064    fn canonical_ordering_applied() {
2065        // Put memory before agent intentionally.
2066        let user = r#"
2067[memory]
2068sqlite_path = ".zeph/data/zeph.db"
2069
2070[agent]
2071name = "Test"
2072"#;
2073        let migrator = ConfigMigrator::new();
2074        let result = migrator.migrate(user).expect("migrate");
2075        // agent should appear before memory in canonical order.
2076        let agent_pos = result.output.find("[agent]");
2077        let memory_pos = result.output.find("[memory]");
2078        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2079            assert!(a < m, "agent section should precede memory section");
2080        }
2081    }
2082
2083    #[test]
2084    fn value_to_toml_string_formats_correctly() {
2085        use toml_edit::Formatted;
2086
2087        let s = make_formatted_str("hello");
2088        assert_eq!(value_to_toml_string(&s), "\"hello\"");
2089
2090        let i = Value::Integer(Formatted::new(42_i64));
2091        assert_eq!(value_to_toml_string(&i), "42");
2092
2093        let b = Value::Boolean(Formatted::new(true));
2094        assert_eq!(value_to_toml_string(&b), "true");
2095
2096        let f = Value::Float(Formatted::new(1.0_f64));
2097        assert_eq!(value_to_toml_string(&f), "1.0");
2098
2099        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2100        assert_eq!(value_to_toml_string(&f2), "3.14");
2101
2102        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2103        let arr_val = Value::Array(arr);
2104        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2105
2106        let empty_arr = Value::Array(Array::new());
2107        assert_eq!(value_to_toml_string(&empty_arr), "[]");
2108    }
2109
2110    #[test]
2111    fn idempotent_full_output_unchanged() {
2112        // Stronger idempotency: the entire output string must not change on a second pass.
2113        let migrator = ConfigMigrator::new();
2114        let first = migrator
2115            .migrate("[agent]\nname = \"Zeph\"\n")
2116            .expect("first migrate");
2117        let second = migrator.migrate(&first.output).expect("second migrate");
2118        assert_eq!(
2119            first.output, second.output,
2120            "full output string must be identical after second migration pass"
2121        );
2122    }
2123
2124    #[test]
2125    fn full_config_produces_zero_additions() {
2126        // Migrating the reference config itself should add nothing new.
2127        let reference = include_str!("../config/default.toml");
2128        let migrator = ConfigMigrator::new();
2129        let result = migrator.migrate(reference).expect("migrate reference");
2130        assert_eq!(
2131            result.added_count, 0,
2132            "migrating the canonical reference should add nothing (added_count = {})",
2133            result.added_count
2134        );
2135        assert!(
2136            result.sections_added.is_empty(),
2137            "migrating the canonical reference should report no sections_added: {:?}",
2138            result.sections_added
2139        );
2140    }
2141
2142    #[test]
2143    fn empty_config_added_count_is_positive() {
2144        // Stricter variant of empty_config_gets_sections_as_comments.
2145        let migrator = ConfigMigrator::new();
2146        let result = migrator.migrate("").expect("migrate empty");
2147        assert!(
2148            result.added_count > 0,
2149            "empty config must report added_count > 0"
2150        );
2151    }
2152
2153    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
2154    // for a pre-guardrail config that has [security] but no [security.guardrail].
2155    #[test]
2156    fn security_without_guardrail_gets_guardrail_commented() {
2157        let user = "[security]\nredact_secrets = true\n";
2158        let migrator = ConfigMigrator::new();
2159        let result = migrator.migrate(user).expect("migrate");
2160        // The generic diff mechanism must add guardrail keys as commented defaults.
2161        assert!(
2162            result.output.contains("guardrail"),
2163            "migration must add guardrail keys for configs without [security.guardrail]: \
2164             got:\n{}",
2165            result.output
2166        );
2167    }
2168
2169    #[test]
2170    fn migrate_reference_contains_tools_policy() {
2171        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
2172        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
2173        // `zeph --migrate-config` will surface the section to users as a discoverable commented
2174        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
2175        let reference = include_str!("../config/default.toml");
2176        assert!(
2177            reference.contains("[tools.policy]"),
2178            "default.toml must contain [tools.policy] section so migrate-config can surface it"
2179        );
2180        assert!(
2181            reference.contains("enabled = false"),
2182            "tools.policy section must include enabled = false default"
2183        );
2184    }
2185
2186    #[test]
2187    fn migrate_reference_contains_probe_section() {
2188        // default.toml must contain the probe section comment block so users can discover it
2189        // when reading the file directly or after running --migrate-config.
2190        let reference = include_str!("../config/default.toml");
2191        assert!(
2192            reference.contains("[memory.compression.probe]"),
2193            "default.toml must contain [memory.compression.probe] section comment"
2194        );
2195        assert!(
2196            reference.contains("hard_fail_threshold"),
2197            "probe section must include hard_fail_threshold default"
2198        );
2199    }
2200
2201    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
2202
2203    #[test]
2204    fn migrate_llm_no_llm_section_is_noop() {
2205        let src = "[agent]\nname = \"Zeph\"\n";
2206        let result = migrate_llm_to_providers(src).expect("migrate");
2207        assert_eq!(result.added_count, 0);
2208        assert_eq!(result.output, src);
2209    }
2210
2211    #[test]
2212    fn migrate_llm_already_new_format_is_noop() {
2213        let src = r#"
2214[llm]
2215[[llm.providers]]
2216type = "ollama"
2217model = "qwen3:8b"
2218"#;
2219        let result = migrate_llm_to_providers(src).expect("migrate");
2220        assert_eq!(result.added_count, 0);
2221    }
2222
2223    #[test]
2224    fn migrate_llm_ollama_produces_providers_block() {
2225        let src = r#"
2226[llm]
2227provider = "ollama"
2228model = "qwen3:8b"
2229base_url = "http://localhost:11434"
2230embedding_model = "nomic-embed-text"
2231"#;
2232        let result = migrate_llm_to_providers(src).expect("migrate");
2233        assert!(
2234            result.output.contains("[[llm.providers]]"),
2235            "should contain [[llm.providers]]:\n{}",
2236            result.output
2237        );
2238        assert!(
2239            result.output.contains("type = \"ollama\""),
2240            "{}",
2241            result.output
2242        );
2243        assert!(
2244            result.output.contains("model = \"qwen3:8b\""),
2245            "{}",
2246            result.output
2247        );
2248    }
2249
2250    #[test]
2251    fn migrate_llm_claude_produces_providers_block() {
2252        let src = r#"
2253[llm]
2254provider = "claude"
2255
2256[llm.cloud]
2257model = "claude-sonnet-4-6"
2258max_tokens = 8192
2259server_compaction = true
2260"#;
2261        let result = migrate_llm_to_providers(src).expect("migrate");
2262        assert!(
2263            result.output.contains("[[llm.providers]]"),
2264            "{}",
2265            result.output
2266        );
2267        assert!(
2268            result.output.contains("type = \"claude\""),
2269            "{}",
2270            result.output
2271        );
2272        assert!(
2273            result.output.contains("model = \"claude-sonnet-4-6\""),
2274            "{}",
2275            result.output
2276        );
2277        assert!(
2278            result.output.contains("server_compaction = true"),
2279            "{}",
2280            result.output
2281        );
2282    }
2283
2284    #[test]
2285    fn migrate_llm_openai_copies_fields() {
2286        let src = r#"
2287[llm]
2288provider = "openai"
2289
2290[llm.openai]
2291base_url = "https://api.openai.com/v1"
2292model = "gpt-4o"
2293max_tokens = 4096
2294"#;
2295        let result = migrate_llm_to_providers(src).expect("migrate");
2296        assert!(
2297            result.output.contains("type = \"openai\""),
2298            "{}",
2299            result.output
2300        );
2301        assert!(
2302            result
2303                .output
2304                .contains("base_url = \"https://api.openai.com/v1\""),
2305            "{}",
2306            result.output
2307        );
2308    }
2309
2310    #[test]
2311    fn migrate_llm_gemini_copies_fields() {
2312        let src = r#"
2313[llm]
2314provider = "gemini"
2315
2316[llm.gemini]
2317model = "gemini-2.0-flash"
2318max_tokens = 8192
2319base_url = "https://generativelanguage.googleapis.com"
2320"#;
2321        let result = migrate_llm_to_providers(src).expect("migrate");
2322        assert!(
2323            result.output.contains("type = \"gemini\""),
2324            "{}",
2325            result.output
2326        );
2327        assert!(
2328            result.output.contains("model = \"gemini-2.0-flash\""),
2329            "{}",
2330            result.output
2331        );
2332    }
2333
2334    #[test]
2335    fn migrate_llm_compatible_copies_multiple_entries() {
2336        let src = r#"
2337[llm]
2338provider = "compatible"
2339
2340[[llm.compatible]]
2341name = "proxy-a"
2342base_url = "http://proxy-a:8080/v1"
2343model = "llama3"
2344max_tokens = 4096
2345
2346[[llm.compatible]]
2347name = "proxy-b"
2348base_url = "http://proxy-b:8080/v1"
2349model = "mistral"
2350max_tokens = 2048
2351"#;
2352        let result = migrate_llm_to_providers(src).expect("migrate");
2353        // Both compatible entries should be emitted.
2354        let count = result.output.matches("[[llm.providers]]").count();
2355        assert_eq!(
2356            count, 2,
2357            "expected 2 [[llm.providers]] blocks:\n{}",
2358            result.output
2359        );
2360        assert!(
2361            result.output.contains("name = \"proxy-a\""),
2362            "{}",
2363            result.output
2364        );
2365        assert!(
2366            result.output.contains("name = \"proxy-b\""),
2367            "{}",
2368            result.output
2369        );
2370    }
2371
2372    #[test]
2373    fn migrate_llm_mixed_format_errors() {
2374        // Legacy + new format together should produce an error.
2375        let src = r#"
2376[llm]
2377provider = "ollama"
2378
2379[[llm.providers]]
2380type = "ollama"
2381"#;
2382        assert!(
2383            migrate_llm_to_providers(src).is_err(),
2384            "mixed format must return error"
2385        );
2386    }
2387
2388    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
2389
2390    #[test]
2391    fn stt_migration_no_stt_section_returns_unchanged() {
2392        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2393        let result = migrate_stt_to_provider(src).unwrap();
2394        assert_eq!(result.added_count, 0);
2395        assert_eq!(result.output, src);
2396    }
2397
2398    #[test]
2399    fn stt_migration_no_model_or_base_url_returns_unchanged() {
2400        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2401        let result = migrate_stt_to_provider(src).unwrap();
2402        assert_eq!(result.added_count, 0);
2403    }
2404
2405    #[test]
2406    fn stt_migration_moves_model_to_provider_entry() {
2407        let src = r#"
2408[llm]
2409
2410[[llm.providers]]
2411type = "openai"
2412name = "quality"
2413model = "gpt-5.4"
2414
2415[llm.stt]
2416provider = "quality"
2417model = "gpt-4o-mini-transcribe"
2418language = "en"
2419"#;
2420        let result = migrate_stt_to_provider(src).unwrap();
2421        assert_eq!(result.added_count, 1);
2422        // stt_model should appear in providers entry.
2423        assert!(
2424            result.output.contains("stt_model"),
2425            "stt_model must be in output"
2426        );
2427        // model should be removed from [llm.stt].
2428        // The output should parse cleanly.
2429        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2430        let stt = doc
2431            .get("llm")
2432            .and_then(toml_edit::Item::as_table)
2433            .and_then(|l| l.get("stt"))
2434            .and_then(toml_edit::Item::as_table)
2435            .unwrap();
2436        assert!(
2437            stt.get("model").is_none(),
2438            "model must be removed from [llm.stt]"
2439        );
2440        assert_eq!(
2441            stt.get("provider").and_then(toml_edit::Item::as_str),
2442            Some("quality")
2443        );
2444    }
2445
2446    #[test]
2447    fn stt_migration_creates_new_provider_when_no_match() {
2448        let src = r#"
2449[llm]
2450
2451[[llm.providers]]
2452type = "ollama"
2453name = "local"
2454model = "qwen3:8b"
2455
2456[llm.stt]
2457provider = "whisper"
2458model = "whisper-1"
2459base_url = "https://api.openai.com/v1"
2460language = "en"
2461"#;
2462        let result = migrate_stt_to_provider(src).unwrap();
2463        assert!(
2464            result.output.contains("openai-stt"),
2465            "new entry name must be openai-stt"
2466        );
2467        assert!(
2468            result.output.contains("stt_model"),
2469            "stt_model must be in output"
2470        );
2471    }
2472
2473    #[test]
2474    fn stt_migration_candle_whisper_creates_candle_entry() {
2475        let src = r#"
2476[llm]
2477
2478[llm.stt]
2479provider = "candle-whisper"
2480model = "openai/whisper-tiny"
2481language = "auto"
2482"#;
2483        let result = migrate_stt_to_provider(src).unwrap();
2484        assert!(
2485            result.output.contains("local-whisper"),
2486            "candle entry name must be local-whisper"
2487        );
2488        assert!(result.output.contains("candle"), "type must be candle");
2489    }
2490
2491    #[test]
2492    fn stt_migration_w2_assigns_explicit_name() {
2493        // Provider has no explicit name (type = "openai") — migration must assign one.
2494        let src = r#"
2495[llm]
2496
2497[[llm.providers]]
2498type = "openai"
2499model = "gpt-5.4"
2500
2501[llm.stt]
2502provider = "openai"
2503model = "whisper-1"
2504language = "auto"
2505"#;
2506        let result = migrate_stt_to_provider(src).unwrap();
2507        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2508        let providers = doc
2509            .get("llm")
2510            .and_then(toml_edit::Item::as_table)
2511            .and_then(|l| l.get("providers"))
2512            .and_then(toml_edit::Item::as_array_of_tables)
2513            .unwrap();
2514        let entry = providers
2515            .iter()
2516            .find(|t| t.get("stt_model").is_some())
2517            .unwrap();
2518        // Must have an explicit `name` field (W2).
2519        assert!(
2520            entry.get("name").is_some(),
2521            "migrated entry must have explicit name"
2522        );
2523    }
2524
2525    #[test]
2526    fn stt_migration_removes_base_url_from_stt_table() {
2527        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
2528        let src = r#"
2529[llm]
2530
2531[[llm.providers]]
2532type = "openai"
2533name = "quality"
2534model = "gpt-5.4"
2535
2536[llm.stt]
2537provider = "quality"
2538model = "whisper-1"
2539base_url = "https://api.openai.com/v1"
2540language = "en"
2541"#;
2542        let result = migrate_stt_to_provider(src).unwrap();
2543        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2544        let stt = doc
2545            .get("llm")
2546            .and_then(toml_edit::Item::as_table)
2547            .and_then(|l| l.get("stt"))
2548            .and_then(toml_edit::Item::as_table)
2549            .unwrap();
2550        assert!(
2551            stt.get("model").is_none(),
2552            "model must be removed from [llm.stt]"
2553        );
2554        assert!(
2555            stt.get("base_url").is_none(),
2556            "base_url must be removed from [llm.stt]"
2557        );
2558    }
2559
2560    #[test]
2561    fn migrate_planner_model_to_provider_with_field() {
2562        let input = r#"
2563[orchestration]
2564enabled = true
2565planner_model = "gpt-4o"
2566max_tasks = 20
2567"#;
2568        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2569        assert_eq!(result.added_count, 1, "added_count must be 1");
2570        assert!(
2571            !result.output.contains("planner_model = "),
2572            "planner_model key must be removed from output"
2573        );
2574        assert!(
2575            result.output.contains("# planner_provider"),
2576            "commented-out planner_provider entry must be present"
2577        );
2578        assert!(
2579            result.output.contains("gpt-4o"),
2580            "old value must appear in the comment"
2581        );
2582        assert!(
2583            result.output.contains("MIGRATED"),
2584            "comment must include MIGRATED marker"
2585        );
2586    }
2587
2588    #[test]
2589    fn migrate_planner_model_to_provider_no_op() {
2590        let input = r"
2591[orchestration]
2592enabled = true
2593max_tasks = 20
2594";
2595        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2596        assert_eq!(
2597            result.added_count, 0,
2598            "added_count must be 0 when field is absent"
2599        );
2600        assert_eq!(
2601            result.output, input,
2602            "output must equal input when nothing to migrate"
2603        );
2604    }
2605
2606    #[test]
2607    fn migrate_error_invalid_structure_formats_correctly() {
2608        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
2609        // produces a human-readable message. The error path is triggered when the [llm] item
2610        // is present but cannot be obtained as a mutable table (defensive guard replacing the
2611        // previous .expect() calls that would have panicked).
2612        let err = MigrateError::InvalidStructure("test sentinel");
2613        assert!(
2614            matches!(err, MigrateError::InvalidStructure(_)),
2615            "variant must match"
2616        );
2617        let msg = err.to_string();
2618        assert!(
2619            msg.contains("invalid TOML structure"),
2620            "error message must mention 'invalid TOML structure', got: {msg}"
2621        );
2622        assert!(
2623            msg.contains("test sentinel"),
2624            "message must include reason: {msg}"
2625        );
2626    }
2627
2628    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
2629
2630    #[test]
2631    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2632        let src = r#"
2633[mcp]
2634allowed_commands = ["npx"]
2635
2636[[mcp.servers]]
2637id = "srv-a"
2638command = "npx"
2639args = ["-y", "some-mcp"]
2640
2641[[mcp.servers]]
2642id = "srv-b"
2643command = "npx"
2644args = ["-y", "other-mcp"]
2645"#;
2646        let result = migrate_mcp_trust_levels(src).expect("migrate");
2647        assert_eq!(
2648            result.added_count, 2,
2649            "both entries must get trust_level added"
2650        );
2651        assert!(
2652            result
2653                .sections_added
2654                .contains(&"mcp.servers.trust_level".to_owned()),
2655            "sections_added must report mcp.servers.trust_level"
2656        );
2657        // Both entries must now contain trust_level = "trusted"
2658        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2659        assert_eq!(
2660            occurrences, 2,
2661            "each entry must have trust_level = \"trusted\""
2662        );
2663    }
2664
2665    #[test]
2666    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2667        let src = r#"
2668[[mcp.servers]]
2669id = "srv-a"
2670command = "npx"
2671trust_level = "sandboxed"
2672tool_allowlist = ["read_file"]
2673
2674[[mcp.servers]]
2675id = "srv-b"
2676command = "npx"
2677"#;
2678        let result = migrate_mcp_trust_levels(src).expect("migrate");
2679        // Only srv-b has no trust_level, so only 1 entry should be updated
2680        assert_eq!(
2681            result.added_count, 1,
2682            "only entry without trust_level gets updated"
2683        );
2684        // srv-a's sandboxed value must not be overwritten
2685        assert!(
2686            result.output.contains("trust_level = \"sandboxed\""),
2687            "existing trust_level must not be overwritten"
2688        );
2689        // srv-b gets trusted
2690        assert!(
2691            result.output.contains("trust_level = \"trusted\""),
2692            "entry without trust_level must get trusted"
2693        );
2694    }
2695
2696    #[test]
2697    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2698        let src = "[agent]\nname = \"Zeph\"\n";
2699        let result = migrate_mcp_trust_levels(src).expect("migrate");
2700        assert_eq!(result.added_count, 0);
2701        assert!(result.sections_added.is_empty());
2702        assert_eq!(result.output, src);
2703    }
2704
2705    #[test]
2706    fn migrate_mcp_trust_levels_no_servers_is_noop() {
2707        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2708        let result = migrate_mcp_trust_levels(src).expect("migrate");
2709        assert_eq!(result.added_count, 0);
2710        assert!(result.sections_added.is_empty());
2711        assert_eq!(result.output, src);
2712    }
2713
2714    #[test]
2715    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2716        let src = r#"
2717[[mcp.servers]]
2718id = "srv-a"
2719trust_level = "trusted"
2720
2721[[mcp.servers]]
2722id = "srv-b"
2723trust_level = "untrusted"
2724"#;
2725        let result = migrate_mcp_trust_levels(src).expect("migrate");
2726        assert_eq!(result.added_count, 0);
2727        assert!(result.sections_added.is_empty());
2728    }
2729
2730    #[test]
2731    fn migrate_database_url_adds_comment_when_absent() {
2732        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2733        let result = migrate_database_url(src).expect("migrate");
2734        assert_eq!(result.added_count, 1);
2735        assert!(
2736            result
2737                .sections_added
2738                .contains(&"memory.database_url".to_owned())
2739        );
2740        assert!(result.output.contains("# database_url = \"\""));
2741    }
2742
2743    #[test]
2744    fn migrate_database_url_is_noop_when_present() {
2745        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2746        let result = migrate_database_url(src).expect("migrate");
2747        assert_eq!(result.added_count, 0);
2748        assert!(result.sections_added.is_empty());
2749        assert_eq!(result.output, src);
2750    }
2751
2752    #[test]
2753    fn migrate_database_url_creates_memory_section_when_absent() {
2754        let src = "[agent]\nname = \"Zeph\"\n";
2755        let result = migrate_database_url(src).expect("migrate");
2756        assert_eq!(result.added_count, 1);
2757        assert!(result.output.contains("# database_url = \"\""));
2758    }
2759
2760    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
2761
2762    #[test]
2763    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2764        let src = "[agent]\nname = \"Zeph\"\n";
2765        let result = migrate_agent_budget_hint(src).expect("migrate");
2766        assert_eq!(result.added_count, 1);
2767        assert!(result.output.contains("budget_hint_enabled"));
2768        assert!(
2769            result
2770                .sections_added
2771                .contains(&"agent.budget_hint_enabled".to_owned())
2772        );
2773    }
2774
2775    #[test]
2776    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2777        let src = "[llm]\nmodel = \"gpt-4o\"\n";
2778        let result = migrate_agent_budget_hint(src).expect("migrate");
2779        assert_eq!(result.added_count, 0);
2780        assert_eq!(result.output, src);
2781    }
2782
2783    #[test]
2784    fn migrate_agent_budget_hint_already_present_is_noop() {
2785        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2786        let result = migrate_agent_budget_hint(src).expect("migrate");
2787        assert_eq!(result.added_count, 0);
2788        assert_eq!(result.output, src);
2789    }
2790
2791    #[test]
2792    fn migrate_telemetry_config_empty_config_appends_comment_block() {
2793        let src = "[agent]\nname = \"Zeph\"\n";
2794        let result = migrate_telemetry_config(src).expect("migrate");
2795        assert_eq!(result.added_count, 1);
2796        assert_eq!(result.sections_added, vec!["telemetry"]);
2797        assert!(
2798            result.output.contains("# [telemetry]"),
2799            "expected commented-out [telemetry] block in output"
2800        );
2801        assert!(
2802            result.output.contains("enabled = false"),
2803            "expected enabled = false in telemetry comment block"
2804        );
2805    }
2806
2807    #[test]
2808    fn migrate_telemetry_config_existing_section_is_noop() {
2809        let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
2810        let result = migrate_telemetry_config(src).expect("migrate");
2811        assert_eq!(result.added_count, 0);
2812        assert_eq!(result.output, src);
2813    }
2814
2815    #[test]
2816    fn migrate_telemetry_config_existing_comment_is_noop() {
2817        // Idempotency: if the comment block was already added, don't append again.
2818        let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
2819        let result = migrate_telemetry_config(src).expect("migrate");
2820        assert_eq!(result.added_count, 0);
2821        assert_eq!(result.output, src);
2822    }
2823
2824    // ── migrate_otel_filter tests (#2997) ─────────────────────────────────────
2825
2826    #[test]
2827    fn migrate_otel_filter_already_present_is_noop() {
2828        // Real key present — must not modify.
2829        let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
2830        let result = migrate_otel_filter(src).expect("migrate");
2831        assert_eq!(result.added_count, 0);
2832        assert_eq!(result.output, src);
2833    }
2834
2835    #[test]
2836    fn migrate_otel_filter_commented_key_is_noop() {
2837        // Commented-out key already present — idempotent.
2838        let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
2839        let result = migrate_otel_filter(src).expect("migrate");
2840        assert_eq!(result.added_count, 0);
2841        assert_eq!(result.output, src);
2842    }
2843
2844    #[test]
2845    fn migrate_otel_filter_no_telemetry_section_is_noop() {
2846        // [telemetry] absent — must not inject into wrong location.
2847        let src = "[agent]\nname = \"Zeph\"\n";
2848        let result = migrate_otel_filter(src).expect("migrate");
2849        assert_eq!(result.added_count, 0);
2850        assert_eq!(result.output, src);
2851        assert!(!result.output.contains("otel_filter"));
2852    }
2853
2854    #[test]
2855    fn migrate_otel_filter_injects_within_telemetry_section() {
2856        let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
2857        let result = migrate_otel_filter(src).expect("migrate");
2858        assert_eq!(result.added_count, 1);
2859        assert_eq!(result.sections_added, vec!["telemetry.otel_filter"]);
2860        assert!(
2861            result.output.contains("otel_filter"),
2862            "otel_filter comment must appear"
2863        );
2864        // Comment must appear before [agent] — i.e., within the telemetry section.
2865        let otel_pos = result
2866            .output
2867            .find("otel_filter")
2868            .expect("otel_filter present");
2869        let agent_pos = result.output.find("[agent]").expect("[agent] present");
2870        assert!(
2871            otel_pos < agent_pos,
2872            "otel_filter comment should appear before [agent] section"
2873        );
2874    }
2875}