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// Helper to create a formatted value (used in tests).
2108#[cfg(test)]
2109fn make_formatted_str(s: &str) -> Value {
2110    use toml_edit::Formatted;
2111    Value::String(Formatted::new(s.to_owned()))
2112}
2113
2114#[cfg(test)]
2115mod tests {
2116    use super::*;
2117
2118    #[test]
2119    fn empty_config_gets_sections_as_comments() {
2120        let migrator = ConfigMigrator::new();
2121        let result = migrator.migrate("").expect("migrate empty");
2122        // Should have added sections since reference is non-empty.
2123        assert!(result.added_count > 0 || !result.sections_added.is_empty());
2124        // Output should mention at least agent section.
2125        assert!(
2126            result.output.contains("[agent]") || result.output.contains("# [agent]"),
2127            "expected agent section in output, got:\n{}",
2128            result.output
2129        );
2130    }
2131
2132    #[test]
2133    fn existing_values_not_overwritten() {
2134        let user = r#"
2135[agent]
2136name = "MyAgent"
2137max_tool_iterations = 5
2138"#;
2139        let migrator = ConfigMigrator::new();
2140        let result = migrator.migrate(user).expect("migrate");
2141        // Original name preserved.
2142        assert!(
2143            result.output.contains("name = \"MyAgent\""),
2144            "user value should be preserved"
2145        );
2146        assert!(
2147            result.output.contains("max_tool_iterations = 5"),
2148            "user value should be preserved"
2149        );
2150        // Should not appear as commented default.
2151        assert!(
2152            !result.output.contains("# max_tool_iterations = 10"),
2153            "already-set key should not appear as comment"
2154        );
2155    }
2156
2157    #[test]
2158    fn missing_nested_key_added_as_comment() {
2159        // User has [memory] but is missing some keys.
2160        let user = r#"
2161[memory]
2162sqlite_path = ".zeph/data/zeph.db"
2163"#;
2164        let migrator = ConfigMigrator::new();
2165        let result = migrator.migrate(user).expect("migrate");
2166        // history_limit should be added as comment since it's in reference.
2167        assert!(
2168            result.output.contains("# history_limit"),
2169            "missing key should be added as comment, got:\n{}",
2170            result.output
2171        );
2172    }
2173
2174    #[test]
2175    fn unknown_user_keys_preserved() {
2176        let user = r#"
2177[agent]
2178name = "Test"
2179my_custom_key = "preserved"
2180"#;
2181        let migrator = ConfigMigrator::new();
2182        let result = migrator.migrate(user).expect("migrate");
2183        assert!(
2184            result.output.contains("my_custom_key = \"preserved\""),
2185            "custom user keys must not be removed"
2186        );
2187    }
2188
2189    #[test]
2190    fn idempotent() {
2191        let migrator = ConfigMigrator::new();
2192        let first = migrator
2193            .migrate("[agent]\nname = \"Zeph\"\n")
2194            .expect("first migrate");
2195        let second = migrator.migrate(&first.output).expect("second migrate");
2196        assert_eq!(
2197            first.output, second.output,
2198            "idempotent: full output must be identical on second run"
2199        );
2200    }
2201
2202    #[test]
2203    fn malformed_input_returns_error() {
2204        let migrator = ConfigMigrator::new();
2205        let err = migrator
2206            .migrate("[[invalid toml [[[")
2207            .expect_err("should error");
2208        assert!(
2209            matches!(err, MigrateError::Parse(_)),
2210            "expected Parse error"
2211        );
2212    }
2213
2214    #[test]
2215    fn array_of_tables_preserved() {
2216        let user = r#"
2217[mcp]
2218allowed_commands = ["npx"]
2219
2220[[mcp.servers]]
2221id = "my-server"
2222command = "npx"
2223args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2224"#;
2225        let migrator = ConfigMigrator::new();
2226        let result = migrator.migrate(user).expect("migrate");
2227        // User's [[mcp.servers]] entry must survive.
2228        assert!(
2229            result.output.contains("[[mcp.servers]]"),
2230            "array-of-tables entries must be preserved"
2231        );
2232        assert!(result.output.contains("id = \"my-server\""));
2233    }
2234
2235    #[test]
2236    fn canonical_ordering_applied() {
2237        // Put memory before agent intentionally.
2238        let user = r#"
2239[memory]
2240sqlite_path = ".zeph/data/zeph.db"
2241
2242[agent]
2243name = "Test"
2244"#;
2245        let migrator = ConfigMigrator::new();
2246        let result = migrator.migrate(user).expect("migrate");
2247        // agent should appear before memory in canonical order.
2248        let agent_pos = result.output.find("[agent]");
2249        let memory_pos = result.output.find("[memory]");
2250        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2251            assert!(a < m, "agent section should precede memory section");
2252        }
2253    }
2254
2255    #[test]
2256    fn value_to_toml_string_formats_correctly() {
2257        use toml_edit::Formatted;
2258
2259        let s = make_formatted_str("hello");
2260        assert_eq!(value_to_toml_string(&s), "\"hello\"");
2261
2262        let i = Value::Integer(Formatted::new(42_i64));
2263        assert_eq!(value_to_toml_string(&i), "42");
2264
2265        let b = Value::Boolean(Formatted::new(true));
2266        assert_eq!(value_to_toml_string(&b), "true");
2267
2268        let f = Value::Float(Formatted::new(1.0_f64));
2269        assert_eq!(value_to_toml_string(&f), "1.0");
2270
2271        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2272        assert_eq!(value_to_toml_string(&f2), "3.14");
2273
2274        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2275        let arr_val = Value::Array(arr);
2276        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2277
2278        let empty_arr = Value::Array(Array::new());
2279        assert_eq!(value_to_toml_string(&empty_arr), "[]");
2280    }
2281
2282    #[test]
2283    fn idempotent_full_output_unchanged() {
2284        // Stronger idempotency: the entire output string must not change on a second pass.
2285        let migrator = ConfigMigrator::new();
2286        let first = migrator
2287            .migrate("[agent]\nname = \"Zeph\"\n")
2288            .expect("first migrate");
2289        let second = migrator.migrate(&first.output).expect("second migrate");
2290        assert_eq!(
2291            first.output, second.output,
2292            "full output string must be identical after second migration pass"
2293        );
2294    }
2295
2296    #[test]
2297    fn full_config_produces_zero_additions() {
2298        // Migrating the reference config itself should add nothing new.
2299        let reference = include_str!("../config/default.toml");
2300        let migrator = ConfigMigrator::new();
2301        let result = migrator.migrate(reference).expect("migrate reference");
2302        assert_eq!(
2303            result.added_count, 0,
2304            "migrating the canonical reference should add nothing (added_count = {})",
2305            result.added_count
2306        );
2307        assert!(
2308            result.sections_added.is_empty(),
2309            "migrating the canonical reference should report no sections_added: {:?}",
2310            result.sections_added
2311        );
2312    }
2313
2314    #[test]
2315    fn empty_config_added_count_is_positive() {
2316        // Stricter variant of empty_config_gets_sections_as_comments.
2317        let migrator = ConfigMigrator::new();
2318        let result = migrator.migrate("").expect("migrate empty");
2319        assert!(
2320            result.added_count > 0,
2321            "empty config must report added_count > 0"
2322        );
2323    }
2324
2325    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
2326    // for a pre-guardrail config that has [security] but no [security.guardrail].
2327    #[test]
2328    fn security_without_guardrail_gets_guardrail_commented() {
2329        let user = "[security]\nredact_secrets = true\n";
2330        let migrator = ConfigMigrator::new();
2331        let result = migrator.migrate(user).expect("migrate");
2332        // The generic diff mechanism must add guardrail keys as commented defaults.
2333        assert!(
2334            result.output.contains("guardrail"),
2335            "migration must add guardrail keys for configs without [security.guardrail]: \
2336             got:\n{}",
2337            result.output
2338        );
2339    }
2340
2341    #[test]
2342    fn migrate_reference_contains_tools_policy() {
2343        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
2344        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
2345        // `zeph --migrate-config` will surface the section to users as a discoverable commented
2346        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
2347        let reference = include_str!("../config/default.toml");
2348        assert!(
2349            reference.contains("[tools.policy]"),
2350            "default.toml must contain [tools.policy] section so migrate-config can surface it"
2351        );
2352        assert!(
2353            reference.contains("enabled = false"),
2354            "tools.policy section must include enabled = false default"
2355        );
2356    }
2357
2358    #[test]
2359    fn migrate_reference_contains_probe_section() {
2360        // default.toml must contain the probe section comment block so users can discover it
2361        // when reading the file directly or after running --migrate-config.
2362        let reference = include_str!("../config/default.toml");
2363        assert!(
2364            reference.contains("[memory.compression.probe]"),
2365            "default.toml must contain [memory.compression.probe] section comment"
2366        );
2367        assert!(
2368            reference.contains("hard_fail_threshold"),
2369            "probe section must include hard_fail_threshold default"
2370        );
2371    }
2372
2373    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
2374
2375    #[test]
2376    fn migrate_llm_no_llm_section_is_noop() {
2377        let src = "[agent]\nname = \"Zeph\"\n";
2378        let result = migrate_llm_to_providers(src).expect("migrate");
2379        assert_eq!(result.added_count, 0);
2380        assert_eq!(result.output, src);
2381    }
2382
2383    #[test]
2384    fn migrate_llm_already_new_format_is_noop() {
2385        let src = r#"
2386[llm]
2387[[llm.providers]]
2388type = "ollama"
2389model = "qwen3:8b"
2390"#;
2391        let result = migrate_llm_to_providers(src).expect("migrate");
2392        assert_eq!(result.added_count, 0);
2393    }
2394
2395    #[test]
2396    fn migrate_llm_ollama_produces_providers_block() {
2397        let src = r#"
2398[llm]
2399provider = "ollama"
2400model = "qwen3:8b"
2401base_url = "http://localhost:11434"
2402embedding_model = "nomic-embed-text"
2403"#;
2404        let result = migrate_llm_to_providers(src).expect("migrate");
2405        assert!(
2406            result.output.contains("[[llm.providers]]"),
2407            "should contain [[llm.providers]]:\n{}",
2408            result.output
2409        );
2410        assert!(
2411            result.output.contains("type = \"ollama\""),
2412            "{}",
2413            result.output
2414        );
2415        assert!(
2416            result.output.contains("model = \"qwen3:8b\""),
2417            "{}",
2418            result.output
2419        );
2420    }
2421
2422    #[test]
2423    fn migrate_llm_claude_produces_providers_block() {
2424        let src = r#"
2425[llm]
2426provider = "claude"
2427
2428[llm.cloud]
2429model = "claude-sonnet-4-6"
2430max_tokens = 8192
2431server_compaction = true
2432"#;
2433        let result = migrate_llm_to_providers(src).expect("migrate");
2434        assert!(
2435            result.output.contains("[[llm.providers]]"),
2436            "{}",
2437            result.output
2438        );
2439        assert!(
2440            result.output.contains("type = \"claude\""),
2441            "{}",
2442            result.output
2443        );
2444        assert!(
2445            result.output.contains("model = \"claude-sonnet-4-6\""),
2446            "{}",
2447            result.output
2448        );
2449        assert!(
2450            result.output.contains("server_compaction = true"),
2451            "{}",
2452            result.output
2453        );
2454    }
2455
2456    #[test]
2457    fn migrate_llm_openai_copies_fields() {
2458        let src = r#"
2459[llm]
2460provider = "openai"
2461
2462[llm.openai]
2463base_url = "https://api.openai.com/v1"
2464model = "gpt-4o"
2465max_tokens = 4096
2466"#;
2467        let result = migrate_llm_to_providers(src).expect("migrate");
2468        assert!(
2469            result.output.contains("type = \"openai\""),
2470            "{}",
2471            result.output
2472        );
2473        assert!(
2474            result
2475                .output
2476                .contains("base_url = \"https://api.openai.com/v1\""),
2477            "{}",
2478            result.output
2479        );
2480    }
2481
2482    #[test]
2483    fn migrate_llm_gemini_copies_fields() {
2484        let src = r#"
2485[llm]
2486provider = "gemini"
2487
2488[llm.gemini]
2489model = "gemini-2.0-flash"
2490max_tokens = 8192
2491base_url = "https://generativelanguage.googleapis.com"
2492"#;
2493        let result = migrate_llm_to_providers(src).expect("migrate");
2494        assert!(
2495            result.output.contains("type = \"gemini\""),
2496            "{}",
2497            result.output
2498        );
2499        assert!(
2500            result.output.contains("model = \"gemini-2.0-flash\""),
2501            "{}",
2502            result.output
2503        );
2504    }
2505
2506    #[test]
2507    fn migrate_llm_compatible_copies_multiple_entries() {
2508        let src = r#"
2509[llm]
2510provider = "compatible"
2511
2512[[llm.compatible]]
2513name = "proxy-a"
2514base_url = "http://proxy-a:8080/v1"
2515model = "llama3"
2516max_tokens = 4096
2517
2518[[llm.compatible]]
2519name = "proxy-b"
2520base_url = "http://proxy-b:8080/v1"
2521model = "mistral"
2522max_tokens = 2048
2523"#;
2524        let result = migrate_llm_to_providers(src).expect("migrate");
2525        // Both compatible entries should be emitted.
2526        let count = result.output.matches("[[llm.providers]]").count();
2527        assert_eq!(
2528            count, 2,
2529            "expected 2 [[llm.providers]] blocks:\n{}",
2530            result.output
2531        );
2532        assert!(
2533            result.output.contains("name = \"proxy-a\""),
2534            "{}",
2535            result.output
2536        );
2537        assert!(
2538            result.output.contains("name = \"proxy-b\""),
2539            "{}",
2540            result.output
2541        );
2542    }
2543
2544    #[test]
2545    fn migrate_llm_mixed_format_errors() {
2546        // Legacy + new format together should produce an error.
2547        let src = r#"
2548[llm]
2549provider = "ollama"
2550
2551[[llm.providers]]
2552type = "ollama"
2553"#;
2554        assert!(
2555            migrate_llm_to_providers(src).is_err(),
2556            "mixed format must return error"
2557        );
2558    }
2559
2560    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
2561
2562    #[test]
2563    fn stt_migration_no_stt_section_returns_unchanged() {
2564        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2565        let result = migrate_stt_to_provider(src).unwrap();
2566        assert_eq!(result.added_count, 0);
2567        assert_eq!(result.output, src);
2568    }
2569
2570    #[test]
2571    fn stt_migration_no_model_or_base_url_returns_unchanged() {
2572        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2573        let result = migrate_stt_to_provider(src).unwrap();
2574        assert_eq!(result.added_count, 0);
2575    }
2576
2577    #[test]
2578    fn stt_migration_moves_model_to_provider_entry() {
2579        let src = r#"
2580[llm]
2581
2582[[llm.providers]]
2583type = "openai"
2584name = "quality"
2585model = "gpt-5.4"
2586
2587[llm.stt]
2588provider = "quality"
2589model = "gpt-4o-mini-transcribe"
2590language = "en"
2591"#;
2592        let result = migrate_stt_to_provider(src).unwrap();
2593        assert_eq!(result.added_count, 1);
2594        // stt_model should appear in providers entry.
2595        assert!(
2596            result.output.contains("stt_model"),
2597            "stt_model must be in output"
2598        );
2599        // model should be removed from [llm.stt].
2600        // The output should parse cleanly.
2601        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2602        let stt = doc
2603            .get("llm")
2604            .and_then(toml_edit::Item::as_table)
2605            .and_then(|l| l.get("stt"))
2606            .and_then(toml_edit::Item::as_table)
2607            .unwrap();
2608        assert!(
2609            stt.get("model").is_none(),
2610            "model must be removed from [llm.stt]"
2611        );
2612        assert_eq!(
2613            stt.get("provider").and_then(toml_edit::Item::as_str),
2614            Some("quality")
2615        );
2616    }
2617
2618    #[test]
2619    fn stt_migration_creates_new_provider_when_no_match() {
2620        let src = r#"
2621[llm]
2622
2623[[llm.providers]]
2624type = "ollama"
2625name = "local"
2626model = "qwen3:8b"
2627
2628[llm.stt]
2629provider = "whisper"
2630model = "whisper-1"
2631base_url = "https://api.openai.com/v1"
2632language = "en"
2633"#;
2634        let result = migrate_stt_to_provider(src).unwrap();
2635        assert!(
2636            result.output.contains("openai-stt"),
2637            "new entry name must be openai-stt"
2638        );
2639        assert!(
2640            result.output.contains("stt_model"),
2641            "stt_model must be in output"
2642        );
2643    }
2644
2645    #[test]
2646    fn stt_migration_candle_whisper_creates_candle_entry() {
2647        let src = r#"
2648[llm]
2649
2650[llm.stt]
2651provider = "candle-whisper"
2652model = "openai/whisper-tiny"
2653language = "auto"
2654"#;
2655        let result = migrate_stt_to_provider(src).unwrap();
2656        assert!(
2657            result.output.contains("local-whisper"),
2658            "candle entry name must be local-whisper"
2659        );
2660        assert!(result.output.contains("candle"), "type must be candle");
2661    }
2662
2663    #[test]
2664    fn stt_migration_w2_assigns_explicit_name() {
2665        // Provider has no explicit name (type = "openai") — migration must assign one.
2666        let src = r#"
2667[llm]
2668
2669[[llm.providers]]
2670type = "openai"
2671model = "gpt-5.4"
2672
2673[llm.stt]
2674provider = "openai"
2675model = "whisper-1"
2676language = "auto"
2677"#;
2678        let result = migrate_stt_to_provider(src).unwrap();
2679        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2680        let providers = doc
2681            .get("llm")
2682            .and_then(toml_edit::Item::as_table)
2683            .and_then(|l| l.get("providers"))
2684            .and_then(toml_edit::Item::as_array_of_tables)
2685            .unwrap();
2686        let entry = providers
2687            .iter()
2688            .find(|t| t.get("stt_model").is_some())
2689            .unwrap();
2690        // Must have an explicit `name` field (W2).
2691        assert!(
2692            entry.get("name").is_some(),
2693            "migrated entry must have explicit name"
2694        );
2695    }
2696
2697    #[test]
2698    fn stt_migration_removes_base_url_from_stt_table() {
2699        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
2700        let src = r#"
2701[llm]
2702
2703[[llm.providers]]
2704type = "openai"
2705name = "quality"
2706model = "gpt-5.4"
2707
2708[llm.stt]
2709provider = "quality"
2710model = "whisper-1"
2711base_url = "https://api.openai.com/v1"
2712language = "en"
2713"#;
2714        let result = migrate_stt_to_provider(src).unwrap();
2715        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2716        let stt = doc
2717            .get("llm")
2718            .and_then(toml_edit::Item::as_table)
2719            .and_then(|l| l.get("stt"))
2720            .and_then(toml_edit::Item::as_table)
2721            .unwrap();
2722        assert!(
2723            stt.get("model").is_none(),
2724            "model must be removed from [llm.stt]"
2725        );
2726        assert!(
2727            stt.get("base_url").is_none(),
2728            "base_url must be removed from [llm.stt]"
2729        );
2730    }
2731
2732    #[test]
2733    fn migrate_planner_model_to_provider_with_field() {
2734        let input = r#"
2735[orchestration]
2736enabled = true
2737planner_model = "gpt-4o"
2738max_tasks = 20
2739"#;
2740        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2741        assert_eq!(result.added_count, 1, "added_count must be 1");
2742        assert!(
2743            !result.output.contains("planner_model = "),
2744            "planner_model key must be removed from output"
2745        );
2746        assert!(
2747            result.output.contains("# planner_provider"),
2748            "commented-out planner_provider entry must be present"
2749        );
2750        assert!(
2751            result.output.contains("gpt-4o"),
2752            "old value must appear in the comment"
2753        );
2754        assert!(
2755            result.output.contains("MIGRATED"),
2756            "comment must include MIGRATED marker"
2757        );
2758    }
2759
2760    #[test]
2761    fn migrate_planner_model_to_provider_no_op() {
2762        let input = r"
2763[orchestration]
2764enabled = true
2765max_tasks = 20
2766";
2767        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2768        assert_eq!(
2769            result.added_count, 0,
2770            "added_count must be 0 when field is absent"
2771        );
2772        assert_eq!(
2773            result.output, input,
2774            "output must equal input when nothing to migrate"
2775        );
2776    }
2777
2778    #[test]
2779    fn migrate_error_invalid_structure_formats_correctly() {
2780        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
2781        // produces a human-readable message. The error path is triggered when the [llm] item
2782        // is present but cannot be obtained as a mutable table (defensive guard replacing the
2783        // previous .expect() calls that would have panicked).
2784        let err = MigrateError::InvalidStructure("test sentinel");
2785        assert!(
2786            matches!(err, MigrateError::InvalidStructure(_)),
2787            "variant must match"
2788        );
2789        let msg = err.to_string();
2790        assert!(
2791            msg.contains("invalid TOML structure"),
2792            "error message must mention 'invalid TOML structure', got: {msg}"
2793        );
2794        assert!(
2795            msg.contains("test sentinel"),
2796            "message must include reason: {msg}"
2797        );
2798    }
2799
2800    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
2801
2802    #[test]
2803    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2804        let src = r#"
2805[mcp]
2806allowed_commands = ["npx"]
2807
2808[[mcp.servers]]
2809id = "srv-a"
2810command = "npx"
2811args = ["-y", "some-mcp"]
2812
2813[[mcp.servers]]
2814id = "srv-b"
2815command = "npx"
2816args = ["-y", "other-mcp"]
2817"#;
2818        let result = migrate_mcp_trust_levels(src).expect("migrate");
2819        assert_eq!(
2820            result.added_count, 2,
2821            "both entries must get trust_level added"
2822        );
2823        assert!(
2824            result
2825                .sections_added
2826                .contains(&"mcp.servers.trust_level".to_owned()),
2827            "sections_added must report mcp.servers.trust_level"
2828        );
2829        // Both entries must now contain trust_level = "trusted"
2830        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2831        assert_eq!(
2832            occurrences, 2,
2833            "each entry must have trust_level = \"trusted\""
2834        );
2835    }
2836
2837    #[test]
2838    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2839        let src = r#"
2840[[mcp.servers]]
2841id = "srv-a"
2842command = "npx"
2843trust_level = "sandboxed"
2844tool_allowlist = ["read_file"]
2845
2846[[mcp.servers]]
2847id = "srv-b"
2848command = "npx"
2849"#;
2850        let result = migrate_mcp_trust_levels(src).expect("migrate");
2851        // Only srv-b has no trust_level, so only 1 entry should be updated
2852        assert_eq!(
2853            result.added_count, 1,
2854            "only entry without trust_level gets updated"
2855        );
2856        // srv-a's sandboxed value must not be overwritten
2857        assert!(
2858            result.output.contains("trust_level = \"sandboxed\""),
2859            "existing trust_level must not be overwritten"
2860        );
2861        // srv-b gets trusted
2862        assert!(
2863            result.output.contains("trust_level = \"trusted\""),
2864            "entry without trust_level must get trusted"
2865        );
2866    }
2867
2868    #[test]
2869    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2870        let src = "[agent]\nname = \"Zeph\"\n";
2871        let result = migrate_mcp_trust_levels(src).expect("migrate");
2872        assert_eq!(result.added_count, 0);
2873        assert!(result.sections_added.is_empty());
2874        assert_eq!(result.output, src);
2875    }
2876
2877    #[test]
2878    fn migrate_mcp_trust_levels_no_servers_is_noop() {
2879        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2880        let result = migrate_mcp_trust_levels(src).expect("migrate");
2881        assert_eq!(result.added_count, 0);
2882        assert!(result.sections_added.is_empty());
2883        assert_eq!(result.output, src);
2884    }
2885
2886    #[test]
2887    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2888        let src = r#"
2889[[mcp.servers]]
2890id = "srv-a"
2891trust_level = "trusted"
2892
2893[[mcp.servers]]
2894id = "srv-b"
2895trust_level = "untrusted"
2896"#;
2897        let result = migrate_mcp_trust_levels(src).expect("migrate");
2898        assert_eq!(result.added_count, 0);
2899        assert!(result.sections_added.is_empty());
2900    }
2901
2902    #[test]
2903    fn migrate_database_url_adds_comment_when_absent() {
2904        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2905        let result = migrate_database_url(src).expect("migrate");
2906        assert_eq!(result.added_count, 1);
2907        assert!(
2908            result
2909                .sections_added
2910                .contains(&"memory.database_url".to_owned())
2911        );
2912        assert!(result.output.contains("# database_url = \"\""));
2913    }
2914
2915    #[test]
2916    fn migrate_database_url_is_noop_when_present() {
2917        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2918        let result = migrate_database_url(src).expect("migrate");
2919        assert_eq!(result.added_count, 0);
2920        assert!(result.sections_added.is_empty());
2921        assert_eq!(result.output, src);
2922    }
2923
2924    #[test]
2925    fn migrate_database_url_creates_memory_section_when_absent() {
2926        let src = "[agent]\nname = \"Zeph\"\n";
2927        let result = migrate_database_url(src).expect("migrate");
2928        assert_eq!(result.added_count, 1);
2929        assert!(result.output.contains("# database_url = \"\""));
2930    }
2931
2932    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
2933
2934    #[test]
2935    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2936        let src = "[agent]\nname = \"Zeph\"\n";
2937        let result = migrate_agent_budget_hint(src).expect("migrate");
2938        assert_eq!(result.added_count, 1);
2939        assert!(result.output.contains("budget_hint_enabled"));
2940        assert!(
2941            result
2942                .sections_added
2943                .contains(&"agent.budget_hint_enabled".to_owned())
2944        );
2945    }
2946
2947    #[test]
2948    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2949        let src = "[llm]\nmodel = \"gpt-4o\"\n";
2950        let result = migrate_agent_budget_hint(src).expect("migrate");
2951        assert_eq!(result.added_count, 0);
2952        assert_eq!(result.output, src);
2953    }
2954
2955    #[test]
2956    fn migrate_agent_budget_hint_already_present_is_noop() {
2957        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2958        let result = migrate_agent_budget_hint(src).expect("migrate");
2959        assert_eq!(result.added_count, 0);
2960        assert_eq!(result.output, src);
2961    }
2962
2963    #[test]
2964    fn migrate_telemetry_config_empty_config_appends_comment_block() {
2965        let src = "[agent]\nname = \"Zeph\"\n";
2966        let result = migrate_telemetry_config(src).expect("migrate");
2967        assert_eq!(result.added_count, 1);
2968        assert_eq!(result.sections_added, vec!["telemetry"]);
2969        assert!(
2970            result.output.contains("# [telemetry]"),
2971            "expected commented-out [telemetry] block in output"
2972        );
2973        assert!(
2974            result.output.contains("enabled = false"),
2975            "expected enabled = false in telemetry comment block"
2976        );
2977    }
2978
2979    #[test]
2980    fn migrate_telemetry_config_existing_section_is_noop() {
2981        let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
2982        let result = migrate_telemetry_config(src).expect("migrate");
2983        assert_eq!(result.added_count, 0);
2984        assert_eq!(result.output, src);
2985    }
2986
2987    #[test]
2988    fn migrate_telemetry_config_existing_comment_is_noop() {
2989        // Idempotency: if the comment block was already added, don't append again.
2990        let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
2991        let result = migrate_telemetry_config(src).expect("migrate");
2992        assert_eq!(result.added_count, 0);
2993        assert_eq!(result.output, src);
2994    }
2995
2996    // ── migrate_otel_filter tests (#2997) ─────────────────────────────────────
2997
2998    #[test]
2999    fn migrate_otel_filter_already_present_is_noop() {
3000        // Real key present — must not modify.
3001        let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
3002        let result = migrate_otel_filter(src).expect("migrate");
3003        assert_eq!(result.added_count, 0);
3004        assert_eq!(result.output, src);
3005    }
3006
3007    #[test]
3008    fn migrate_otel_filter_commented_key_is_noop() {
3009        // Commented-out key already present — idempotent.
3010        let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
3011        let result = migrate_otel_filter(src).expect("migrate");
3012        assert_eq!(result.added_count, 0);
3013        assert_eq!(result.output, src);
3014    }
3015
3016    #[test]
3017    fn migrate_otel_filter_no_telemetry_section_is_noop() {
3018        // [telemetry] absent — must not inject into wrong location.
3019        let src = "[agent]\nname = \"Zeph\"\n";
3020        let result = migrate_otel_filter(src).expect("migrate");
3021        assert_eq!(result.added_count, 0);
3022        assert_eq!(result.output, src);
3023        assert!(!result.output.contains("otel_filter"));
3024    }
3025
3026    #[test]
3027    fn migrate_otel_filter_injects_within_telemetry_section() {
3028        let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
3029        let result = migrate_otel_filter(src).expect("migrate");
3030        assert_eq!(result.added_count, 1);
3031        assert_eq!(result.sections_added, vec!["telemetry.otel_filter"]);
3032        assert!(
3033            result.output.contains("otel_filter"),
3034            "otel_filter comment must appear"
3035        );
3036        // Comment must appear before [agent] — i.e., within the telemetry section.
3037        let otel_pos = result
3038            .output
3039            .find("otel_filter")
3040            .expect("otel_filter present");
3041        let agent_pos = result.output.find("[agent]").expect("[agent] present");
3042        assert!(
3043            otel_pos < agent_pos,
3044            "otel_filter comment should appear before [agent] section"
3045        );
3046    }
3047
3048    #[test]
3049    fn sandbox_migration_adds_commented_section_when_absent() {
3050        let src = "[agent]\nname = \"Z\"\n";
3051        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3052        assert_eq!(result.added_count, 1);
3053        assert!(result.output.contains("# [tools.sandbox]"));
3054        assert!(result.output.contains("# profile = \"workspace\""));
3055    }
3056
3057    #[test]
3058    fn sandbox_migration_noop_when_section_present() {
3059        let src = "[tools.sandbox]\nenabled = true\n";
3060        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3061        assert_eq!(result.added_count, 0);
3062    }
3063
3064    #[test]
3065    fn sandbox_migration_noop_when_dotted_key_present() {
3066        let src = "[tools]\nsandbox = { enabled = true }\n";
3067        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3068        assert_eq!(result.added_count, 0);
3069    }
3070
3071    #[test]
3072    fn sandbox_migration_false_positive_comment_does_not_block() {
3073        // Comments mentioning tools.sandbox must NOT suppress insertion.
3074        let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
3075        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3076        assert_eq!(result.added_count, 1);
3077    }
3078
3079    #[test]
3080    fn embedded_default_mentions_tools_sandbox() {
3081        let default_src = include_str!("../config/default.toml");
3082        assert!(
3083            default_src.contains("tools.sandbox"),
3084            "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
3085        );
3086    }
3087
3088    #[test]
3089    fn sandbox_migration_idempotent_on_own_output() {
3090        let base = "[agent]\nmodel = \"test\"\n";
3091        let first = migrate_sandbox_config(base).unwrap();
3092        assert_eq!(first.added_count, 1);
3093        let second = migrate_sandbox_config(&first.output).unwrap();
3094        assert_eq!(second.added_count, 0, "second run must not double-append");
3095        assert_eq!(second.output, first.output);
3096    }
3097
3098    #[test]
3099    fn migrate_agent_budget_hint_idempotent_on_commented_output() {
3100        let base = "[agent]\nname = \"Zeph\"\n";
3101        let first = migrate_agent_budget_hint(base).unwrap();
3102        assert_eq!(first.added_count, 1);
3103        let second = migrate_agent_budget_hint(&first.output).unwrap();
3104        assert_eq!(second.added_count, 0, "second run must not double-append");
3105        assert_eq!(second.output, first.output);
3106    }
3107
3108    #[test]
3109    fn migrate_forgetting_config_idempotent_on_commented_output() {
3110        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3111        let first = migrate_forgetting_config(base).unwrap();
3112        assert_eq!(first.added_count, 1);
3113        let second = migrate_forgetting_config(&first.output).unwrap();
3114        assert_eq!(second.added_count, 0, "second run must not double-append");
3115        assert_eq!(second.output, first.output);
3116    }
3117
3118    #[test]
3119    fn migrate_microcompact_config_idempotent_on_commented_output() {
3120        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3121        let first = migrate_microcompact_config(base).unwrap();
3122        assert_eq!(first.added_count, 1);
3123        let second = migrate_microcompact_config(&first.output).unwrap();
3124        assert_eq!(second.added_count, 0, "second run must not double-append");
3125        assert_eq!(second.output, first.output);
3126    }
3127
3128    #[test]
3129    fn migrate_autodream_config_idempotent_on_commented_output() {
3130        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3131        let first = migrate_autodream_config(base).unwrap();
3132        assert_eq!(first.added_count, 1);
3133        let second = migrate_autodream_config(&first.output).unwrap();
3134        assert_eq!(second.added_count, 0, "second run must not double-append");
3135        assert_eq!(second.output, first.output);
3136    }
3137
3138    #[test]
3139    fn migrate_compression_predictor_idempotent_on_commented_output() {
3140        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3141        let first = migrate_compression_predictor_config(base).unwrap();
3142        assert_eq!(first.added_count, 1);
3143        let second = migrate_compression_predictor_config(&first.output).unwrap();
3144        assert_eq!(second.added_count, 0, "second run must not double-append");
3145        assert_eq!(second.output, first.output);
3146    }
3147
3148    #[test]
3149    fn migrate_database_url_idempotent_on_commented_output() {
3150        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3151        let first = migrate_database_url(base).unwrap();
3152        assert_eq!(first.added_count, 1);
3153        let second = migrate_database_url(&first.output).unwrap();
3154        assert_eq!(second.added_count, 0, "second run must not double-append");
3155        assert_eq!(second.output, first.output);
3156    }
3157
3158    #[test]
3159    fn migrate_shell_transactional_idempotent_on_commented_output() {
3160        let base = "[tools]\n[tools.shell]\nallow_list = []\n";
3161        let first = migrate_shell_transactional(base).unwrap();
3162        assert_eq!(first.added_count, 1);
3163        let second = migrate_shell_transactional(&first.output).unwrap();
3164        assert_eq!(second.added_count, 0, "second run must not double-append");
3165        assert_eq!(second.output, first.output);
3166    }
3167
3168    #[test]
3169    fn migrate_otel_filter_idempotent_on_commented_output() {
3170        let base = "[telemetry]\nenabled = true\n";
3171        let first = migrate_otel_filter(base).unwrap();
3172        assert_eq!(first.added_count, 1);
3173        let second = migrate_otel_filter(&first.output).unwrap();
3174        assert_eq!(second.added_count, 0, "second run must not double-append");
3175        assert_eq!(second.output, first.output);
3176    }
3177
3178    #[test]
3179    fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
3180        let migrator = ConfigMigrator::new();
3181        let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
3182        let result = migrator.migrate(src).expect("migrate");
3183        let sec_body_start = result
3184            .output
3185            .find("[security.content_isolation]")
3186            .unwrap_or(0);
3187        let sec_body = &result.output[sec_body_start..];
3188        let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
3189        let sec_slice = &sec_body[..next_header];
3190        assert!(
3191            sec_slice.contains("# enabled"),
3192            "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
3193        );
3194    }
3195
3196    #[test]
3197    fn config_migrator_idempotent_on_realistic_config() {
3198        let base = r#"
3199[agent]
3200name = "Zeph"
3201
3202[memory]
3203db_path = "~/.zeph/memory.db"
3204soft_compaction_threshold = 0.6
3205
3206[index]
3207max_chunks = 12
3208
3209[tools]
3210[tools.shell]
3211allow_list = []
3212
3213[telemetry]
3214enabled = false
3215
3216[security]
3217[security.content_isolation]
3218enabled = true
3219"#;
3220        let migrator = ConfigMigrator::new();
3221        let first = migrator.migrate(base).expect("first migrate");
3222        let second = migrator.migrate(&first.output).expect("second migrate");
3223        assert_eq!(
3224            second.added_count, 0,
3225            "second run of ConfigMigrator::migrate must add 0 entries, got {}",
3226            second.added_count
3227        );
3228        assert_eq!(
3229            first.output, second.output,
3230            "output must be identical on second run"
3231        );
3232        for line in first.output.lines() {
3233            if line.starts_with('[') && !line.starts_with("[[") {
3234                assert!(
3235                    !line.contains('#'),
3236                    "section header must not have inline comment: {line:?}"
3237                );
3238            }
3239        }
3240    }
3241
3242    #[test]
3243    fn migrate_claude_prompt_cache_ttl_1h_survives() {
3244        let src = r#"
3245[llm]
3246provider = "claude"
3247
3248[llm.cloud]
3249model = "claude-sonnet-4-6"
3250prompt_cache_ttl = "1h"
3251"#;
3252        let result = migrate_llm_to_providers(src).expect("migrate");
3253        assert!(
3254            result.output.contains("prompt_cache_ttl = \"1h\""),
3255            "1h TTL must be preserved in migrated output:\n{}",
3256            result.output
3257        );
3258    }
3259
3260    #[test]
3261    fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
3262        let src = r#"
3263[llm]
3264provider = "claude"
3265
3266[llm.cloud]
3267model = "claude-sonnet-4-6"
3268prompt_cache_ttl = "ephemeral"
3269"#;
3270        let result = migrate_llm_to_providers(src).expect("migrate");
3271        assert!(
3272            !result.output.contains("prompt_cache_ttl"),
3273            "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
3274            result.output
3275        );
3276    }
3277
3278    #[test]
3279    fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
3280        let src = r#"
3281[[llm.providers]]
3282type = "claude"
3283model = "claude-sonnet-4-6"
3284prompt_cache_ttl = "1h"
3285"#;
3286        let migrator = ConfigMigrator::new();
3287        let first = migrator.migrate(src).expect("first migrate");
3288        let second = migrator.migrate(&first.output).expect("second migrate");
3289        assert_eq!(
3290            first.output, second.output,
3291            "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
3292        );
3293    }
3294}