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, 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    "session",
44];
45
46/// Error type for migration failures.
47#[derive(Debug, thiserror::Error)]
48pub enum MigrateError {
49    /// Failed to parse the user's config.
50    #[error("failed to parse input config: {0}")]
51    Parse(#[from] toml_edit::TomlError),
52    /// Failed to parse the embedded reference config (should never happen in practice).
53    #[error("failed to parse reference config: {0}")]
54    Reference(toml_edit::TomlError),
55    /// The document structure is inconsistent (e.g. `[llm.stt].model` exists but `[llm]` table
56    /// cannot be obtained as a mutable table — can happen when `[llm]` is absent or not a table).
57    #[error("migration failed: invalid TOML structure — {0}")]
58    InvalidStructure(&'static str),
59}
60
61/// Result of a migration operation.
62#[derive(Debug)]
63pub struct MigrationResult {
64    /// The migrated TOML document as a string.
65    pub output: String,
66    /// Number of top-level keys or sub-keys added as comments.
67    pub added_count: usize,
68    /// Names of top-level sections that were added.
69    pub sections_added: Vec<String>,
70}
71
72/// Migrates a user config by adding missing parameters as commented-out entries.
73///
74/// The canonical reference is embedded from `config/default.toml` at compile time.
75/// User values are never modified; only missing keys are appended as comments.
76pub struct ConfigMigrator {
77    reference_src: &'static str,
78}
79
80impl Default for ConfigMigrator {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl ConfigMigrator {
87    /// Create a new migrator using the embedded canonical reference config.
88    #[must_use]
89    pub fn new() -> Self {
90        Self {
91            reference_src: include_str!("../config/default.toml"),
92        }
93    }
94
95    /// Migrate `user_toml`: add missing parameters from the reference as commented-out entries.
96    ///
97    /// # Errors
98    ///
99    /// Returns `MigrateError::Parse` if the user's TOML is invalid.
100    /// Returns `MigrateError::Reference` if the embedded reference TOML cannot be parsed.
101    ///
102    /// # Panics
103    ///
104    /// Never panics in practice; `.expect("checked")` is unreachable because `is_table()` is
105    /// verified on the same `ref_item` immediately before calling `as_table()`.
106    pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
107        let reference_doc = self
108            .reference_src
109            .parse::<DocumentMut>()
110            .map_err(MigrateError::Reference)?;
111        let mut user_doc = user_toml.parse::<DocumentMut>()?;
112
113        let mut added_count = 0usize;
114        let mut sections_added: Vec<String> = Vec::new();
115        // Collected scalar/sub-table comment lines to insert after rendering.
116        // Each entry: (section_key, comment_line).
117        let mut pending_comments: Vec<(String, String)> = Vec::new();
118
119        // Walk the reference top-level keys.
120        for (key, ref_item) in reference_doc.as_table() {
121            if ref_item.is_table() {
122                let ref_table = ref_item.as_table().expect("is_table checked above");
123                if user_doc.contains_key(key) {
124                    // Section exists — merge missing sub-keys.
125                    if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
126                        let (n, comments) =
127                            merge_table_commented(user_table, ref_table, key, user_toml);
128                        added_count += n;
129                        pending_comments.extend(comments);
130                    }
131                } else {
132                    // Entire section is missing — record for textual append after rendering.
133                    // Idempotency: skip if a commented block for this section was already appended.
134                    if user_toml.contains(&format!("# [{key}]")) {
135                        continue;
136                    }
137                    let commented = commented_table_block(key, ref_table);
138                    if !commented.is_empty() {
139                        sections_added.push(key.to_owned());
140                    }
141                    added_count += 1;
142                }
143            } else {
144                // Top-level scalar/array key.
145                if !user_doc.contains_key(key) {
146                    let raw = format_commented_item(key, ref_item);
147                    if !raw.is_empty() {
148                        sections_added.push(format!("__scalar__{key}"));
149                        added_count += 1;
150                    }
151                }
152            }
153        }
154
155        // Render the user doc as-is first.
156        let user_str = user_doc.to_string();
157
158        // Insert collected scalar/sub-table comment lines via raw text operations.
159        // This avoids toml_edit decor roundtrip loss — guards check the rendered string.
160        let mut output = user_str;
161        for (section_key, comment_line) in &pending_comments {
162            if !section_body(&output, section_key).contains(comment_line.trim()) {
163                output = insert_after_section(&output, section_key, comment_line);
164            }
165        }
166
167        // Append missing sections as raw commented text at the end.
168        for key in &sections_added {
169            if let Some(scalar_key) = key.strip_prefix("__scalar__") {
170                if let Some(ref_item) = reference_doc.get(scalar_key) {
171                    let raw = format_commented_item(scalar_key, ref_item);
172                    if !raw.is_empty() {
173                        output.push('\n');
174                        output.push_str(&raw);
175                        output.push('\n');
176                    }
177                }
178            } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
179            {
180                let block = commented_table_block(key, ref_table);
181                if !block.is_empty() {
182                    output.push('\n');
183                    output.push_str(&block);
184                }
185            }
186        }
187
188        // Reorder top-level sections by canonical order.
189        output = reorder_sections(&output, CANONICAL_ORDER);
190
191        // Resolve sections_added to only real section names (not scalars).
192        let sections_added_clean: Vec<String> = sections_added
193            .into_iter()
194            .filter(|k| !k.starts_with("__scalar__"))
195            .collect();
196
197        Ok(MigrationResult {
198            output,
199            added_count,
200            sections_added: sections_added_clean,
201        })
202    }
203}
204
205/// Merge missing keys from `ref_table` into `user_table` as commented-out entries.
206///
207/// Returns `(count, comment_lines)` where `comment_lines` is a list of
208/// `(section_key, comment_line)` pairs to be inserted into the rendered output.
209/// Using raw-string insertion avoids `toml_edit` decor roundtrip loss.
210fn merge_table_commented(
211    user_table: &mut Table,
212    ref_table: &Table,
213    section_key: &str,
214    user_toml: &str,
215) -> (usize, Vec<(String, String)>) {
216    let mut count = 0usize;
217    let mut comments: Vec<(String, String)> = Vec::new();
218    for (key, ref_item) in ref_table {
219        if ref_item.is_table() {
220            if user_table.contains_key(key) {
221                let pair = (
222                    user_table.get_mut(key).and_then(Item::as_table_mut),
223                    ref_item.as_table(),
224                );
225                if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
226                    let sub_key = format!("{section_key}.{key}");
227                    let (n, c) =
228                        merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
229                    count += n;
230                    comments.extend(c);
231                }
232            } else if let Some(ref_sub_table) = ref_item.as_table() {
233                // Sub-table missing from user config — collect as raw commented block.
234                let dotted = format!("{section_key}.{key}");
235                let marker = format!("# [{dotted}]");
236                if !user_toml.contains(&marker) {
237                    let block = commented_table_block(&dotted, ref_sub_table);
238                    if !block.is_empty() {
239                        comments.push((section_key.to_owned(), format!("\n{block}")));
240                        count += 1;
241                    }
242                }
243            }
244        } else if ref_item.is_array_of_tables() {
245            // Never inject array-of-tables entries — they are user-defined.
246        } else {
247            // Scalar/array value — check if already present (as value or as comment).
248            if !user_table.contains_key(key) {
249                let raw_value = ref_item
250                    .as_value()
251                    .map(value_to_toml_string)
252                    .unwrap_or_default();
253                if !raw_value.is_empty() {
254                    let comment_line = format!("# {key} = {raw_value}\n");
255                    // Scope the guard to the target section body so that an identical key
256                    // name in another section does not suppress this insertion.
257                    if !section_body(user_toml, section_key).contains(comment_line.trim()) {
258                        comments.push((section_key.to_owned(), comment_line));
259                        count += 1;
260                    }
261                }
262            }
263        }
264    }
265    (count, comments)
266}
267
268/// Return the body of `[section]` in `doc` — the text between the section header line
269/// and the next top-level `[...]` header (or end of document).
270///
271/// Used to scope idempotency guards to a single section so that a comment present in
272/// one section does not suppress insertion into a different section with the same key name.
273fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
274    let header = format!("[{section}]");
275    let Some(section_start) = doc.find(&header) else {
276        return "";
277    };
278    let body_start = section_start + header.len();
279    let body_end = doc[body_start..]
280        .find("\n[")
281        .map_or(doc.len(), |r| body_start + r);
282    &doc[body_start..body_end]
283}
284
285/// Insert `text` after the last line belonging to `[section_name]` and before the next
286/// top-level `[section]` header (or at the end of the file if no such header follows).
287///
288/// This is a purely textual operation: it does not parse TOML, making it immune to
289/// `toml_edit` decor round-trip loss.
290fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
291    let header = format!("[{section_name}]");
292    let Some(section_start) = raw.find(&header) else {
293        return format!("{raw}{text}");
294    };
295    // Find the next top-level section `[...]` after `section_start`.
296    let search_from = section_start + header.len();
297    // Look for `\n[` which signals a new top-level section.
298    let insert_pos = raw[search_from..]
299        .find("\n[")
300        .map_or(raw.len(), |rel| search_from + rel + 1);
301    let mut out = String::with_capacity(raw.len() + text.len());
302    out.push_str(&raw[..insert_pos]);
303    out.push_str(text);
304    out.push_str(&raw[insert_pos..]);
305    out
306}
307
308/// Format a reference item as a commented TOML line: `# key = value`.
309fn format_commented_item(key: &str, item: &Item) -> String {
310    if let Some(val) = item.as_value() {
311        let raw = value_to_toml_string(val);
312        if !raw.is_empty() {
313            return format!("# {key} = {raw}\n");
314        }
315    }
316    String::new()
317}
318
319/// Render a table as a commented-out TOML block with arbitrary nesting depth.
320///
321/// `section_name` is the full dotted path (e.g. `security.content_isolation`).
322/// Returns an empty string if the table has no renderable content.
323fn commented_table_block(section_name: &str, table: &Table) -> String {
324    use std::fmt::Write as _;
325
326    let mut lines = format!("# [{section_name}]\n");
327
328    for (key, item) in table {
329        if item.is_table() {
330            if let Some(sub_table) = item.as_table() {
331                let sub_name = format!("{section_name}.{key}");
332                let sub_block = commented_table_block(&sub_name, sub_table);
333                if !sub_block.is_empty() {
334                    lines.push('\n');
335                    lines.push_str(&sub_block);
336                }
337            }
338        } else if item.is_array_of_tables() {
339            // Skip — user configures these manually (e.g. `[[mcp.servers]]`).
340        } else if let Some(val) = item.as_value() {
341            let raw = value_to_toml_string(val);
342            if !raw.is_empty() {
343                let _ = writeln!(lines, "# {key} = {raw}");
344            }
345        }
346    }
347
348    // Return empty if we only wrote the section header with no content.
349    if lines.trim() == format!("[{section_name}]") {
350        return String::new();
351    }
352    lines
353}
354
355/// Convert a `toml_edit::Value` to its TOML string representation.
356fn value_to_toml_string(val: &Value) -> String {
357    match val {
358        Value::String(s) => {
359            let inner = s.value();
360            format!("\"{inner}\"")
361        }
362        Value::Integer(i) => i.value().to_string(),
363        Value::Float(f) => {
364            let v = f.value();
365            // Use representation that round-trips exactly.
366            if v.fract() == 0.0 {
367                format!("{v:.1}")
368            } else {
369                format!("{v}")
370            }
371        }
372        Value::Boolean(b) => b.value().to_string(),
373        Value::Array(arr) => format_array(arr),
374        Value::InlineTable(t) => {
375            let pairs: Vec<String> = t
376                .iter()
377                .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
378                .collect();
379            format!("{{ {} }}", pairs.join(", "))
380        }
381        Value::Datetime(dt) => dt.value().to_string(),
382    }
383}
384
385fn format_array(arr: &Array) -> String {
386    if arr.is_empty() {
387        return "[]".to_owned();
388    }
389    let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
390    format!("[{}]", items.join(", "))
391}
392
393/// Reorder top-level sections of a TOML document string by the canonical order.
394///
395/// Sections not in the canonical list are placed at the end, preserving their relative order.
396/// This operates on the raw string rather than the parsed document to preserve comments that
397/// would otherwise be dropped by `toml_edit`'s round-trip.
398fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
399    let sections = split_into_sections(toml_str);
400    if sections.is_empty() {
401        return toml_str.to_owned();
402    }
403
404    // Each entry is (header, content). Empty header = preamble block.
405    let preamble_block = sections
406        .iter()
407        .find(|(h, _)| h.is_empty())
408        .map_or("", |(_, c)| c.as_str());
409
410    let section_map: Vec<(&str, &str)> = sections
411        .iter()
412        .filter(|(h, _)| !h.is_empty())
413        .map(|(h, c)| (h.as_str(), c.as_str()))
414        .collect();
415
416    let mut out = String::new();
417    if !preamble_block.is_empty() {
418        out.push_str(preamble_block);
419    }
420
421    let mut emitted: Vec<bool> = vec![false; section_map.len()];
422
423    for &canon in canonical_order {
424        for (idx, &(header, content)) in section_map.iter().enumerate() {
425            let section_name = extract_section_name(header);
426            let top_level = section_name
427                .split('.')
428                .next()
429                .unwrap_or("")
430                .trim_start_matches('#')
431                .trim();
432            if top_level == canon && !emitted[idx] {
433                out.push_str(content);
434                emitted[idx] = true;
435            }
436        }
437    }
438
439    // Append sections not in canonical order.
440    for (idx, &(_, content)) in section_map.iter().enumerate() {
441        if !emitted[idx] {
442            out.push_str(content);
443        }
444    }
445
446    out
447}
448
449/// Extract the section name from a section header line (e.g. `[agent]` → `agent`).
450fn extract_section_name(header: &str) -> &str {
451    // Strip leading `# ` for commented headers.
452    let trimmed = header.trim().trim_start_matches("# ");
453    // Strip `[` and `]`.
454    if trimmed.starts_with('[') && trimmed.contains(']') {
455        let inner = &trimmed[1..];
456        if let Some(end) = inner.find(']') {
457            return &inner[..end];
458        }
459    }
460    trimmed
461}
462
463/// Split a TOML string into `(header_line, full_block)` pairs.
464///
465/// The first element may have an empty header representing the preamble.
466fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
467    let mut sections: Vec<(String, String)> = Vec::new();
468    let mut current_header = String::new();
469    let mut current_content = String::new();
470
471    for line in toml_str.lines() {
472        let trimmed = line.trim();
473        if is_top_level_section_header(trimmed) {
474            sections.push((current_header.clone(), current_content.clone()));
475            trimmed.clone_into(&mut current_header);
476            line.clone_into(&mut current_content);
477            current_content.push('\n');
478        } else {
479            current_content.push_str(line);
480            current_content.push('\n');
481        }
482    }
483
484    // Push the last section.
485    if !current_header.is_empty() || !current_content.is_empty() {
486        sections.push((current_header, current_content));
487    }
488
489    sections
490}
491
492/// Determine if a line is a real (non-commented) top-level section header.
493///
494/// Top-level means `[name]` with no dots. Commented headers like `# [name]`
495/// are NOT treated as section boundaries — they are migrator-generated hints.
496fn is_top_level_section_header(line: &str) -> bool {
497    if line.starts_with('[')
498        && !line.starts_with("[[")
499        && let Some(end) = line.find(']')
500    {
501        return !line[1..end].contains('.');
502    }
503    false
504}
505
506#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
507fn migrate_ollama_provider(
508    llm: &toml_edit::Table,
509    model: &Option<String>,
510    base_url: &Option<String>,
511    embedding_model: &Option<String>,
512) -> Vec<String> {
513    let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
514    if let Some(m) = model {
515        block.push_str(&format!("model = \"{m}\"\n"));
516    }
517    if let Some(em) = embedding_model {
518        block.push_str(&format!("embedding_model = \"{em}\"\n"));
519    }
520    if let Some(u) = base_url {
521        block.push_str(&format!("base_url = \"{u}\"\n"));
522    }
523    let _ = llm; // not needed for simple ollama case
524    vec![block]
525}
526
527#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
528fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
529    let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
530    if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
531        if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
532            block.push_str(&format!("model = \"{m}\"\n"));
533        }
534        if let Some(t) = cloud
535            .get("max_tokens")
536            .and_then(toml_edit::Item::as_integer)
537        {
538            block.push_str(&format!("max_tokens = {t}\n"));
539        }
540        if cloud
541            .get("server_compaction")
542            .and_then(toml_edit::Item::as_bool)
543            == Some(true)
544        {
545            block.push_str("server_compaction = true\n");
546        }
547        if cloud
548            .get("enable_extended_context")
549            .and_then(toml_edit::Item::as_bool)
550            == Some(true)
551        {
552            block.push_str("enable_extended_context = true\n");
553        }
554        if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
555            let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
556            block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
557        }
558        if let Some(v) = cloud
559            .get("prompt_cache_ttl")
560            .and_then(toml_edit::Item::as_str)
561        {
562            if v != "ephemeral" {
563                block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
564            }
565        }
566    } else if let Some(m) = model {
567        block.push_str(&format!("model = \"{m}\"\n"));
568    }
569    vec![block]
570}
571
572#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
573fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
574    let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
575    if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
576        copy_str_field(openai, "model", &mut block);
577        copy_str_field(openai, "base_url", &mut block);
578        copy_int_field(openai, "max_tokens", &mut block);
579        copy_str_field(openai, "embedding_model", &mut block);
580        copy_str_field(openai, "reasoning_effort", &mut block);
581    } else if let Some(m) = model {
582        block.push_str(&format!("model = \"{m}\"\n"));
583    }
584    vec![block]
585}
586
587#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
588fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
589    let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
590    if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
591        copy_str_field(gemini, "model", &mut block);
592        copy_int_field(gemini, "max_tokens", &mut block);
593        copy_str_field(gemini, "base_url", &mut block);
594        copy_str_field(gemini, "embedding_model", &mut block);
595        copy_str_field(gemini, "thinking_level", &mut block);
596        copy_int_field(gemini, "thinking_budget", &mut block);
597        if let Some(v) = gemini
598            .get("include_thoughts")
599            .and_then(toml_edit::Item::as_bool)
600        {
601            block.push_str(&format!("include_thoughts = {v}\n"));
602        }
603    } else if let Some(m) = model {
604        block.push_str(&format!("model = \"{m}\"\n"));
605    }
606    vec![block]
607}
608
609#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
610fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
611    let mut blocks = Vec::new();
612    if let Some(compat_arr) = llm
613        .get("compatible")
614        .and_then(toml_edit::Item::as_array_of_tables)
615    {
616        for entry in compat_arr {
617            let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
618            copy_str_field(entry, "name", &mut block);
619            copy_str_field(entry, "base_url", &mut block);
620            copy_str_field(entry, "model", &mut block);
621            copy_int_field(entry, "max_tokens", &mut block);
622            copy_str_field(entry, "embedding_model", &mut block);
623            blocks.push(block);
624        }
625    }
626    blocks
627}
628
629// Returns (provider_blocks, routing, routes_block)
630#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
631fn migrate_orchestrator_provider(
632    llm: &toml_edit::Table,
633    model: &Option<String>,
634    base_url: &Option<String>,
635    embedding_model: &Option<String>,
636) -> (Vec<String>, Option<String>, Option<String>) {
637    let mut blocks = Vec::new();
638    let routing = Some("task".to_owned());
639    let mut routes_block = None;
640    if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
641        let default_name = orch
642            .get("default")
643            .and_then(toml_edit::Item::as_str)
644            .unwrap_or("")
645            .to_owned();
646        let embed_name = orch
647            .get("embed")
648            .and_then(toml_edit::Item::as_str)
649            .unwrap_or("")
650            .to_owned();
651        if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
652            let mut rb = "[llm.routes]\n".to_owned();
653            for (key, val) in routes {
654                if let Some(arr) = val.as_array() {
655                    let items: Vec<String> = arr
656                        .iter()
657                        .filter_map(toml_edit::Value::as_str)
658                        .map(|s| format!("\"{s}\""))
659                        .collect();
660                    rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
661                }
662            }
663            routes_block = Some(rb);
664        }
665        if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
666            for (name, pcfg_item) in providers {
667                let Some(pcfg) = pcfg_item.as_table() else {
668                    continue;
669                };
670                let ptype = pcfg
671                    .get("type")
672                    .and_then(toml_edit::Item::as_str)
673                    .unwrap_or("ollama");
674                let mut block =
675                    format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
676                if name == default_name {
677                    block.push_str("default = true\n");
678                }
679                if name == embed_name {
680                    block.push_str("embed = true\n");
681                }
682                copy_str_field(pcfg, "model", &mut block);
683                copy_str_field(pcfg, "base_url", &mut block);
684                copy_str_field(pcfg, "embedding_model", &mut block);
685                if ptype == "claude" && !pcfg.contains_key("model") {
686                    if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
687                        copy_str_field(cloud, "model", &mut block);
688                        copy_int_field(cloud, "max_tokens", &mut block);
689                    }
690                }
691                if ptype == "openai" && !pcfg.contains_key("model") {
692                    if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
693                        copy_str_field(openai, "model", &mut block);
694                        copy_str_field(openai, "base_url", &mut block);
695                        copy_int_field(openai, "max_tokens", &mut block);
696                        copy_str_field(openai, "embedding_model", &mut block);
697                    }
698                }
699                if ptype == "ollama" && !pcfg.contains_key("base_url") {
700                    if let Some(u) = base_url {
701                        block.push_str(&format!("base_url = \"{u}\"\n"));
702                    }
703                }
704                if ptype == "ollama" && !pcfg.contains_key("model") {
705                    if let Some(m) = model {
706                        block.push_str(&format!("model = \"{m}\"\n"));
707                    }
708                }
709                if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
710                    if let Some(em) = embedding_model {
711                        block.push_str(&format!("embedding_model = \"{em}\"\n"));
712                    }
713                }
714                blocks.push(block);
715            }
716        }
717    }
718    (blocks, routing, routes_block)
719}
720
721// Returns (provider_blocks, routing)
722#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
723fn migrate_router_provider(
724    llm: &toml_edit::Table,
725    model: &Option<String>,
726    base_url: &Option<String>,
727    embedding_model: &Option<String>,
728) -> (Vec<String>, Option<String>) {
729    let mut blocks = Vec::new();
730    let mut routing = None;
731    if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
732        let strategy = router
733            .get("strategy")
734            .and_then(toml_edit::Item::as_str)
735            .unwrap_or("ema");
736        routing = Some(strategy.to_owned());
737        if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
738            for item in chain {
739                let name = item.as_str().unwrap_or_default();
740                let ptype = infer_provider_type(name, llm);
741                let mut block =
742                    format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
743                match ptype {
744                    "claude" => {
745                        if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
746                            copy_str_field(cloud, "model", &mut block);
747                            copy_int_field(cloud, "max_tokens", &mut block);
748                        }
749                    }
750                    "openai" => {
751                        if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
752                        {
753                            copy_str_field(openai, "model", &mut block);
754                            copy_str_field(openai, "base_url", &mut block);
755                            copy_int_field(openai, "max_tokens", &mut block);
756                            copy_str_field(openai, "embedding_model", &mut block);
757                        } else {
758                            if let Some(m) = model {
759                                block.push_str(&format!("model = \"{m}\"\n"));
760                            }
761                            if let Some(u) = base_url {
762                                block.push_str(&format!("base_url = \"{u}\"\n"));
763                            }
764                        }
765                    }
766                    "ollama" => {
767                        if let Some(m) = model {
768                            block.push_str(&format!("model = \"{m}\"\n"));
769                        }
770                        if let Some(em) = embedding_model {
771                            block.push_str(&format!("embedding_model = \"{em}\"\n"));
772                        }
773                        if let Some(u) = base_url {
774                            block.push_str(&format!("base_url = \"{u}\"\n"));
775                        }
776                    }
777                    _ => {
778                        if let Some(m) = model {
779                            block.push_str(&format!("model = \"{m}\"\n"));
780                        }
781                    }
782                }
783                blocks.push(block);
784            }
785        }
786    }
787    (blocks, routing)
788}
789
790/// Migrate a TOML config string from the old `[llm]` format (with `provider`, `[llm.cloud]`,
791/// `[llm.openai]`, `[llm.orchestrator]`, `[llm.router]` sections) to the new
792/// `[[llm.providers]]` array format.
793///
794/// If the config does not contain legacy LLM keys, it is returned unchanged.
795/// Creates a `.bak` backup at `backup_path` before writing.
796///
797/// # Errors
798///
799/// Returns `MigrateError::Parse` if the input TOML is invalid.
800#[allow(
801    clippy::too_many_lines,
802    clippy::format_push_string,
803    clippy::manual_let_else,
804    clippy::op_ref,
805    clippy::collapsible_if
806)]
807pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
808    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
809
810    // Detect whether this is a legacy-format config.
811    let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
812        Some(t) => t,
813        None => {
814            // No [llm] section at all — nothing to migrate.
815            return Ok(MigrationResult {
816                output: toml_src.to_owned(),
817                added_count: 0,
818                sections_added: Vec::new(),
819            });
820        }
821    };
822
823    let has_provider_field = llm.contains_key("provider");
824    let has_cloud = llm.contains_key("cloud");
825    let has_openai = llm.contains_key("openai");
826    let has_gemini = llm.contains_key("gemini");
827    let has_orchestrator = llm.contains_key("orchestrator");
828    let has_router = llm.contains_key("router");
829    let has_providers = llm.contains_key("providers");
830
831    if !has_provider_field
832        && !has_cloud
833        && !has_openai
834        && !has_orchestrator
835        && !has_router
836        && !has_gemini
837    {
838        // Already in new format (or empty).
839        return Ok(MigrationResult {
840            output: toml_src.to_owned(),
841            added_count: 0,
842            sections_added: Vec::new(),
843        });
844    }
845
846    if has_providers {
847        // Mixed format — refuse to migrate, let the caller handle the error.
848        return Err(MigrateError::Parse(
849            "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
850                .parse::<toml_edit::DocumentMut>()
851                .unwrap_err(),
852        ));
853    }
854
855    // Build new [[llm.providers]] entries from legacy sections.
856    let provider_str = llm
857        .get("provider")
858        .and_then(toml_edit::Item::as_str)
859        .unwrap_or("ollama");
860    let base_url = llm
861        .get("base_url")
862        .and_then(toml_edit::Item::as_str)
863        .map(str::to_owned);
864    let model = llm
865        .get("model")
866        .and_then(toml_edit::Item::as_str)
867        .map(str::to_owned);
868    let embedding_model = llm
869        .get("embedding_model")
870        .and_then(toml_edit::Item::as_str)
871        .map(str::to_owned);
872
873    // Collect provider entries as inline TOML strings.
874    let mut provider_blocks: Vec<String> = Vec::new();
875    let mut routing: Option<String> = None;
876    let mut routes_block: Option<String> = None;
877
878    match provider_str {
879        "ollama" => {
880            provider_blocks.extend(migrate_ollama_provider(
881                llm,
882                &model,
883                &base_url,
884                &embedding_model,
885            ));
886        }
887        "claude" => {
888            provider_blocks.extend(migrate_claude_provider(llm, &model));
889        }
890        "openai" => {
891            provider_blocks.extend(migrate_openai_provider(llm, &model));
892        }
893        "gemini" => {
894            provider_blocks.extend(migrate_gemini_provider(llm, &model));
895        }
896        "compatible" => {
897            provider_blocks.extend(migrate_compatible_provider(llm));
898        }
899        "orchestrator" => {
900            let (blocks, r, rb) =
901                migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
902            provider_blocks.extend(blocks);
903            routing = r;
904            routes_block = rb;
905        }
906        "router" => {
907            let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
908            provider_blocks.extend(blocks);
909            routing = r;
910        }
911        other => {
912            let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
913            if let Some(ref m) = model {
914                block.push_str(&format!("model = \"{m}\"\n"));
915            }
916            provider_blocks.push(block);
917        }
918    }
919
920    if provider_blocks.is_empty() {
921        // Nothing to convert; return as-is.
922        return Ok(MigrationResult {
923            output: toml_src.to_owned(),
924            added_count: 0,
925            sections_added: Vec::new(),
926        });
927    }
928
929    // Build the replacement [llm] section.
930    let mut new_llm = "[llm]\n".to_owned();
931    if let Some(ref r) = routing {
932        new_llm.push_str(&format!("routing = \"{r}\"\n"));
933    }
934    // Carry over cross-cutting LLM settings.
935    for key in &[
936        "response_cache_enabled",
937        "response_cache_ttl_secs",
938        "semantic_cache_enabled",
939        "semantic_cache_threshold",
940        "semantic_cache_max_candidates",
941        "summary_model",
942        "instruction_file",
943    ] {
944        if let Some(val) = llm.get(key) {
945            if let Some(v) = val.as_value() {
946                let raw = value_to_toml_string(v);
947                if !raw.is_empty() {
948                    new_llm.push_str(&format!("{key} = {raw}\n"));
949                }
950            }
951        }
952    }
953    new_llm.push('\n');
954
955    if let Some(rb) = routes_block {
956        new_llm.push_str(&rb);
957        new_llm.push('\n');
958    }
959
960    for block in &provider_blocks {
961        new_llm.push_str(block);
962        new_llm.push('\n');
963    }
964
965    // Remove old [llm] section and all its sub-sections from the source,
966    // then prepend the new section.
967    let output = replace_llm_section(toml_src, &new_llm);
968
969    Ok(MigrationResult {
970        output,
971        added_count: provider_blocks.len(),
972        sections_added: vec!["llm.providers".to_owned()],
973    })
974}
975
976/// Infer provider type from a name used in router chain.
977fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
978    match name {
979        "claude" => "claude",
980        "openai" => "openai",
981        "gemini" => "gemini",
982        "ollama" => "ollama",
983        "candle" => "candle",
984        _ => {
985            // Check if there's a compatible entry with this name.
986            if llm.contains_key("compatible") {
987                "compatible"
988            } else if llm.contains_key("openai") {
989                "openai"
990            } else {
991                "ollama"
992            }
993        }
994    }
995}
996
997fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
998    use std::fmt::Write as _;
999    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
1000        let _ = writeln!(out, "{key} = \"{v}\"");
1001    }
1002}
1003
1004fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1005    use std::fmt::Write as _;
1006    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
1007        let _ = writeln!(out, "{key} = {v}");
1008    }
1009}
1010
1011/// Replace the entire [llm] section (including all [llm.*] sub-sections and
1012/// [[llm.*]] array-of-table entries) with `new_llm_section`.
1013fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
1014    let mut out = String::new();
1015    let mut in_llm = false;
1016    let mut skip_until_next_top = false;
1017
1018    for line in toml_str.lines() {
1019        let trimmed = line.trim();
1020
1021        // Check if this is a top-level section header [something] or [[something]].
1022        let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
1023            && trimmed.ends_with(']')
1024            && !trimmed[1..trimmed.len() - 1].contains('.');
1025        let is_top_aot = trimmed.starts_with("[[")
1026            && trimmed.ends_with("]]")
1027            && !trimmed[2..trimmed.len() - 2].contains('.');
1028        let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
1029            && (trimmed.contains(']'));
1030
1031        if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
1032            in_llm = true;
1033            skip_until_next_top = true;
1034            continue;
1035        }
1036
1037        if is_top_section || is_top_aot {
1038            if skip_until_next_top {
1039                // Emit the new LLM section before the next top-level section.
1040                out.push_str(new_llm_section);
1041                skip_until_next_top = false;
1042            }
1043            in_llm = false;
1044        }
1045
1046        if !skip_until_next_top {
1047            out.push_str(line);
1048            out.push('\n');
1049        }
1050    }
1051
1052    // If [llm] was the last section, append now.
1053    if skip_until_next_top {
1054        out.push_str(new_llm_section);
1055    }
1056
1057    out
1058}
1059
1060/// Migrate an old `[llm.stt]` section (with `model` / `base_url` fields) to the new format
1061/// where those fields live on a `[[llm.providers]]` entry via `stt_model`.
1062///
1063/// Transformations:
1064/// - `[llm.stt].model` → `stt_model` on the matching or new `[[llm.providers]]` entry
1065/// - `[llm.stt].base_url` → `base_url` on that entry (skipped when already present)
1066/// - `[llm.stt].provider` is updated to the provider name; the entry is assigned an explicit
1067///   `name` when it lacked one (W2 guard).
1068/// - Old `model` and `base_url` keys are stripped from `[llm.stt]`.
1069///
1070/// If `[llm.stt]` is absent or already uses the new format (no `model` / `base_url`), the
1071/// input is returned unchanged.
1072///
1073/// # Errors
1074///
1075/// Returns `MigrateError::Parse` if the input TOML is invalid.
1076/// Returns `MigrateError::InvalidStructure` if `[llm.stt].model` is present but the `[llm]`
1077/// key is absent or not a table, making mutation impossible.
1078#[allow(clippy::too_many_lines)]
1079pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1080    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1081
1082    // Extract fields from [llm.stt] if present.
1083    let stt_model = doc
1084        .get("llm")
1085        .and_then(toml_edit::Item::as_table)
1086        .and_then(|llm| llm.get("stt"))
1087        .and_then(toml_edit::Item::as_table)
1088        .and_then(|stt| stt.get("model"))
1089        .and_then(toml_edit::Item::as_str)
1090        .map(ToOwned::to_owned);
1091
1092    let stt_base_url = doc
1093        .get("llm")
1094        .and_then(toml_edit::Item::as_table)
1095        .and_then(|llm| llm.get("stt"))
1096        .and_then(toml_edit::Item::as_table)
1097        .and_then(|stt| stt.get("base_url"))
1098        .and_then(toml_edit::Item::as_str)
1099        .map(ToOwned::to_owned);
1100
1101    let stt_provider_hint = doc
1102        .get("llm")
1103        .and_then(toml_edit::Item::as_table)
1104        .and_then(|llm| llm.get("stt"))
1105        .and_then(toml_edit::Item::as_table)
1106        .and_then(|stt| stt.get("provider"))
1107        .and_then(toml_edit::Item::as_str)
1108        .map(ToOwned::to_owned)
1109        .unwrap_or_default();
1110
1111    // Nothing to migrate if [llm.stt] does not exist or already lacks the old fields.
1112    if stt_model.is_none() && stt_base_url.is_none() {
1113        return Ok(MigrationResult {
1114            output: toml_src.to_owned(),
1115            added_count: 0,
1116            sections_added: Vec::new(),
1117        });
1118    }
1119
1120    let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1121
1122    // Determine the target provider type based on provider hint.
1123    let target_type = match stt_provider_hint.as_str() {
1124        "candle-whisper" | "candle" => "candle",
1125        _ => "openai",
1126    };
1127
1128    // Find or create a [[llm.providers]] entry to attach stt_model to.
1129    // Priority: entry whose effective name matches the hint, else first entry of matching type.
1130    let providers = doc
1131        .get("llm")
1132        .and_then(toml_edit::Item::as_table)
1133        .and_then(|llm| llm.get("providers"))
1134        .and_then(toml_edit::Item::as_array_of_tables);
1135
1136    let matching_idx = providers.and_then(|arr| {
1137        arr.iter().enumerate().find_map(|(i, t)| {
1138            let name = t
1139                .get("name")
1140                .and_then(toml_edit::Item::as_str)
1141                .unwrap_or("");
1142            let ptype = t
1143                .get("type")
1144                .and_then(toml_edit::Item::as_str)
1145                .unwrap_or("");
1146            // Match by explicit name hint or by type when hint is a legacy backend string.
1147            let name_match = !stt_provider_hint.is_empty()
1148                && (name == stt_provider_hint || ptype == stt_provider_hint);
1149            let type_match = ptype == target_type;
1150            if name_match || type_match {
1151                Some(i)
1152            } else {
1153                None
1154            }
1155        })
1156    });
1157
1158    // Determine the final provider name to write into [llm.stt].provider.
1159    let resolved_provider_name: String;
1160
1161    if let Some(idx) = matching_idx {
1162        // Attach stt_model to the existing entry.
1163        let llm_mut = doc
1164            .get_mut("llm")
1165            .and_then(toml_edit::Item::as_table_mut)
1166            .ok_or(MigrateError::InvalidStructure(
1167                "[llm] table not accessible for mutation",
1168            ))?;
1169        let providers_mut = llm_mut
1170            .get_mut("providers")
1171            .and_then(toml_edit::Item::as_array_of_tables_mut)
1172            .ok_or(MigrateError::InvalidStructure(
1173                "[[llm.providers]] array not accessible for mutation",
1174            ))?;
1175        let entry = providers_mut
1176            .iter_mut()
1177            .nth(idx)
1178            .ok_or(MigrateError::InvalidStructure(
1179                "[[llm.providers]] entry index out of range during mutation",
1180            ))?;
1181
1182        // W2: ensure explicit name.
1183        let existing_name = entry
1184            .get("name")
1185            .and_then(toml_edit::Item::as_str)
1186            .map(ToOwned::to_owned);
1187        let entry_name = existing_name.unwrap_or_else(|| {
1188            let t = entry
1189                .get("type")
1190                .and_then(toml_edit::Item::as_str)
1191                .unwrap_or("openai");
1192            format!("{t}-stt")
1193        });
1194        entry.insert("name", toml_edit::value(entry_name.clone()));
1195        entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1196        if stt_base_url.is_some() && entry.get("base_url").is_none() {
1197            entry.insert(
1198                "base_url",
1199                toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1200            );
1201        }
1202        resolved_provider_name = entry_name;
1203    } else {
1204        // No matching entry — append a new [[llm.providers]] block.
1205        let new_name = if target_type == "candle" {
1206            "local-whisper".to_owned()
1207        } else {
1208            "openai-stt".to_owned()
1209        };
1210        let mut new_entry = toml_edit::Table::new();
1211        new_entry.insert("name", toml_edit::value(new_name.clone()));
1212        new_entry.insert("type", toml_edit::value(target_type));
1213        new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1214        if let Some(ref url) = stt_base_url {
1215            new_entry.insert("base_url", toml_edit::value(url.clone()));
1216        }
1217        // Ensure [[llm.providers]] array exists.
1218        let llm_mut = doc
1219            .get_mut("llm")
1220            .and_then(toml_edit::Item::as_table_mut)
1221            .ok_or(MigrateError::InvalidStructure(
1222                "[llm] table not accessible for mutation",
1223            ))?;
1224        if let Some(item) = llm_mut.get_mut("providers") {
1225            if let Some(arr) = item.as_array_of_tables_mut() {
1226                arr.push(new_entry);
1227            }
1228        } else {
1229            let mut arr = toml_edit::ArrayOfTables::new();
1230            arr.push(new_entry);
1231            llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1232        }
1233        resolved_provider_name = new_name;
1234    }
1235
1236    // Update [llm.stt]: set provider name, remove old fields.
1237    if let Some(stt_table) = doc
1238        .get_mut("llm")
1239        .and_then(toml_edit::Item::as_table_mut)
1240        .and_then(|llm| llm.get_mut("stt"))
1241        .and_then(toml_edit::Item::as_table_mut)
1242    {
1243        stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1244        stt_table.remove("model");
1245        stt_table.remove("base_url");
1246    }
1247
1248    Ok(MigrationResult {
1249        output: doc.to_string(),
1250        added_count: 1,
1251        sections_added: vec!["llm.providers.stt_model".to_owned()],
1252    })
1253}
1254
1255/// Migrate `[orchestration] planner_model` to `planner_provider`.
1256///
1257/// The namespaces differ: `planner_model` held a raw model name (e.g. `"gpt-4o"`),
1258/// while `planner_provider` must reference a `[[llm.providers]]` `name` field. A migrated
1259/// value would cause a silent `warn!` from `build_planner_provider()` when resolution fails,
1260/// so the old value is commented out and a warning is emitted.
1261///
1262/// If `planner_model` is absent, the input is returned unchanged.
1263///
1264/// # Errors
1265///
1266/// Returns `MigrateError::Parse` if the input TOML is invalid.
1267pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1268    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1269
1270    let old_value = doc
1271        .get("orchestration")
1272        .and_then(toml_edit::Item::as_table)
1273        .and_then(|t| t.get("planner_model"))
1274        .and_then(toml_edit::Item::as_value)
1275        .and_then(toml_edit::Value::as_str)
1276        .map(ToOwned::to_owned);
1277
1278    let Some(old_model) = old_value else {
1279        return Ok(MigrationResult {
1280            output: toml_src.to_owned(),
1281            added_count: 0,
1282            sections_added: Vec::new(),
1283        });
1284    };
1285
1286    // Remove the old key via text substitution to preserve surrounding comments/formatting.
1287    // We rebuild the section comment in the output rather than using toml_edit mutations,
1288    // following the same line-oriented approach used elsewhere in this file.
1289    let commented_out = format!(
1290        "# planner_provider = \"{old_model}\"  \
1291         # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1292    );
1293
1294    let orch_table = doc
1295        .get_mut("orchestration")
1296        .and_then(toml_edit::Item::as_table_mut)
1297        .ok_or(MigrateError::InvalidStructure(
1298            "[orchestration] is not a table",
1299        ))?;
1300    orch_table.remove("planner_model");
1301    let decor = orch_table.decor_mut();
1302    let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1303    // Append the commented-out entry as a trailing comment on the section.
1304    let new_suffix = if existing_suffix.trim().is_empty() {
1305        format!("\n{commented_out}\n")
1306    } else {
1307        format!("{existing_suffix}\n{commented_out}\n")
1308    };
1309    decor.set_suffix(new_suffix);
1310
1311    eprintln!(
1312        "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1313         and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1314         `name` field, not a raw model name. Update or remove the commented line."
1315    );
1316
1317    Ok(MigrationResult {
1318        output: doc.to_string(),
1319        added_count: 1,
1320        sections_added: vec!["orchestration.planner_provider".to_owned()],
1321    })
1322}
1323
1324/// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry
1325/// that lacks an explicit `trust_level`.
1326///
1327/// Before this PR all config-defined servers skipped SSRF validation (equivalent to
1328/// `trust_level = "trusted"`). Without migration, upgrading to the new default
1329/// (`Untrusted`) would silently break remote servers on private networks.
1330///
1331/// This function adds `trust_level = "trusted"` only to entries that are missing the
1332/// field, preserving entries that already have it set.
1333///
1334/// # Errors
1335///
1336/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1337pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1338    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1339    let mut added = 0usize;
1340
1341    let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1342        return Ok(MigrationResult {
1343            output: toml_src.to_owned(),
1344            added_count: 0,
1345            sections_added: Vec::new(),
1346        });
1347    };
1348
1349    let Some(servers) = mcp
1350        .get_mut("servers")
1351        .and_then(toml_edit::Item::as_array_of_tables_mut)
1352    else {
1353        return Ok(MigrationResult {
1354            output: toml_src.to_owned(),
1355            added_count: 0,
1356            sections_added: Vec::new(),
1357        });
1358    };
1359
1360    for entry in servers.iter_mut() {
1361        if !entry.contains_key("trust_level") {
1362            entry.insert(
1363                "trust_level",
1364                toml_edit::value(toml_edit::Value::from("trusted")),
1365            );
1366            added += 1;
1367        }
1368    }
1369
1370    if added > 0 {
1371        eprintln!(
1372            "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1373             entr{} (preserving previous SSRF-skip behavior). \
1374             Review and adjust trust levels as needed.",
1375            if added == 1 { "y" } else { "ies" }
1376        );
1377    }
1378
1379    Ok(MigrationResult {
1380        output: doc.to_string(),
1381        added_count: added,
1382        sections_added: if added > 0 {
1383            vec!["mcp.servers.trust_level".to_owned()]
1384        } else {
1385            Vec::new()
1386        },
1387    })
1388}
1389
1390/// Migrate `[agent].max_tool_retries` → `[tools.retry].max_attempts` and
1391/// `[agent].max_retry_duration_secs` → `[tools.retry].budget_secs`.
1392///
1393/// Old fields are preserved (not removed) to avoid breaking configs that rely on them
1394/// until they are officially deprecated in a future release. The new `[tools.retry]` section
1395/// is added if missing, populated with the migrated values.
1396///
1397/// # Errors
1398///
1399/// Returns `MigrateError::Parse` if the TOML is invalid.
1400pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1401    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1402
1403    let max_retries = doc
1404        .get("agent")
1405        .and_then(toml_edit::Item::as_table)
1406        .and_then(|t| t.get("max_tool_retries"))
1407        .and_then(toml_edit::Item::as_value)
1408        .and_then(toml_edit::Value::as_integer)
1409        .map(i64::cast_unsigned);
1410
1411    let budget_secs = doc
1412        .get("agent")
1413        .and_then(toml_edit::Item::as_table)
1414        .and_then(|t| t.get("max_retry_duration_secs"))
1415        .and_then(toml_edit::Item::as_value)
1416        .and_then(toml_edit::Value::as_integer)
1417        .map(i64::cast_unsigned);
1418
1419    if max_retries.is_none() && budget_secs.is_none() {
1420        return Ok(MigrationResult {
1421            output: toml_src.to_owned(),
1422            added_count: 0,
1423            sections_added: Vec::new(),
1424        });
1425    }
1426
1427    // Ensure [tools.retry] section exists.
1428    if !doc.contains_key("tools") {
1429        doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1430    }
1431    let tools_table = doc
1432        .get_mut("tools")
1433        .and_then(toml_edit::Item::as_table_mut)
1434        .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1435
1436    if !tools_table.contains_key("retry") {
1437        tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1438    }
1439    let retry_table = tools_table
1440        .get_mut("retry")
1441        .and_then(toml_edit::Item::as_table_mut)
1442        .ok_or(MigrateError::InvalidStructure(
1443            "[tools.retry] is not a table",
1444        ))?;
1445
1446    let mut added_count = 0usize;
1447
1448    if let Some(retries) = max_retries
1449        && !retry_table.contains_key("max_attempts")
1450    {
1451        retry_table.insert(
1452            "max_attempts",
1453            toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1454        );
1455        added_count += 1;
1456    }
1457
1458    if let Some(secs) = budget_secs
1459        && !retry_table.contains_key("budget_secs")
1460    {
1461        retry_table.insert(
1462            "budget_secs",
1463            toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1464        );
1465        added_count += 1;
1466    }
1467
1468    if added_count > 0 {
1469        eprintln!(
1470            "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1471             [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1472        );
1473    }
1474
1475    Ok(MigrationResult {
1476        output: doc.to_string(),
1477        added_count,
1478        sections_added: if added_count > 0 {
1479            vec!["tools.retry".to_owned()]
1480        } else {
1481            Vec::new()
1482        },
1483    })
1484}
1485
1486/// Add a commented-out `database_url = ""` entry under `[memory]` if absent.
1487///
1488/// If the `[memory]` section does not exist it is created. This migration surfaces the
1489/// `PostgreSQL` URL option for users upgrading from a pre-postgres config file.
1490///
1491/// # Errors
1492///
1493/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1494pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1495    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1496    if toml_src.contains("database_url") {
1497        return Ok(MigrationResult {
1498            output: toml_src.to_owned(),
1499            added_count: 0,
1500            sections_added: Vec::new(),
1501        });
1502    }
1503
1504    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1505
1506    // Ensure [memory] section exists (created if absent so the comment has context).
1507    if !doc.contains_key("memory") {
1508        doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1509    }
1510
1511    let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1512         # Leave empty and store the actual URL in the vault:\n\
1513         #   zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1514         # database_url = \"\"\n";
1515    let raw = doc.to_string();
1516    let output = format!("{raw}{comment}");
1517
1518    Ok(MigrationResult {
1519        output,
1520        added_count: 1,
1521        sections_added: vec!["memory.database_url".to_owned()],
1522    })
1523}
1524
1525/// No-op migration for `[tools.shell]` transactional fields added in #2414.
1526///
1527/// All 5 new fields have `#[serde(default)]` so existing configs parse without changes.
1528/// This step adds them as commented-out hints in `[tools.shell]` if not already present.
1529///
1530/// # Errors
1531///
1532/// Returns `MigrateError` if the TOML cannot be parsed or `[tools.shell]` is malformed.
1533pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1534    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1535    if toml_src.contains("transactional") {
1536        return Ok(MigrationResult {
1537            output: toml_src.to_owned(),
1538            added_count: 0,
1539            sections_added: Vec::new(),
1540        });
1541    }
1542
1543    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1544
1545    let tools_shell_exists = doc
1546        .get("tools")
1547        .and_then(toml_edit::Item::as_table)
1548        .is_some_and(|t| t.contains_key("shell"));
1549    if !tools_shell_exists {
1550        // No [tools.shell] section — nothing to annotate; new configs will get defaults.
1551        return Ok(MigrationResult {
1552            output: toml_src.to_owned(),
1553            added_count: 0,
1554            sections_added: Vec::new(),
1555        });
1556    }
1557
1558    let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1559         # transactional = false\n\
1560         # transaction_scope = []          # glob patterns; empty = all extracted paths\n\
1561         # auto_rollback = false           # rollback when exit code >= 2\n\
1562         # auto_rollback_exit_codes = []   # explicit exit codes; overrides >= 2 heuristic\n\
1563         # snapshot_required = false       # abort if snapshot fails (default: warn and proceed)\n";
1564    let raw = doc.to_string();
1565    let output = format!("{raw}{comment}");
1566
1567    Ok(MigrationResult {
1568        output,
1569        added_count: 1,
1570        sections_added: vec!["tools.shell.transactional".to_owned()],
1571    })
1572}
1573
1574/// Migration step: add `budget_hint_enabled` as a commented-out entry under `[agent]` if absent.
1575///
1576/// # Errors
1577///
1578/// Returns an error if the config cannot be parsed or the `[agent]` section is malformed.
1579pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1580    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1581    if toml_src.contains("budget_hint_enabled") {
1582        return Ok(MigrationResult {
1583            output: toml_src.to_owned(),
1584            added_count: 0,
1585            sections_added: Vec::new(),
1586        });
1587    }
1588
1589    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1590    if !doc.contains_key("agent") {
1591        return Ok(MigrationResult {
1592            output: toml_src.to_owned(),
1593            added_count: 0,
1594            sections_added: Vec::new(),
1595        });
1596    }
1597
1598    let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1599         # budget_hint_enabled = true\n";
1600    let raw = doc.to_string();
1601    let output = format!("{raw}{comment}");
1602
1603    Ok(MigrationResult {
1604        output,
1605        added_count: 1,
1606        sections_added: vec!["agent.budget_hint_enabled".to_owned()],
1607    })
1608}
1609
1610/// Add a commented-out `[memory.forgetting]` section if absent (#2397).
1611///
1612/// All forgetting fields have `#[serde(default)]` so existing configs parse without changes.
1613/// This step surfaces the new section for users upgrading from older configs.
1614///
1615/// # Errors
1616///
1617/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1618pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1619    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1620    if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1621        return Ok(MigrationResult {
1622            output: toml_src.to_owned(),
1623            added_count: 0,
1624            sections_added: Vec::new(),
1625        });
1626    }
1627
1628    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1629    if !doc.contains_key("memory") {
1630        return Ok(MigrationResult {
1631            output: toml_src.to_owned(),
1632            added_count: 0,
1633            sections_added: Vec::new(),
1634        });
1635    }
1636
1637    let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1638         # [memory.forgetting]\n\
1639         # enabled = false\n\
1640         # decay_rate = 0.1                   # per-sweep importance decay\n\
1641         # forgetting_floor = 0.05            # prune below this score\n\
1642         # sweep_interval_secs = 7200         # run every 2 hours\n\
1643         # sweep_batch_size = 500\n\
1644         # protect_recent_hours = 24\n\
1645         # protect_min_access_count = 3\n";
1646    let raw = doc.to_string();
1647    let output = format!("{raw}{comment}");
1648
1649    Ok(MigrationResult {
1650        output,
1651        added_count: 1,
1652        sections_added: vec!["memory.forgetting".to_owned()],
1653    })
1654}
1655
1656/// Add a commented-out `[memory.compression.predictor]` block if absent (#2460).
1657///
1658/// All predictor fields have `#[serde(default)]` so existing configs parse without changes.
1659///
1660/// # Errors
1661///
1662/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1663pub fn migrate_compression_predictor_config(
1664    toml_src: &str,
1665) -> Result<MigrationResult, MigrateError> {
1666    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1667    if toml_src.contains("[memory.compression.predictor]")
1668        || toml_src.contains("# [memory.compression.predictor]")
1669    {
1670        return Ok(MigrationResult {
1671            output: toml_src.to_owned(),
1672            added_count: 0,
1673            sections_added: Vec::new(),
1674        });
1675    }
1676
1677    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1678    if !doc.contains_key("memory") {
1679        return Ok(MigrationResult {
1680            output: toml_src.to_owned(),
1681            added_count: 0,
1682            sections_added: Vec::new(),
1683        });
1684    }
1685
1686    let comment = "\n# Performance-floor compression ratio predictor (#2460). Disabled by default.\n\
1687         # [memory.compression.predictor]\n\
1688         # enabled = false\n\
1689         # min_samples = 10                                             # cold-start threshold\n\
1690         # candidate_ratios = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]\n\
1691         # retrain_interval = 5\n\
1692         # max_training_samples = 200\n";
1693    let raw = doc.to_string();
1694    let output = format!("{raw}{comment}");
1695
1696    Ok(MigrationResult {
1697        output,
1698        added_count: 1,
1699        sections_added: vec!["memory.compression.predictor".to_owned()],
1700    })
1701}
1702
1703/// Add a commented-out `[memory.microcompact]` block if absent (#2699).
1704///
1705/// # Errors
1706///
1707/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1708pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1709    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1710    if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1711        return Ok(MigrationResult {
1712            output: toml_src.to_owned(),
1713            added_count: 0,
1714            sections_added: Vec::new(),
1715        });
1716    }
1717
1718    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1719    if !doc.contains_key("memory") {
1720        return Ok(MigrationResult {
1721            output: toml_src.to_owned(),
1722            added_count: 0,
1723            sections_added: Vec::new(),
1724        });
1725    }
1726
1727    let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1728         # [memory.microcompact]\n\
1729         # enabled = false\n\
1730         # gap_threshold_minutes = 60   # idle gap before clearing stale outputs\n\
1731         # keep_recent = 3              # always keep this many recent outputs intact\n";
1732    let raw = doc.to_string();
1733    let output = format!("{raw}{comment}");
1734
1735    Ok(MigrationResult {
1736        output,
1737        added_count: 1,
1738        sections_added: vec!["memory.microcompact".to_owned()],
1739    })
1740}
1741
1742/// Add a commented-out `[memory.autodream]` block if absent (#2697).
1743///
1744/// # Errors
1745///
1746/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1747pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1748    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1749    if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1750        return Ok(MigrationResult {
1751            output: toml_src.to_owned(),
1752            added_count: 0,
1753            sections_added: Vec::new(),
1754        });
1755    }
1756
1757    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1758    if !doc.contains_key("memory") {
1759        return Ok(MigrationResult {
1760            output: toml_src.to_owned(),
1761            added_count: 0,
1762            sections_added: Vec::new(),
1763        });
1764    }
1765
1766    let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1767         # [memory.autodream]\n\
1768         # enabled = false\n\
1769         # min_sessions = 5             # sessions since last consolidation\n\
1770         # min_hours = 8                # hours since last consolidation\n\
1771         # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1772         # max_iterations = 5\n";
1773    let raw = doc.to_string();
1774    let output = format!("{raw}{comment}");
1775
1776    Ok(MigrationResult {
1777        output,
1778        added_count: 1,
1779        sections_added: vec!["memory.autodream".to_owned()],
1780    })
1781}
1782
1783/// Add a commented-out `[magic_docs]` block if absent (#2702).
1784///
1785/// # Errors
1786///
1787/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1788pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1789    use toml_edit::{Item, Table};
1790
1791    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1792
1793    if doc.contains_key("magic_docs") {
1794        return Ok(MigrationResult {
1795            output: toml_src.to_owned(),
1796            added_count: 0,
1797            sections_added: Vec::new(),
1798        });
1799    }
1800
1801    doc.insert("magic_docs", Item::Table(Table::new()));
1802    let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1803         # [magic_docs]\n\
1804         # enabled = false\n\
1805         # min_turns_between_updates = 10\n\
1806         # update_provider = \"\"         # provider name from [[llm.providers]]; empty = primary\n\
1807         # max_iterations = 3\n";
1808    // Remove the just-inserted empty table and replace with a comment.
1809    doc.remove("magic_docs");
1810    // Append as a trailing comment on the document root.
1811    let raw = doc.to_string();
1812    let output = format!("{raw}\n{comment}");
1813
1814    Ok(MigrationResult {
1815        output,
1816        added_count: 1,
1817        sections_added: vec!["magic_docs".to_owned()],
1818    })
1819}
1820
1821/// Add a commented-out `[telemetry]` block if the section is absent (#2846).
1822///
1823/// Existing configs that were written before the `telemetry` section was introduced will have
1824/// the block appended as comments so users can discover and enable it without manual hunting.
1825///
1826/// # Errors
1827///
1828/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1829pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1830    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1831
1832    if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1833        return Ok(MigrationResult {
1834            output: toml_src.to_owned(),
1835            added_count: 0,
1836            sections_added: Vec::new(),
1837        });
1838    }
1839
1840    let comment = "\n\
1841         # Profiling and distributed tracing (requires --features profiling). All\n\
1842         # instrumentation points are zero-overhead when the feature is absent.\n\
1843         # [telemetry]\n\
1844         # enabled = false\n\
1845         # backend = \"local\"        # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1846         # trace_dir = \".local/traces\"\n\
1847         # include_args = false\n\
1848         # service_name = \"zeph-agent\"\n\
1849         # sample_rate = 1.0\n\
1850         # otel_filter = \"info\"     # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1851
1852    let raw = doc.to_string();
1853    let output = format!("{raw}{comment}");
1854
1855    Ok(MigrationResult {
1856        output,
1857        added_count: 1,
1858        sections_added: vec!["telemetry".to_owned()],
1859    })
1860}
1861
1862/// Add a commented-out `[agent.supervisor]` block if the sub-table is absent (#2883).
1863///
1864/// Appended as comments under `[agent]` so users can discover and tune supervisor limits
1865/// without manual hunting. Safe to call on configs that already have the section.
1866///
1867/// # Errors
1868///
1869/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1870pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1871    // Idempotency: skip if already present (either as real section or commented-out block).
1872    if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1873        return Ok(MigrationResult {
1874            output: toml_src.to_owned(),
1875            added_count: 0,
1876            sections_added: Vec::new(),
1877        });
1878    }
1879
1880    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1881
1882    // Only inject the comment block when an [agent] section is already present so we don't
1883    // pollute configs that have no [agent] at all.
1884    if !doc.contains_key("agent") {
1885        return Ok(MigrationResult {
1886            output: toml_src.to_owned(),
1887            added_count: 0,
1888            sections_added: Vec::new(),
1889        });
1890    }
1891
1892    let comment = "\n\
1893         # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1894         # [agent.supervisor]\n\
1895         # enrichment_limit = 4\n\
1896         # telemetry_limit = 8\n\
1897         # abort_enrichment_on_turn = false\n";
1898
1899    let raw = doc.to_string();
1900    let output = format!("{raw}{comment}");
1901
1902    Ok(MigrationResult {
1903        output,
1904        added_count: 1,
1905        sections_added: vec!["agent.supervisor".to_owned()],
1906    })
1907}
1908
1909/// Add a commented-out `otel_filter` entry under `[telemetry]` if the key is absent (#2997).
1910///
1911/// When `[telemetry]` exists but lacks `otel_filter`, appends the key as a comment so users
1912/// can discover it without manual hunting. Safe to call when the key is already present
1913/// (real or commented-out).
1914///
1915/// # Errors
1916///
1917/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1918pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1919    // Idempotency: skip if key already present (real or commented-out).
1920    if toml_src.contains("otel_filter") {
1921        return Ok(MigrationResult {
1922            output: toml_src.to_owned(),
1923            added_count: 0,
1924            sections_added: Vec::new(),
1925        });
1926    }
1927
1928    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1929
1930    // Only inject when [telemetry] section exists; otherwise the field will be added
1931    // by migrate_telemetry_config which already includes it in the commented block.
1932    if !doc.contains_key("telemetry") {
1933        return Ok(MigrationResult {
1934            output: toml_src.to_owned(),
1935            added_count: 0,
1936            sections_added: Vec::new(),
1937        });
1938    }
1939
1940    let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
1941        (tonic=warn etc.) are always appended (#2997).\n\
1942        # otel_filter = \"info\"\n";
1943    let raw = doc.to_string();
1944    // Insert within [telemetry] so the comment stays adjacent to its section.
1945    let output = insert_after_section(&raw, "telemetry", comment);
1946
1947    Ok(MigrationResult {
1948        output,
1949        added_count: 1,
1950        sections_added: vec!["telemetry.otel_filter".to_owned()],
1951    })
1952}
1953
1954/// Adds a commented-out `[tools.egress]` section to configs that predate egress logging (#3058).
1955///
1956/// # Errors
1957///
1958/// Returns [`MigrateError`] if the TOML source cannot be parsed.
1959pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1960    if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
1961        return Ok(MigrationResult {
1962            output: toml_src.to_owned(),
1963            added_count: 0,
1964            sections_added: Vec::new(),
1965        });
1966    }
1967
1968    let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
1969        # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
1970        # [tools.egress]\n\
1971        # enabled = true           # set to false to disable all egress event recording\n\
1972        # log_blocked = true       # record scheme/domain/SSRF-blocked requests\n\
1973        # log_response_bytes = true\n\
1974        # log_hosts_to_tui = true\n";
1975
1976    let mut output = toml_src.to_owned();
1977    output.push_str(comment);
1978    Ok(MigrationResult {
1979        output,
1980        added_count: 1,
1981        sections_added: vec!["tools.egress".to_owned()],
1982    })
1983}
1984
1985/// Adds a commented-out `[security.vigil]` section to configs that predate VIGIL (#3058).
1986///
1987/// # Errors
1988///
1989/// Returns [`MigrateError`] if the TOML source cannot be parsed.
1990pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1991    if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
1992        return Ok(MigrationResult {
1993            output: toml_src.to_owned(),
1994            added_count: 0,
1995            sections_added: Vec::new(),
1996        });
1997    }
1998
1999    let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2000        # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2001        # [security.vigil]\n\
2002        # enabled = true          # master switch; false bypasses VIGIL entirely\n\
2003        # strict_mode = false     # true: block (replace with sentinel); false: truncate+annotate\n\
2004        # sanitize_max_chars = 2048\n\
2005        # extra_patterns = []     # operator-supplied additional injection patterns (max 64)\n\
2006        # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2007
2008    let mut output = toml_src.to_owned();
2009    output.push_str(comment);
2010    Ok(MigrationResult {
2011        output,
2012        added_count: 1,
2013        sections_added: vec!["security.vigil".to_owned()],
2014    })
2015}
2016
2017/// Adds a commented-out `[tools.sandbox]` section to configs that predate the
2018/// OS subprocess sandbox wizard (#3070). Also referenced by #3077.
2019///
2020/// Idempotent: if the section (or a dotted-key form under `[tools]`) is already
2021/// present, OR if the commented-out block was already appended by a prior run,
2022/// the input is returned unchanged. Uses `toml_edit` parsing to avoid false
2023/// positives from comments that mention `tools.sandbox`.
2024///
2025/// # Errors
2026///
2027/// Returns [`MigrateError`] if the TOML source cannot be parsed.
2028pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2029    let doc: DocumentMut = toml_src.parse()?;
2030    let already_present = doc
2031        .get("tools")
2032        .and_then(|t| t.as_table())
2033        .and_then(|t| t.get("sandbox"))
2034        .is_some();
2035    // Secondary guard: commented-out block appended by a prior run of this
2036    // function is not a real TOML key, so toml_edit would not detect it above.
2037    if already_present || toml_src.contains("# [tools.sandbox]") {
2038        return Ok(MigrationResult {
2039            output: toml_src.to_owned(),
2040            added_count: 0,
2041            sections_added: Vec::new(),
2042        });
2043    }
2044
2045    let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2046        # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2047        # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2048        # [tools.sandbox]\n\
2049        # enabled = false                 # set to true to wrap shell commands\n\
2050        # profile = \"workspace\"          # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2051        # backend = \"auto\"               # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2052        # strict = true                   # fail startup if sandbox init fails (fail-closed)\n\
2053        # allow_read = []                 # additional read-allowed absolute paths\n\
2054        # allow_write = []                # additional write-allowed absolute paths\n";
2055
2056    let mut output = toml_src.to_owned();
2057    output.push_str(comment);
2058    Ok(MigrationResult {
2059        output,
2060        added_count: 1,
2061        sections_added: vec!["tools.sandbox".to_owned()],
2062    })
2063}
2064
2065/// Add a commented-out `persistence_enabled` key under `[orchestration]` when absent (#3107).
2066///
2067/// Existing configs that omit this key pick up `true` via `#[serde(default)]`, so this
2068/// migration is informational — it surfaces the new option without changing behaviour.
2069///
2070/// # Errors
2071///
2072/// Returns [`MigrateError`] if the TOML document cannot be parsed.
2073pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2074    // Skip if the key is already present (active or commented).
2075    if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2076        return Ok(MigrationResult {
2077            output: toml_src.to_owned(),
2078            added_count: 0,
2079            sections_added: Vec::new(),
2080        });
2081    }
2082
2083    // Only inject under an existing [orchestration] section.
2084    if !toml_src.contains("[orchestration]") {
2085        return Ok(MigrationResult {
2086            output: toml_src.to_owned(),
2087            added_count: 0,
2088            sections_added: Vec::new(),
2089        });
2090    }
2091
2092    // Insert the commented key right after the `[orchestration]` header line.
2093    let comment = "# persistence_enabled = true  \
2094        # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2095    let output = toml_src.replacen(
2096        "[orchestration]\n",
2097        &format!("[orchestration]\n{comment}"),
2098        1,
2099    );
2100    Ok(MigrationResult {
2101        output,
2102        added_count: 1,
2103        sections_added: vec!["orchestration.persistence_enabled".to_owned()],
2104    })
2105}
2106
2107/// Add commented-out `[session.recap]` block if absent (#3064).
2108///
2109/// All recap fields have `#[serde(default)]` so existing configs parse without changes.
2110///
2111/// # Errors
2112///
2113/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
2114pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2115    // Idempotency: check both active and commented forms.
2116    if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2117        return Ok(MigrationResult {
2118            output: toml_src.to_owned(),
2119            added_count: 0,
2120            sections_added: Vec::new(),
2121        });
2122    }
2123
2124    let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2125         # [session.recap]\n\
2126         # on_resume = true\n\
2127         # max_tokens = 200\n\
2128         # provider = \"\"\n\
2129         # max_input_messages = 20\n";
2130    let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2131    let output = format!("{raw}{comment}");
2132
2133    Ok(MigrationResult {
2134        output,
2135        added_count: 1,
2136        sections_added: vec!["session.recap".to_owned()],
2137    })
2138}
2139
2140/// Add commented-out MCP elicitation keys to `[mcp]` section if absent (#3141).
2141///
2142/// All elicitation fields have `#[serde(default)]` so existing configs parse without changes.
2143///
2144/// # Errors
2145///
2146/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
2147pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2148    // Idempotency: check for any elicitation key presence.
2149    if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2150        return Ok(MigrationResult {
2151            output: toml_src.to_owned(),
2152            added_count: 0,
2153            sections_added: Vec::new(),
2154        });
2155    }
2156
2157    // Only inject under an existing [mcp] section.
2158    if !toml_src.contains("[mcp]") {
2159        return Ok(MigrationResult {
2160            output: toml_src.to_owned(),
2161            added_count: 0,
2162            sections_added: Vec::new(),
2163        });
2164    }
2165
2166    // Guard against configs that have `[mcp]` but with Windows line endings or at EOF.
2167    if !toml_src.contains("[mcp]\n") {
2168        return Ok(MigrationResult {
2169            output: toml_src.to_owned(),
2170            added_count: 0,
2171            sections_added: Vec::new(),
2172        });
2173    }
2174
2175    let comment = "# elicitation_enabled = false          \
2176        # opt-in: servers may request user input mid-task (#3141)\n\
2177        # elicitation_timeout = 120            # seconds to wait for user response\n\
2178        # elicitation_queue_capacity = 16      # beyond this limit requests are auto-declined\n\
2179        # elicitation_warn_sensitive_fields = true  # warn before prompting for password/token/etc.\n";
2180    let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2181
2182    Ok(MigrationResult {
2183        output,
2184        added_count: 1,
2185        sections_added: vec!["mcp.elicitation".to_owned()],
2186    })
2187}
2188
2189/// Add a commented-out `[quality]` block if the config lacks it (#3228).
2190///
2191/// Introduced alongside the MARCH self-check pipeline (#3226). All `QualityConfig`
2192/// fields have `#[serde(default)]` so existing configs parse without changes; this
2193/// migration only surfaces the section so users can discover and enable it.
2194///
2195/// # Errors
2196///
2197/// This function is infallible in practice; the `Result` return type matches the
2198/// migration function convention for use in chained pipelines.
2199pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2200    // Idempotency: line-anchored check avoids false-positives on [quality.foo] subtables.
2201    if toml_src
2202        .lines()
2203        .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2204    {
2205        return Ok(MigrationResult {
2206            output: toml_src.to_owned(),
2207            added_count: 0,
2208            sections_added: Vec::new(),
2209        });
2210    }
2211
2212    let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2213         # [quality]\n\
2214         # self_check = false                    # enable post-response self-check\n\
2215         # trigger = \"has_retrieval\"             # has_retrieval | always | manual\n\
2216         # latency_budget_ms = 4000              # hard ceiling for the whole pipeline\n\
2217         # proposer_provider = \"\"                # optional: provider name from [[llm.providers]]\n\
2218         # checker_provider = \"\"                 # optional: provider name from [[llm.providers]]\n\
2219         # min_evidence = 0.6                    # 0.0..1.0; below → flag assertion\n\
2220         # async_run = false                     # true = fire-and-forget (non-blocking)\n\
2221         # per_call_timeout_ms = 2000            # per-LLM-call timeout\n\
2222         # max_assertions = 12                   # maximum assertions extracted from one response\n\
2223         # max_response_chars = 8000             # skip pipeline when response exceeds this\n\
2224         # cache_disabled_for_checker = true     # suppress prompt-cache on Checker provider\n\
2225         # flag_marker = \"[verify]\"              # marker appended when assertions are flagged\n";
2226    let output = format!("{toml_src}{comment}");
2227
2228    Ok(MigrationResult {
2229        output,
2230        added_count: 1,
2231        sections_added: vec!["quality".to_owned()],
2232    })
2233}
2234
2235// Helper to create a formatted value (used in tests).
2236#[cfg(test)]
2237fn make_formatted_str(s: &str) -> Value {
2238    use toml_edit::Formatted;
2239    Value::String(Formatted::new(s.to_owned()))
2240}
2241
2242#[cfg(test)]
2243mod tests {
2244    use super::*;
2245
2246    #[test]
2247    fn empty_config_gets_sections_as_comments() {
2248        let migrator = ConfigMigrator::new();
2249        let result = migrator.migrate("").expect("migrate empty");
2250        // Should have added sections since reference is non-empty.
2251        assert!(result.added_count > 0 || !result.sections_added.is_empty());
2252        // Output should mention at least agent section.
2253        assert!(
2254            result.output.contains("[agent]") || result.output.contains("# [agent]"),
2255            "expected agent section in output, got:\n{}",
2256            result.output
2257        );
2258    }
2259
2260    #[test]
2261    fn existing_values_not_overwritten() {
2262        let user = r#"
2263[agent]
2264name = "MyAgent"
2265max_tool_iterations = 5
2266"#;
2267        let migrator = ConfigMigrator::new();
2268        let result = migrator.migrate(user).expect("migrate");
2269        // Original name preserved.
2270        assert!(
2271            result.output.contains("name = \"MyAgent\""),
2272            "user value should be preserved"
2273        );
2274        assert!(
2275            result.output.contains("max_tool_iterations = 5"),
2276            "user value should be preserved"
2277        );
2278        // Should not appear as commented default.
2279        assert!(
2280            !result.output.contains("# max_tool_iterations = 10"),
2281            "already-set key should not appear as comment"
2282        );
2283    }
2284
2285    #[test]
2286    fn missing_nested_key_added_as_comment() {
2287        // User has [memory] but is missing some keys.
2288        let user = r#"
2289[memory]
2290sqlite_path = ".zeph/data/zeph.db"
2291"#;
2292        let migrator = ConfigMigrator::new();
2293        let result = migrator.migrate(user).expect("migrate");
2294        // history_limit should be added as comment since it's in reference.
2295        assert!(
2296            result.output.contains("# history_limit"),
2297            "missing key should be added as comment, got:\n{}",
2298            result.output
2299        );
2300    }
2301
2302    #[test]
2303    fn unknown_user_keys_preserved() {
2304        let user = r#"
2305[agent]
2306name = "Test"
2307my_custom_key = "preserved"
2308"#;
2309        let migrator = ConfigMigrator::new();
2310        let result = migrator.migrate(user).expect("migrate");
2311        assert!(
2312            result.output.contains("my_custom_key = \"preserved\""),
2313            "custom user keys must not be removed"
2314        );
2315    }
2316
2317    #[test]
2318    fn idempotent() {
2319        let migrator = ConfigMigrator::new();
2320        let first = migrator
2321            .migrate("[agent]\nname = \"Zeph\"\n")
2322            .expect("first migrate");
2323        let second = migrator.migrate(&first.output).expect("second migrate");
2324        assert_eq!(
2325            first.output, second.output,
2326            "idempotent: full output must be identical on second run"
2327        );
2328    }
2329
2330    #[test]
2331    fn malformed_input_returns_error() {
2332        let migrator = ConfigMigrator::new();
2333        let err = migrator
2334            .migrate("[[invalid toml [[[")
2335            .expect_err("should error");
2336        assert!(
2337            matches!(err, MigrateError::Parse(_)),
2338            "expected Parse error"
2339        );
2340    }
2341
2342    #[test]
2343    fn array_of_tables_preserved() {
2344        let user = r#"
2345[mcp]
2346allowed_commands = ["npx"]
2347
2348[[mcp.servers]]
2349id = "my-server"
2350command = "npx"
2351args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2352"#;
2353        let migrator = ConfigMigrator::new();
2354        let result = migrator.migrate(user).expect("migrate");
2355        // User's [[mcp.servers]] entry must survive.
2356        assert!(
2357            result.output.contains("[[mcp.servers]]"),
2358            "array-of-tables entries must be preserved"
2359        );
2360        assert!(result.output.contains("id = \"my-server\""));
2361    }
2362
2363    #[test]
2364    fn canonical_ordering_applied() {
2365        // Put memory before agent intentionally.
2366        let user = r#"
2367[memory]
2368sqlite_path = ".zeph/data/zeph.db"
2369
2370[agent]
2371name = "Test"
2372"#;
2373        let migrator = ConfigMigrator::new();
2374        let result = migrator.migrate(user).expect("migrate");
2375        // agent should appear before memory in canonical order.
2376        let agent_pos = result.output.find("[agent]");
2377        let memory_pos = result.output.find("[memory]");
2378        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2379            assert!(a < m, "agent section should precede memory section");
2380        }
2381    }
2382
2383    #[test]
2384    fn value_to_toml_string_formats_correctly() {
2385        use toml_edit::Formatted;
2386
2387        let s = make_formatted_str("hello");
2388        assert_eq!(value_to_toml_string(&s), "\"hello\"");
2389
2390        let i = Value::Integer(Formatted::new(42_i64));
2391        assert_eq!(value_to_toml_string(&i), "42");
2392
2393        let b = Value::Boolean(Formatted::new(true));
2394        assert_eq!(value_to_toml_string(&b), "true");
2395
2396        let f = Value::Float(Formatted::new(1.0_f64));
2397        assert_eq!(value_to_toml_string(&f), "1.0");
2398
2399        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2400        assert_eq!(value_to_toml_string(&f2), "3.14");
2401
2402        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2403        let arr_val = Value::Array(arr);
2404        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2405
2406        let empty_arr = Value::Array(Array::new());
2407        assert_eq!(value_to_toml_string(&empty_arr), "[]");
2408    }
2409
2410    #[test]
2411    fn idempotent_full_output_unchanged() {
2412        // Stronger idempotency: the entire output string must not change on a second pass.
2413        let migrator = ConfigMigrator::new();
2414        let first = migrator
2415            .migrate("[agent]\nname = \"Zeph\"\n")
2416            .expect("first migrate");
2417        let second = migrator.migrate(&first.output).expect("second migrate");
2418        assert_eq!(
2419            first.output, second.output,
2420            "full output string must be identical after second migration pass"
2421        );
2422    }
2423
2424    #[test]
2425    fn full_config_produces_zero_additions() {
2426        // Migrating the reference config itself should add nothing new.
2427        let reference = include_str!("../config/default.toml");
2428        let migrator = ConfigMigrator::new();
2429        let result = migrator.migrate(reference).expect("migrate reference");
2430        assert_eq!(
2431            result.added_count, 0,
2432            "migrating the canonical reference should add nothing (added_count = {})",
2433            result.added_count
2434        );
2435        assert!(
2436            result.sections_added.is_empty(),
2437            "migrating the canonical reference should report no sections_added: {:?}",
2438            result.sections_added
2439        );
2440    }
2441
2442    #[test]
2443    fn empty_config_added_count_is_positive() {
2444        // Stricter variant of empty_config_gets_sections_as_comments.
2445        let migrator = ConfigMigrator::new();
2446        let result = migrator.migrate("").expect("migrate empty");
2447        assert!(
2448            result.added_count > 0,
2449            "empty config must report added_count > 0"
2450        );
2451    }
2452
2453    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
2454    // for a pre-guardrail config that has [security] but no [security.guardrail].
2455    #[test]
2456    fn security_without_guardrail_gets_guardrail_commented() {
2457        let user = "[security]\nredact_secrets = true\n";
2458        let migrator = ConfigMigrator::new();
2459        let result = migrator.migrate(user).expect("migrate");
2460        // The generic diff mechanism must add guardrail keys as commented defaults.
2461        assert!(
2462            result.output.contains("guardrail"),
2463            "migration must add guardrail keys for configs without [security.guardrail]: \
2464             got:\n{}",
2465            result.output
2466        );
2467    }
2468
2469    #[test]
2470    fn migrate_reference_contains_tools_policy() {
2471        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
2472        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
2473        // `zeph --migrate-config` will surface the section to users as a discoverable commented
2474        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
2475        let reference = include_str!("../config/default.toml");
2476        assert!(
2477            reference.contains("[tools.policy]"),
2478            "default.toml must contain [tools.policy] section so migrate-config can surface it"
2479        );
2480        assert!(
2481            reference.contains("enabled = false"),
2482            "tools.policy section must include enabled = false default"
2483        );
2484    }
2485
2486    #[test]
2487    fn migrate_reference_contains_probe_section() {
2488        // default.toml must contain the probe section comment block so users can discover it
2489        // when reading the file directly or after running --migrate-config.
2490        let reference = include_str!("../config/default.toml");
2491        assert!(
2492            reference.contains("[memory.compression.probe]"),
2493            "default.toml must contain [memory.compression.probe] section comment"
2494        );
2495        assert!(
2496            reference.contains("hard_fail_threshold"),
2497            "probe section must include hard_fail_threshold default"
2498        );
2499    }
2500
2501    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
2502
2503    #[test]
2504    fn migrate_llm_no_llm_section_is_noop() {
2505        let src = "[agent]\nname = \"Zeph\"\n";
2506        let result = migrate_llm_to_providers(src).expect("migrate");
2507        assert_eq!(result.added_count, 0);
2508        assert_eq!(result.output, src);
2509    }
2510
2511    #[test]
2512    fn migrate_llm_already_new_format_is_noop() {
2513        let src = r#"
2514[llm]
2515[[llm.providers]]
2516type = "ollama"
2517model = "qwen3:8b"
2518"#;
2519        let result = migrate_llm_to_providers(src).expect("migrate");
2520        assert_eq!(result.added_count, 0);
2521    }
2522
2523    #[test]
2524    fn migrate_llm_ollama_produces_providers_block() {
2525        let src = r#"
2526[llm]
2527provider = "ollama"
2528model = "qwen3:8b"
2529base_url = "http://localhost:11434"
2530embedding_model = "nomic-embed-text"
2531"#;
2532        let result = migrate_llm_to_providers(src).expect("migrate");
2533        assert!(
2534            result.output.contains("[[llm.providers]]"),
2535            "should contain [[llm.providers]]:\n{}",
2536            result.output
2537        );
2538        assert!(
2539            result.output.contains("type = \"ollama\""),
2540            "{}",
2541            result.output
2542        );
2543        assert!(
2544            result.output.contains("model = \"qwen3:8b\""),
2545            "{}",
2546            result.output
2547        );
2548    }
2549
2550    #[test]
2551    fn migrate_llm_claude_produces_providers_block() {
2552        let src = r#"
2553[llm]
2554provider = "claude"
2555
2556[llm.cloud]
2557model = "claude-sonnet-4-6"
2558max_tokens = 8192
2559server_compaction = true
2560"#;
2561        let result = migrate_llm_to_providers(src).expect("migrate");
2562        assert!(
2563            result.output.contains("[[llm.providers]]"),
2564            "{}",
2565            result.output
2566        );
2567        assert!(
2568            result.output.contains("type = \"claude\""),
2569            "{}",
2570            result.output
2571        );
2572        assert!(
2573            result.output.contains("model = \"claude-sonnet-4-6\""),
2574            "{}",
2575            result.output
2576        );
2577        assert!(
2578            result.output.contains("server_compaction = true"),
2579            "{}",
2580            result.output
2581        );
2582    }
2583
2584    #[test]
2585    fn migrate_llm_openai_copies_fields() {
2586        let src = r#"
2587[llm]
2588provider = "openai"
2589
2590[llm.openai]
2591base_url = "https://api.openai.com/v1"
2592model = "gpt-4o"
2593max_tokens = 4096
2594"#;
2595        let result = migrate_llm_to_providers(src).expect("migrate");
2596        assert!(
2597            result.output.contains("type = \"openai\""),
2598            "{}",
2599            result.output
2600        );
2601        assert!(
2602            result
2603                .output
2604                .contains("base_url = \"https://api.openai.com/v1\""),
2605            "{}",
2606            result.output
2607        );
2608    }
2609
2610    #[test]
2611    fn migrate_llm_gemini_copies_fields() {
2612        let src = r#"
2613[llm]
2614provider = "gemini"
2615
2616[llm.gemini]
2617model = "gemini-2.0-flash"
2618max_tokens = 8192
2619base_url = "https://generativelanguage.googleapis.com"
2620"#;
2621        let result = migrate_llm_to_providers(src).expect("migrate");
2622        assert!(
2623            result.output.contains("type = \"gemini\""),
2624            "{}",
2625            result.output
2626        );
2627        assert!(
2628            result.output.contains("model = \"gemini-2.0-flash\""),
2629            "{}",
2630            result.output
2631        );
2632    }
2633
2634    #[test]
2635    fn migrate_llm_compatible_copies_multiple_entries() {
2636        let src = r#"
2637[llm]
2638provider = "compatible"
2639
2640[[llm.compatible]]
2641name = "proxy-a"
2642base_url = "http://proxy-a:8080/v1"
2643model = "llama3"
2644max_tokens = 4096
2645
2646[[llm.compatible]]
2647name = "proxy-b"
2648base_url = "http://proxy-b:8080/v1"
2649model = "mistral"
2650max_tokens = 2048
2651"#;
2652        let result = migrate_llm_to_providers(src).expect("migrate");
2653        // Both compatible entries should be emitted.
2654        let count = result.output.matches("[[llm.providers]]").count();
2655        assert_eq!(
2656            count, 2,
2657            "expected 2 [[llm.providers]] blocks:\n{}",
2658            result.output
2659        );
2660        assert!(
2661            result.output.contains("name = \"proxy-a\""),
2662            "{}",
2663            result.output
2664        );
2665        assert!(
2666            result.output.contains("name = \"proxy-b\""),
2667            "{}",
2668            result.output
2669        );
2670    }
2671
2672    #[test]
2673    fn migrate_llm_mixed_format_errors() {
2674        // Legacy + new format together should produce an error.
2675        let src = r#"
2676[llm]
2677provider = "ollama"
2678
2679[[llm.providers]]
2680type = "ollama"
2681"#;
2682        assert!(
2683            migrate_llm_to_providers(src).is_err(),
2684            "mixed format must return error"
2685        );
2686    }
2687
2688    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
2689
2690    #[test]
2691    fn stt_migration_no_stt_section_returns_unchanged() {
2692        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2693        let result = migrate_stt_to_provider(src).unwrap();
2694        assert_eq!(result.added_count, 0);
2695        assert_eq!(result.output, src);
2696    }
2697
2698    #[test]
2699    fn stt_migration_no_model_or_base_url_returns_unchanged() {
2700        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2701        let result = migrate_stt_to_provider(src).unwrap();
2702        assert_eq!(result.added_count, 0);
2703    }
2704
2705    #[test]
2706    fn stt_migration_moves_model_to_provider_entry() {
2707        let src = r#"
2708[llm]
2709
2710[[llm.providers]]
2711type = "openai"
2712name = "quality"
2713model = "gpt-5.4"
2714
2715[llm.stt]
2716provider = "quality"
2717model = "gpt-4o-mini-transcribe"
2718language = "en"
2719"#;
2720        let result = migrate_stt_to_provider(src).unwrap();
2721        assert_eq!(result.added_count, 1);
2722        // stt_model should appear in providers entry.
2723        assert!(
2724            result.output.contains("stt_model"),
2725            "stt_model must be in output"
2726        );
2727        // model should be removed from [llm.stt].
2728        // The output should parse cleanly.
2729        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2730        let stt = doc
2731            .get("llm")
2732            .and_then(toml_edit::Item::as_table)
2733            .and_then(|l| l.get("stt"))
2734            .and_then(toml_edit::Item::as_table)
2735            .unwrap();
2736        assert!(
2737            stt.get("model").is_none(),
2738            "model must be removed from [llm.stt]"
2739        );
2740        assert_eq!(
2741            stt.get("provider").and_then(toml_edit::Item::as_str),
2742            Some("quality")
2743        );
2744    }
2745
2746    #[test]
2747    fn stt_migration_creates_new_provider_when_no_match() {
2748        let src = r#"
2749[llm]
2750
2751[[llm.providers]]
2752type = "ollama"
2753name = "local"
2754model = "qwen3:8b"
2755
2756[llm.stt]
2757provider = "whisper"
2758model = "whisper-1"
2759base_url = "https://api.openai.com/v1"
2760language = "en"
2761"#;
2762        let result = migrate_stt_to_provider(src).unwrap();
2763        assert!(
2764            result.output.contains("openai-stt"),
2765            "new entry name must be openai-stt"
2766        );
2767        assert!(
2768            result.output.contains("stt_model"),
2769            "stt_model must be in output"
2770        );
2771    }
2772
2773    #[test]
2774    fn stt_migration_candle_whisper_creates_candle_entry() {
2775        let src = r#"
2776[llm]
2777
2778[llm.stt]
2779provider = "candle-whisper"
2780model = "openai/whisper-tiny"
2781language = "auto"
2782"#;
2783        let result = migrate_stt_to_provider(src).unwrap();
2784        assert!(
2785            result.output.contains("local-whisper"),
2786            "candle entry name must be local-whisper"
2787        );
2788        assert!(result.output.contains("candle"), "type must be candle");
2789    }
2790
2791    #[test]
2792    fn stt_migration_w2_assigns_explicit_name() {
2793        // Provider has no explicit name (type = "openai") — migration must assign one.
2794        let src = r#"
2795[llm]
2796
2797[[llm.providers]]
2798type = "openai"
2799model = "gpt-5.4"
2800
2801[llm.stt]
2802provider = "openai"
2803model = "whisper-1"
2804language = "auto"
2805"#;
2806        let result = migrate_stt_to_provider(src).unwrap();
2807        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2808        let providers = doc
2809            .get("llm")
2810            .and_then(toml_edit::Item::as_table)
2811            .and_then(|l| l.get("providers"))
2812            .and_then(toml_edit::Item::as_array_of_tables)
2813            .unwrap();
2814        let entry = providers
2815            .iter()
2816            .find(|t| t.get("stt_model").is_some())
2817            .unwrap();
2818        // Must have an explicit `name` field (W2).
2819        assert!(
2820            entry.get("name").is_some(),
2821            "migrated entry must have explicit name"
2822        );
2823    }
2824
2825    #[test]
2826    fn stt_migration_removes_base_url_from_stt_table() {
2827        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
2828        let src = r#"
2829[llm]
2830
2831[[llm.providers]]
2832type = "openai"
2833name = "quality"
2834model = "gpt-5.4"
2835
2836[llm.stt]
2837provider = "quality"
2838model = "whisper-1"
2839base_url = "https://api.openai.com/v1"
2840language = "en"
2841"#;
2842        let result = migrate_stt_to_provider(src).unwrap();
2843        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2844        let stt = doc
2845            .get("llm")
2846            .and_then(toml_edit::Item::as_table)
2847            .and_then(|l| l.get("stt"))
2848            .and_then(toml_edit::Item::as_table)
2849            .unwrap();
2850        assert!(
2851            stt.get("model").is_none(),
2852            "model must be removed from [llm.stt]"
2853        );
2854        assert!(
2855            stt.get("base_url").is_none(),
2856            "base_url must be removed from [llm.stt]"
2857        );
2858    }
2859
2860    #[test]
2861    fn migrate_planner_model_to_provider_with_field() {
2862        let input = r#"
2863[orchestration]
2864enabled = true
2865planner_model = "gpt-4o"
2866max_tasks = 20
2867"#;
2868        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2869        assert_eq!(result.added_count, 1, "added_count must be 1");
2870        assert!(
2871            !result.output.contains("planner_model = "),
2872            "planner_model key must be removed from output"
2873        );
2874        assert!(
2875            result.output.contains("# planner_provider"),
2876            "commented-out planner_provider entry must be present"
2877        );
2878        assert!(
2879            result.output.contains("gpt-4o"),
2880            "old value must appear in the comment"
2881        );
2882        assert!(
2883            result.output.contains("MIGRATED"),
2884            "comment must include MIGRATED marker"
2885        );
2886    }
2887
2888    #[test]
2889    fn migrate_planner_model_to_provider_no_op() {
2890        let input = r"
2891[orchestration]
2892enabled = true
2893max_tasks = 20
2894";
2895        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2896        assert_eq!(
2897            result.added_count, 0,
2898            "added_count must be 0 when field is absent"
2899        );
2900        assert_eq!(
2901            result.output, input,
2902            "output must equal input when nothing to migrate"
2903        );
2904    }
2905
2906    #[test]
2907    fn migrate_error_invalid_structure_formats_correctly() {
2908        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
2909        // produces a human-readable message. The error path is triggered when the [llm] item
2910        // is present but cannot be obtained as a mutable table (defensive guard replacing the
2911        // previous .expect() calls that would have panicked).
2912        let err = MigrateError::InvalidStructure("test sentinel");
2913        assert!(
2914            matches!(err, MigrateError::InvalidStructure(_)),
2915            "variant must match"
2916        );
2917        let msg = err.to_string();
2918        assert!(
2919            msg.contains("invalid TOML structure"),
2920            "error message must mention 'invalid TOML structure', got: {msg}"
2921        );
2922        assert!(
2923            msg.contains("test sentinel"),
2924            "message must include reason: {msg}"
2925        );
2926    }
2927
2928    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
2929
2930    #[test]
2931    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2932        let src = r#"
2933[mcp]
2934allowed_commands = ["npx"]
2935
2936[[mcp.servers]]
2937id = "srv-a"
2938command = "npx"
2939args = ["-y", "some-mcp"]
2940
2941[[mcp.servers]]
2942id = "srv-b"
2943command = "npx"
2944args = ["-y", "other-mcp"]
2945"#;
2946        let result = migrate_mcp_trust_levels(src).expect("migrate");
2947        assert_eq!(
2948            result.added_count, 2,
2949            "both entries must get trust_level added"
2950        );
2951        assert!(
2952            result
2953                .sections_added
2954                .contains(&"mcp.servers.trust_level".to_owned()),
2955            "sections_added must report mcp.servers.trust_level"
2956        );
2957        // Both entries must now contain trust_level = "trusted"
2958        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2959        assert_eq!(
2960            occurrences, 2,
2961            "each entry must have trust_level = \"trusted\""
2962        );
2963    }
2964
2965    #[test]
2966    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2967        let src = r#"
2968[[mcp.servers]]
2969id = "srv-a"
2970command = "npx"
2971trust_level = "sandboxed"
2972tool_allowlist = ["read_file"]
2973
2974[[mcp.servers]]
2975id = "srv-b"
2976command = "npx"
2977"#;
2978        let result = migrate_mcp_trust_levels(src).expect("migrate");
2979        // Only srv-b has no trust_level, so only 1 entry should be updated
2980        assert_eq!(
2981            result.added_count, 1,
2982            "only entry without trust_level gets updated"
2983        );
2984        // srv-a's sandboxed value must not be overwritten
2985        assert!(
2986            result.output.contains("trust_level = \"sandboxed\""),
2987            "existing trust_level must not be overwritten"
2988        );
2989        // srv-b gets trusted
2990        assert!(
2991            result.output.contains("trust_level = \"trusted\""),
2992            "entry without trust_level must get trusted"
2993        );
2994    }
2995
2996    #[test]
2997    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2998        let src = "[agent]\nname = \"Zeph\"\n";
2999        let result = migrate_mcp_trust_levels(src).expect("migrate");
3000        assert_eq!(result.added_count, 0);
3001        assert!(result.sections_added.is_empty());
3002        assert_eq!(result.output, src);
3003    }
3004
3005    #[test]
3006    fn migrate_mcp_trust_levels_no_servers_is_noop() {
3007        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
3008        let result = migrate_mcp_trust_levels(src).expect("migrate");
3009        assert_eq!(result.added_count, 0);
3010        assert!(result.sections_added.is_empty());
3011        assert_eq!(result.output, src);
3012    }
3013
3014    #[test]
3015    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
3016        let src = r#"
3017[[mcp.servers]]
3018id = "srv-a"
3019trust_level = "trusted"
3020
3021[[mcp.servers]]
3022id = "srv-b"
3023trust_level = "untrusted"
3024"#;
3025        let result = migrate_mcp_trust_levels(src).expect("migrate");
3026        assert_eq!(result.added_count, 0);
3027        assert!(result.sections_added.is_empty());
3028    }
3029
3030    #[test]
3031    fn migrate_database_url_adds_comment_when_absent() {
3032        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
3033        let result = migrate_database_url(src).expect("migrate");
3034        assert_eq!(result.added_count, 1);
3035        assert!(
3036            result
3037                .sections_added
3038                .contains(&"memory.database_url".to_owned())
3039        );
3040        assert!(result.output.contains("# database_url = \"\""));
3041    }
3042
3043    #[test]
3044    fn migrate_database_url_is_noop_when_present() {
3045        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
3046        let result = migrate_database_url(src).expect("migrate");
3047        assert_eq!(result.added_count, 0);
3048        assert!(result.sections_added.is_empty());
3049        assert_eq!(result.output, src);
3050    }
3051
3052    #[test]
3053    fn migrate_database_url_creates_memory_section_when_absent() {
3054        let src = "[agent]\nname = \"Zeph\"\n";
3055        let result = migrate_database_url(src).expect("migrate");
3056        assert_eq!(result.added_count, 1);
3057        assert!(result.output.contains("# database_url = \"\""));
3058    }
3059
3060    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
3061
3062    #[test]
3063    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
3064        let src = "[agent]\nname = \"Zeph\"\n";
3065        let result = migrate_agent_budget_hint(src).expect("migrate");
3066        assert_eq!(result.added_count, 1);
3067        assert!(result.output.contains("budget_hint_enabled"));
3068        assert!(
3069            result
3070                .sections_added
3071                .contains(&"agent.budget_hint_enabled".to_owned())
3072        );
3073    }
3074
3075    #[test]
3076    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
3077        let src = "[llm]\nmodel = \"gpt-4o\"\n";
3078        let result = migrate_agent_budget_hint(src).expect("migrate");
3079        assert_eq!(result.added_count, 0);
3080        assert_eq!(result.output, src);
3081    }
3082
3083    #[test]
3084    fn migrate_agent_budget_hint_already_present_is_noop() {
3085        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
3086        let result = migrate_agent_budget_hint(src).expect("migrate");
3087        assert_eq!(result.added_count, 0);
3088        assert_eq!(result.output, src);
3089    }
3090
3091    #[test]
3092    fn migrate_telemetry_config_empty_config_appends_comment_block() {
3093        let src = "[agent]\nname = \"Zeph\"\n";
3094        let result = migrate_telemetry_config(src).expect("migrate");
3095        assert_eq!(result.added_count, 1);
3096        assert_eq!(result.sections_added, vec!["telemetry"]);
3097        assert!(
3098            result.output.contains("# [telemetry]"),
3099            "expected commented-out [telemetry] block in output"
3100        );
3101        assert!(
3102            result.output.contains("enabled = false"),
3103            "expected enabled = false in telemetry comment block"
3104        );
3105    }
3106
3107    #[test]
3108    fn migrate_telemetry_config_existing_section_is_noop() {
3109        let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
3110        let result = migrate_telemetry_config(src).expect("migrate");
3111        assert_eq!(result.added_count, 0);
3112        assert_eq!(result.output, src);
3113    }
3114
3115    #[test]
3116    fn migrate_telemetry_config_existing_comment_is_noop() {
3117        // Idempotency: if the comment block was already added, don't append again.
3118        let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
3119        let result = migrate_telemetry_config(src).expect("migrate");
3120        assert_eq!(result.added_count, 0);
3121        assert_eq!(result.output, src);
3122    }
3123
3124    // ── migrate_otel_filter tests (#2997) ─────────────────────────────────────
3125
3126    #[test]
3127    fn migrate_otel_filter_already_present_is_noop() {
3128        // Real key present — must not modify.
3129        let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
3130        let result = migrate_otel_filter(src).expect("migrate");
3131        assert_eq!(result.added_count, 0);
3132        assert_eq!(result.output, src);
3133    }
3134
3135    #[test]
3136    fn migrate_otel_filter_commented_key_is_noop() {
3137        // Commented-out key already present — idempotent.
3138        let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
3139        let result = migrate_otel_filter(src).expect("migrate");
3140        assert_eq!(result.added_count, 0);
3141        assert_eq!(result.output, src);
3142    }
3143
3144    #[test]
3145    fn migrate_otel_filter_no_telemetry_section_is_noop() {
3146        // [telemetry] absent — must not inject into wrong location.
3147        let src = "[agent]\nname = \"Zeph\"\n";
3148        let result = migrate_otel_filter(src).expect("migrate");
3149        assert_eq!(result.added_count, 0);
3150        assert_eq!(result.output, src);
3151        assert!(!result.output.contains("otel_filter"));
3152    }
3153
3154    #[test]
3155    fn migrate_otel_filter_injects_within_telemetry_section() {
3156        let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
3157        let result = migrate_otel_filter(src).expect("migrate");
3158        assert_eq!(result.added_count, 1);
3159        assert_eq!(result.sections_added, vec!["telemetry.otel_filter"]);
3160        assert!(
3161            result.output.contains("otel_filter"),
3162            "otel_filter comment must appear"
3163        );
3164        // Comment must appear before [agent] — i.e., within the telemetry section.
3165        let otel_pos = result
3166            .output
3167            .find("otel_filter")
3168            .expect("otel_filter present");
3169        let agent_pos = result.output.find("[agent]").expect("[agent] present");
3170        assert!(
3171            otel_pos < agent_pos,
3172            "otel_filter comment should appear before [agent] section"
3173        );
3174    }
3175
3176    #[test]
3177    fn sandbox_migration_adds_commented_section_when_absent() {
3178        let src = "[agent]\nname = \"Z\"\n";
3179        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3180        assert_eq!(result.added_count, 1);
3181        assert!(result.output.contains("# [tools.sandbox]"));
3182        assert!(result.output.contains("# profile = \"workspace\""));
3183    }
3184
3185    #[test]
3186    fn sandbox_migration_noop_when_section_present() {
3187        let src = "[tools.sandbox]\nenabled = true\n";
3188        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3189        assert_eq!(result.added_count, 0);
3190    }
3191
3192    #[test]
3193    fn sandbox_migration_noop_when_dotted_key_present() {
3194        let src = "[tools]\nsandbox = { enabled = true }\n";
3195        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3196        assert_eq!(result.added_count, 0);
3197    }
3198
3199    #[test]
3200    fn sandbox_migration_false_positive_comment_does_not_block() {
3201        // Comments mentioning tools.sandbox must NOT suppress insertion.
3202        let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
3203        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3204        assert_eq!(result.added_count, 1);
3205    }
3206
3207    #[test]
3208    fn embedded_default_mentions_tools_sandbox() {
3209        let default_src = include_str!("../config/default.toml");
3210        assert!(
3211            default_src.contains("tools.sandbox"),
3212            "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
3213        );
3214    }
3215
3216    #[test]
3217    fn sandbox_migration_idempotent_on_own_output() {
3218        let base = "[agent]\nmodel = \"test\"\n";
3219        let first = migrate_sandbox_config(base).unwrap();
3220        assert_eq!(first.added_count, 1);
3221        let second = migrate_sandbox_config(&first.output).unwrap();
3222        assert_eq!(second.added_count, 0, "second run must not double-append");
3223        assert_eq!(second.output, first.output);
3224    }
3225
3226    #[test]
3227    fn migrate_agent_budget_hint_idempotent_on_commented_output() {
3228        let base = "[agent]\nname = \"Zeph\"\n";
3229        let first = migrate_agent_budget_hint(base).unwrap();
3230        assert_eq!(first.added_count, 1);
3231        let second = migrate_agent_budget_hint(&first.output).unwrap();
3232        assert_eq!(second.added_count, 0, "second run must not double-append");
3233        assert_eq!(second.output, first.output);
3234    }
3235
3236    #[test]
3237    fn migrate_forgetting_config_idempotent_on_commented_output() {
3238        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3239        let first = migrate_forgetting_config(base).unwrap();
3240        assert_eq!(first.added_count, 1);
3241        let second = migrate_forgetting_config(&first.output).unwrap();
3242        assert_eq!(second.added_count, 0, "second run must not double-append");
3243        assert_eq!(second.output, first.output);
3244    }
3245
3246    #[test]
3247    fn migrate_microcompact_config_idempotent_on_commented_output() {
3248        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3249        let first = migrate_microcompact_config(base).unwrap();
3250        assert_eq!(first.added_count, 1);
3251        let second = migrate_microcompact_config(&first.output).unwrap();
3252        assert_eq!(second.added_count, 0, "second run must not double-append");
3253        assert_eq!(second.output, first.output);
3254    }
3255
3256    #[test]
3257    fn migrate_autodream_config_idempotent_on_commented_output() {
3258        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3259        let first = migrate_autodream_config(base).unwrap();
3260        assert_eq!(first.added_count, 1);
3261        let second = migrate_autodream_config(&first.output).unwrap();
3262        assert_eq!(second.added_count, 0, "second run must not double-append");
3263        assert_eq!(second.output, first.output);
3264    }
3265
3266    #[test]
3267    fn migrate_compression_predictor_idempotent_on_commented_output() {
3268        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3269        let first = migrate_compression_predictor_config(base).unwrap();
3270        assert_eq!(first.added_count, 1);
3271        let second = migrate_compression_predictor_config(&first.output).unwrap();
3272        assert_eq!(second.added_count, 0, "second run must not double-append");
3273        assert_eq!(second.output, first.output);
3274    }
3275
3276    #[test]
3277    fn migrate_database_url_idempotent_on_commented_output() {
3278        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3279        let first = migrate_database_url(base).unwrap();
3280        assert_eq!(first.added_count, 1);
3281        let second = migrate_database_url(&first.output).unwrap();
3282        assert_eq!(second.added_count, 0, "second run must not double-append");
3283        assert_eq!(second.output, first.output);
3284    }
3285
3286    #[test]
3287    fn migrate_shell_transactional_idempotent_on_commented_output() {
3288        let base = "[tools]\n[tools.shell]\nallow_list = []\n";
3289        let first = migrate_shell_transactional(base).unwrap();
3290        assert_eq!(first.added_count, 1);
3291        let second = migrate_shell_transactional(&first.output).unwrap();
3292        assert_eq!(second.added_count, 0, "second run must not double-append");
3293        assert_eq!(second.output, first.output);
3294    }
3295
3296    #[test]
3297    fn migrate_otel_filter_idempotent_on_commented_output() {
3298        let base = "[telemetry]\nenabled = true\n";
3299        let first = migrate_otel_filter(base).unwrap();
3300        assert_eq!(first.added_count, 1);
3301        let second = migrate_otel_filter(&first.output).unwrap();
3302        assert_eq!(second.added_count, 0, "second run must not double-append");
3303        assert_eq!(second.output, first.output);
3304    }
3305
3306    #[test]
3307    fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
3308        let migrator = ConfigMigrator::new();
3309        let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
3310        let result = migrator.migrate(src).expect("migrate");
3311        let sec_body_start = result
3312            .output
3313            .find("[security.content_isolation]")
3314            .unwrap_or(0);
3315        let sec_body = &result.output[sec_body_start..];
3316        let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
3317        let sec_slice = &sec_body[..next_header];
3318        assert!(
3319            sec_slice.contains("# enabled"),
3320            "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
3321        );
3322    }
3323
3324    #[test]
3325    fn config_migrator_idempotent_on_realistic_config() {
3326        let base = r#"
3327[agent]
3328name = "Zeph"
3329
3330[memory]
3331db_path = "~/.zeph/memory.db"
3332soft_compaction_threshold = 0.6
3333
3334[index]
3335max_chunks = 12
3336
3337[tools]
3338[tools.shell]
3339allow_list = []
3340
3341[telemetry]
3342enabled = false
3343
3344[security]
3345[security.content_isolation]
3346enabled = true
3347"#;
3348        let migrator = ConfigMigrator::new();
3349        let first = migrator.migrate(base).expect("first migrate");
3350        let second = migrator.migrate(&first.output).expect("second migrate");
3351        assert_eq!(
3352            second.added_count, 0,
3353            "second run of ConfigMigrator::migrate must add 0 entries, got {}",
3354            second.added_count
3355        );
3356        assert_eq!(
3357            first.output, second.output,
3358            "output must be identical on second run"
3359        );
3360        for line in first.output.lines() {
3361            if line.starts_with('[') && !line.starts_with("[[") {
3362                assert!(
3363                    !line.contains('#'),
3364                    "section header must not have inline comment: {line:?}"
3365                );
3366            }
3367        }
3368    }
3369
3370    #[test]
3371    fn migrate_claude_prompt_cache_ttl_1h_survives() {
3372        let src = r#"
3373[llm]
3374provider = "claude"
3375
3376[llm.cloud]
3377model = "claude-sonnet-4-6"
3378prompt_cache_ttl = "1h"
3379"#;
3380        let result = migrate_llm_to_providers(src).expect("migrate");
3381        assert!(
3382            result.output.contains("prompt_cache_ttl = \"1h\""),
3383            "1h TTL must be preserved in migrated output:\n{}",
3384            result.output
3385        );
3386    }
3387
3388    #[test]
3389    fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
3390        let src = r#"
3391[llm]
3392provider = "claude"
3393
3394[llm.cloud]
3395model = "claude-sonnet-4-6"
3396prompt_cache_ttl = "ephemeral"
3397"#;
3398        let result = migrate_llm_to_providers(src).expect("migrate");
3399        assert!(
3400            !result.output.contains("prompt_cache_ttl"),
3401            "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
3402            result.output
3403        );
3404    }
3405
3406    #[test]
3407    fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
3408        let src = r#"
3409[[llm.providers]]
3410type = "claude"
3411model = "claude-sonnet-4-6"
3412prompt_cache_ttl = "1h"
3413"#;
3414        let migrator = ConfigMigrator::new();
3415        let first = migrator.migrate(src).expect("first migrate");
3416        let second = migrator.migrate(&first.output).expect("second migrate");
3417        assert_eq!(
3418            first.output, second.output,
3419            "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
3420        );
3421    }
3422
3423    // ── migrate_session_recap_config ──────────────────────────────────────────
3424
3425    #[test]
3426    fn migrate_session_recap_adds_block_when_absent() {
3427        let src = "[agent]\nname = \"Zeph\"\n";
3428        let result = migrate_session_recap_config(src).expect("migrate");
3429        assert_eq!(result.added_count, 1);
3430        assert!(result.sections_added.contains(&"session.recap".to_owned()));
3431        assert!(result.output.contains("# [session.recap]"));
3432        assert!(result.output.contains("on_resume = true"));
3433    }
3434
3435    #[test]
3436    fn migrate_session_recap_idempotent_on_commented_block() {
3437        let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
3438        let result = migrate_session_recap_config(src).expect("migrate");
3439        assert_eq!(result.added_count, 0);
3440        assert_eq!(result.output, src);
3441    }
3442
3443    #[test]
3444    fn migrate_session_recap_idempotent_on_active_section() {
3445        let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
3446        let result = migrate_session_recap_config(src).expect("migrate");
3447        assert_eq!(result.added_count, 0);
3448        assert_eq!(result.output, src);
3449    }
3450
3451    // ── migrate_mcp_elicitation_config ────────────────────────────────────────
3452
3453    #[test]
3454    fn migrate_mcp_elicitation_adds_keys_when_absent() {
3455        let src = "[mcp]\nallowed_commands = []\n";
3456        let result = migrate_mcp_elicitation_config(src).expect("migrate");
3457        assert_eq!(result.added_count, 1);
3458        assert!(
3459            result
3460                .sections_added
3461                .contains(&"mcp.elicitation".to_owned())
3462        );
3463        assert!(result.output.contains("# elicitation_enabled = false"));
3464        assert!(result.output.contains("# elicitation_timeout = 120"));
3465    }
3466
3467    #[test]
3468    fn migrate_mcp_elicitation_idempotent_when_key_present() {
3469        let src = "[mcp]\nelicitation_enabled = true\n";
3470        let result = migrate_mcp_elicitation_config(src).expect("migrate");
3471        assert_eq!(result.added_count, 0);
3472        assert_eq!(result.output, src);
3473    }
3474
3475    #[test]
3476    fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
3477        let src = "[agent]\nname = \"Zeph\"\n";
3478        let result = migrate_mcp_elicitation_config(src).expect("migrate");
3479        assert_eq!(result.added_count, 0);
3480        assert_eq!(result.output, src);
3481    }
3482
3483    #[test]
3484    fn migrate_mcp_elicitation_skips_without_trailing_newline() {
3485        // Edge case: `[mcp]` at EOF with no `\n` — replacen would be a no-op.
3486        let src = "[mcp]";
3487        let result = migrate_mcp_elicitation_config(src).expect("migrate");
3488        assert_eq!(result.added_count, 0);
3489        assert_eq!(result.output, src);
3490    }
3491
3492    // ── migrate_quality_config ────────────────────────────────────────────────
3493
3494    #[test]
3495    fn migrate_quality_adds_block_when_absent() {
3496        let src = "[agent]\nname = \"Zeph\"\n";
3497        let result = migrate_quality_config(src).expect("migrate");
3498        assert_eq!(result.added_count, 1);
3499        assert!(result.sections_added.contains(&"quality".to_owned()));
3500        assert!(result.output.contains("# [quality]"));
3501        assert!(result.output.contains("self_check = false"));
3502        assert!(result.output.contains("trigger = \"has_retrieval\""));
3503    }
3504
3505    #[test]
3506    fn migrate_quality_idempotent_on_commented_block() {
3507        let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
3508        let result = migrate_quality_config(src).expect("migrate");
3509        assert_eq!(result.added_count, 0);
3510        assert_eq!(result.output, src);
3511    }
3512
3513    #[test]
3514    fn migrate_quality_idempotent_on_active_section() {
3515        let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
3516        let result = migrate_quality_config(src).expect("migrate");
3517        assert_eq!(result.added_count, 0);
3518        assert_eq!(result.output, src);
3519    }
3520}