Skip to main content

zeph_config/migrate/
mod.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    "notifications",
39    "tui",
40    "agents",
41    "experiments",
42    "lsp",
43    "telemetry",
44    "session",
45];
46
47/// Error type for migration failures.
48#[derive(Debug, thiserror::Error)]
49pub enum MigrateError {
50    /// Failed to parse the user's config.
51    #[error("failed to parse input config: {0}")]
52    Parse(#[from] toml_edit::TomlError),
53    /// Failed to parse the embedded reference config (should never happen in practice).
54    #[error("failed to parse reference config: {0}")]
55    Reference(toml_edit::TomlError),
56    /// The document structure is inconsistent (e.g. `[llm.stt].model` exists but `[llm]` table
57    /// cannot be obtained as a mutable table — can happen when `[llm]` is absent or not a table).
58    #[error("migration failed: invalid TOML structure — {0}")]
59    InvalidStructure(&'static str),
60}
61
62/// Result of a migration operation.
63#[derive(Debug)]
64pub struct MigrationResult {
65    /// The migrated TOML document as a string.
66    pub output: String,
67    /// Number of top-level keys or sub-keys modified (added or removed) during migration.
68    pub changed_count: usize,
69    /// Names of top-level sections that were modified (added or removed).
70    pub sections_changed: Vec<String>,
71}
72
73/// Migrates a user config by adding missing parameters as commented-out entries.
74///
75/// The canonical reference is embedded from `config/default.toml` at compile time.
76/// User values are never modified; only missing keys are appended as comments.
77pub struct ConfigMigrator {
78    reference_src: &'static str,
79}
80
81impl Default for ConfigMigrator {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl ConfigMigrator {
88    /// Create a new migrator using the embedded canonical reference config.
89    #[must_use]
90    pub fn new() -> Self {
91        Self {
92            reference_src: include_str!("../../config/default.toml"),
93        }
94    }
95
96    /// Migrate `user_toml`: add missing parameters from the reference as commented-out entries.
97    ///
98    /// # Errors
99    ///
100    /// Returns `MigrateError::Parse` if the user's TOML is invalid.
101    /// Returns `MigrateError::Reference` if the embedded reference TOML cannot be parsed.
102    ///
103    /// # Panics
104    ///
105    /// Never panics in practice; `.expect("checked")` is unreachable because `is_table()` is
106    /// verified on the same `ref_item` immediately before calling `as_table()`.
107    pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
108        let reference_doc = self
109            .reference_src
110            .parse::<DocumentMut>()
111            .map_err(MigrateError::Reference)?;
112        let mut user_doc = user_toml.parse::<DocumentMut>()?;
113
114        let mut changed_count = 0usize;
115        let mut sections_changed: Vec<String> = Vec::new();
116        // Collected scalar/sub-table comment lines to insert after rendering.
117        // Each entry: (section_key, comment_line).
118        let mut pending_comments: Vec<(String, String)> = Vec::new();
119
120        // Walk the reference top-level keys.
121        for (key, ref_item) in reference_doc.as_table() {
122            if ref_item.is_table() {
123                let ref_table = ref_item.as_table().expect("is_table checked above");
124                if user_doc.contains_key(key) {
125                    // Section exists — merge missing sub-keys.
126                    if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
127                        let (n, comments) =
128                            merge_table_commented(user_table, ref_table, key, user_toml);
129                        changed_count += n;
130                        pending_comments.extend(comments);
131                    }
132                } else {
133                    // Entire section is missing — record for textual append after rendering.
134                    // Idempotency: skip if a commented block for this section was already appended.
135                    if user_toml.contains(&format!("# [{key}]")) {
136                        continue;
137                    }
138                    let commented = commented_table_block(key, ref_table);
139                    if !commented.is_empty() {
140                        sections_changed.push(key.to_owned());
141                    }
142                    changed_count += 1;
143                }
144            } else {
145                // Top-level scalar/array key.
146                if !user_doc.contains_key(key) {
147                    let raw = format_commented_item(key, ref_item);
148                    if !raw.is_empty() {
149                        sections_changed.push(format!("__scalar__{key}"));
150                        changed_count += 1;
151                    }
152                }
153            }
154        }
155
156        // Render the user doc as-is first.
157        let user_str = user_doc.to_string();
158
159        // Insert collected scalar/sub-table comment lines via raw text operations.
160        // This avoids toml_edit decor roundtrip loss — guards check the rendered string.
161        let mut output = user_str;
162        for (section_key, comment_line) in &pending_comments {
163            if !section_body(&output, section_key).contains(comment_line.trim()) {
164                output = insert_after_section(&output, section_key, comment_line);
165            }
166        }
167
168        // Append missing sections as raw commented text at the end.
169        for key in &sections_changed {
170            if let Some(scalar_key) = key.strip_prefix("__scalar__") {
171                if let Some(ref_item) = reference_doc.get(scalar_key) {
172                    let raw = format_commented_item(scalar_key, ref_item);
173                    if !raw.is_empty() {
174                        output.push('\n');
175                        output.push_str(&raw);
176                        output.push('\n');
177                    }
178                }
179            } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
180            {
181                let block = commented_table_block(key, ref_table);
182                if !block.is_empty() {
183                    output.push('\n');
184                    output.push_str(&block);
185                }
186            }
187        }
188
189        // Reorder top-level sections by canonical order.
190        output = reorder_sections(&output, CANONICAL_ORDER);
191
192        // Resolve sections_changed to only real section names (not scalars).
193        let sections_changed_clean: Vec<String> = sections_changed
194            .into_iter()
195            .filter(|k| !k.starts_with("__scalar__"))
196            .collect();
197
198        Ok(MigrationResult {
199            output,
200            changed_count,
201            sections_changed: sections_changed_clean,
202        })
203    }
204}
205
206/// Merge missing keys from `ref_table` into `user_table` as commented-out entries.
207///
208/// Returns `(count, comment_lines)` where `comment_lines` is a list of
209/// `(section_key, comment_line)` pairs to be inserted into the rendered output.
210/// Using raw-string insertion avoids `toml_edit` decor roundtrip loss.
211fn merge_table_commented(
212    user_table: &mut Table,
213    ref_table: &Table,
214    section_key: &str,
215    user_toml: &str,
216) -> (usize, Vec<(String, String)>) {
217    let mut count = 0usize;
218    let mut comments: Vec<(String, String)> = Vec::new();
219    for (key, ref_item) in ref_table {
220        if ref_item.is_table() {
221            if user_table.contains_key(key) {
222                let pair = (
223                    user_table.get_mut(key).and_then(Item::as_table_mut),
224                    ref_item.as_table(),
225                );
226                if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
227                    let sub_key = format!("{section_key}.{key}");
228                    let (n, c) =
229                        merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
230                    count += n;
231                    comments.extend(c);
232                }
233            } else if let Some(ref_sub_table) = ref_item.as_table() {
234                // Sub-table missing from user config — collect as raw commented block.
235                let dotted = format!("{section_key}.{key}");
236                let marker = format!("# [{dotted}]");
237                if !user_toml.contains(&marker) {
238                    let block = commented_table_block(&dotted, ref_sub_table);
239                    if !block.is_empty() {
240                        comments.push((section_key.to_owned(), format!("\n{block}")));
241                        count += 1;
242                    }
243                }
244            }
245        } else if ref_item.is_array_of_tables() {
246            // Never inject array-of-tables entries — they are user-defined.
247        } else {
248            // Scalar/array value — check if already present (as value or as comment).
249            if !user_table.contains_key(key) {
250                let raw_value = ref_item
251                    .as_value()
252                    .map(value_to_toml_string)
253                    .unwrap_or_default();
254                if !raw_value.is_empty() {
255                    let comment_line = format!("# {key} = {raw_value}\n");
256                    // Scope the guard to the target section body so that an identical key
257                    // name in another section does not suppress this insertion.
258                    if !section_body(user_toml, section_key).contains(comment_line.trim()) {
259                        comments.push((section_key.to_owned(), comment_line));
260                        count += 1;
261                    }
262                }
263            }
264        }
265    }
266    (count, comments)
267}
268
269/// Return the body of `[section]` in `doc` — the text between the section header line
270/// and the next top-level `[...]` header (or end of document).
271///
272/// Used to scope idempotency guards to a single section so that a comment present in
273/// one section does not suppress insertion into a different section with the same key name.
274fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
275    let header = format!("[{section}]");
276    let Some(section_start) = doc.find(&header) else {
277        return "";
278    };
279    let body_start = section_start + header.len();
280    let body_end = doc[body_start..]
281        .find("\n[")
282        .map_or(doc.len(), |r| body_start + r);
283    &doc[body_start..body_end]
284}
285
286/// Insert `text` after the last line belonging to `[section_name]` and before the next
287/// top-level `[section]` header (or at the end of the file if no such header follows).
288///
289/// This is a purely textual operation: it does not parse TOML, making it immune to
290/// `toml_edit` decor round-trip loss.
291fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
292    let header = format!("[{section_name}]");
293    let Some(section_start) = raw.find(&header) else {
294        return format!("{raw}{text}");
295    };
296    // Find the next top-level section `[...]` after `section_start`.
297    let search_from = section_start + header.len();
298    // Look for `\n[` which signals a new top-level section.
299    let insert_pos = raw[search_from..]
300        .find("\n[")
301        .map_or(raw.len(), |rel| search_from + rel + 1);
302    let mut out = String::with_capacity(raw.len() + text.len());
303    out.push_str(&raw[..insert_pos]);
304    out.push_str(text);
305    out.push_str(&raw[insert_pos..]);
306    out
307}
308
309/// Format a reference item as a commented TOML line: `# key = value`.
310fn format_commented_item(key: &str, item: &Item) -> String {
311    if let Some(val) = item.as_value() {
312        let raw = value_to_toml_string(val);
313        if !raw.is_empty() {
314            return format!("# {key} = {raw}\n");
315        }
316    }
317    String::new()
318}
319
320/// Render a table as a commented-out TOML block with arbitrary nesting depth.
321///
322/// `section_name` is the full dotted path (e.g. `security.content_isolation`).
323/// Returns an empty string if the table has no renderable content.
324fn commented_table_block(section_name: &str, table: &Table) -> String {
325    use std::fmt::Write as _;
326
327    let mut lines = format!("# [{section_name}]\n");
328
329    for (key, item) in table {
330        if item.is_table() {
331            if let Some(sub_table) = item.as_table() {
332                let sub_name = format!("{section_name}.{key}");
333                let sub_block = commented_table_block(&sub_name, sub_table);
334                if !sub_block.is_empty() {
335                    lines.push('\n');
336                    lines.push_str(&sub_block);
337                }
338            }
339        } else if item.is_array_of_tables() {
340            // Skip — user configures these manually (e.g. `[[mcp.servers]]`).
341        } else if let Some(val) = item.as_value() {
342            let raw = value_to_toml_string(val);
343            if !raw.is_empty() {
344                let _ = writeln!(lines, "# {key} = {raw}");
345            }
346        }
347    }
348
349    // Return empty if we only wrote the section header with no content.
350    if lines.trim() == format!("[{section_name}]") {
351        return String::new();
352    }
353    lines
354}
355
356/// Convert a `toml_edit::Value` to its TOML string representation.
357fn value_to_toml_string(val: &Value) -> String {
358    match val {
359        Value::String(s) => {
360            let inner = s.value();
361            format!("\"{inner}\"")
362        }
363        Value::Integer(i) => i.value().to_string(),
364        Value::Float(f) => {
365            let v = f.value();
366            // Use representation that round-trips exactly.
367            if v.fract() == 0.0 {
368                format!("{v:.1}")
369            } else {
370                format!("{v}")
371            }
372        }
373        Value::Boolean(b) => b.value().to_string(),
374        Value::Array(arr) => format_array(arr),
375        Value::InlineTable(t) => {
376            let pairs: Vec<String> = t
377                .iter()
378                .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
379                .collect();
380            format!("{{ {} }}", pairs.join(", "))
381        }
382        Value::Datetime(dt) => dt.value().to_string(),
383    }
384}
385
386fn format_array(arr: &Array) -> String {
387    if arr.is_empty() {
388        return "[]".to_owned();
389    }
390    let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
391    format!("[{}]", items.join(", "))
392}
393
394/// Reorder top-level sections of a TOML document string by the canonical order.
395///
396/// Sections not in the canonical list are placed at the end, preserving their relative order.
397/// This operates on the raw string rather than the parsed document to preserve comments that
398/// would otherwise be dropped by `toml_edit`'s round-trip.
399fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
400    let sections = split_into_sections(toml_str);
401    if sections.is_empty() {
402        return toml_str.to_owned();
403    }
404
405    // Each entry is (header, content). Empty header = preamble block.
406    let preamble_block = sections
407        .iter()
408        .find(|(h, _)| h.is_empty())
409        .map_or("", |(_, c)| c.as_str());
410
411    let section_map: Vec<(&str, &str)> = sections
412        .iter()
413        .filter(|(h, _)| !h.is_empty())
414        .map(|(h, c)| (h.as_str(), c.as_str()))
415        .collect();
416
417    let mut out = String::new();
418    if !preamble_block.is_empty() {
419        out.push_str(preamble_block);
420    }
421
422    let mut emitted: Vec<bool> = vec![false; section_map.len()];
423
424    for &canon in canonical_order {
425        for (idx, &(header, content)) in section_map.iter().enumerate() {
426            let section_name = extract_section_name(header);
427            let top_level = section_name
428                .split('.')
429                .next()
430                .unwrap_or("")
431                .trim_start_matches('#')
432                .trim();
433            if top_level == canon && !emitted[idx] {
434                out.push_str(content);
435                emitted[idx] = true;
436            }
437        }
438    }
439
440    // Append sections not in canonical order.
441    for (idx, &(_, content)) in section_map.iter().enumerate() {
442        if !emitted[idx] {
443            out.push_str(content);
444        }
445    }
446
447    out
448}
449
450/// Extract the section name from a section header line (e.g. `[agent]` → `agent`).
451fn extract_section_name(header: &str) -> &str {
452    // Strip leading `# ` for commented headers.
453    let trimmed = header.trim().trim_start_matches("# ");
454    // Strip `[` and `]`.
455    if trimmed.starts_with('[') && trimmed.contains(']') {
456        let inner = &trimmed[1..];
457        if let Some(end) = inner.find(']') {
458            return &inner[..end];
459        }
460    }
461    trimmed
462}
463
464/// Split a TOML string into `(header_line, full_block)` pairs.
465///
466/// The first element may have an empty header representing the preamble.
467fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
468    let mut sections: Vec<(String, String)> = Vec::new();
469    let mut current_header = String::new();
470    let mut current_content = String::new();
471
472    for line in toml_str.lines() {
473        let trimmed = line.trim();
474        if is_top_level_section_header(trimmed) {
475            sections.push((current_header.clone(), current_content.clone()));
476            trimmed.clone_into(&mut current_header);
477            line.clone_into(&mut current_content);
478            current_content.push('\n');
479        } else {
480            current_content.push_str(line);
481            current_content.push('\n');
482        }
483    }
484
485    // Push the last section.
486    if !current_header.is_empty() || !current_content.is_empty() {
487        sections.push((current_header, current_content));
488    }
489
490    sections
491}
492
493/// Determine if a line is a real (non-commented) top-level section header.
494///
495/// Top-level means `[name]` with no dots. Commented headers like `# [name]`
496/// are NOT treated as section boundaries — they are migrator-generated hints.
497fn is_top_level_section_header(line: &str) -> bool {
498    if line.starts_with('[')
499        && !line.starts_with("[[")
500        && let Some(end) = line.find(']')
501    {
502        return !line[1..end].contains('.');
503    }
504    false
505}
506
507#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
508fn migrate_ollama_provider(
509    llm: &toml_edit::Table,
510    model: &Option<String>,
511    base_url: &Option<String>,
512    embedding_model: &Option<String>,
513) -> Vec<String> {
514    let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
515    if let Some(m) = model {
516        block.push_str(&format!("model = \"{m}\"\n"));
517    }
518    if let Some(em) = embedding_model {
519        block.push_str(&format!("embedding_model = \"{em}\"\n"));
520    }
521    if let Some(u) = base_url {
522        block.push_str(&format!("base_url = \"{u}\"\n"));
523    }
524    let _ = llm; // not needed for simple ollama case
525    vec![block]
526}
527
528#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
529fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
530    let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
531    if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
532        if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
533            block.push_str(&format!("model = \"{m}\"\n"));
534        }
535        if let Some(t) = cloud
536            .get("max_tokens")
537            .and_then(toml_edit::Item::as_integer)
538        {
539            block.push_str(&format!("max_tokens = {t}\n"));
540        }
541        if cloud
542            .get("server_compaction")
543            .and_then(toml_edit::Item::as_bool)
544            == Some(true)
545        {
546            block.push_str("server_compaction = true\n");
547        }
548        if cloud
549            .get("enable_extended_context")
550            .and_then(toml_edit::Item::as_bool)
551            == Some(true)
552        {
553            block.push_str("enable_extended_context = true\n");
554        }
555        if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
556            let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
557            block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
558        }
559        if let Some(v) = cloud
560            .get("prompt_cache_ttl")
561            .and_then(toml_edit::Item::as_str)
562        {
563            if v != "ephemeral" {
564                block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
565            }
566        }
567    } else if let Some(m) = model {
568        block.push_str(&format!("model = \"{m}\"\n"));
569    }
570    vec![block]
571}
572
573#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
574fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
575    let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
576    if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
577        copy_str_field(openai, "model", &mut block);
578        copy_str_field(openai, "base_url", &mut block);
579        copy_int_field(openai, "max_tokens", &mut block);
580        copy_str_field(openai, "embedding_model", &mut block);
581        copy_str_field(openai, "reasoning_effort", &mut block);
582    } else if let Some(m) = model {
583        block.push_str(&format!("model = \"{m}\"\n"));
584    }
585    vec![block]
586}
587
588#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
589fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
590    let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
591    if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
592        copy_str_field(gemini, "model", &mut block);
593        copy_int_field(gemini, "max_tokens", &mut block);
594        copy_str_field(gemini, "base_url", &mut block);
595        copy_str_field(gemini, "embedding_model", &mut block);
596        copy_str_field(gemini, "thinking_level", &mut block);
597        copy_int_field(gemini, "thinking_budget", &mut block);
598        if let Some(v) = gemini
599            .get("include_thoughts")
600            .and_then(toml_edit::Item::as_bool)
601        {
602            block.push_str(&format!("include_thoughts = {v}\n"));
603        }
604    } else if let Some(m) = model {
605        block.push_str(&format!("model = \"{m}\"\n"));
606    }
607    vec![block]
608}
609
610#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
611fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
612    let mut blocks = Vec::new();
613    if let Some(compat_arr) = llm
614        .get("compatible")
615        .and_then(toml_edit::Item::as_array_of_tables)
616    {
617        for entry in compat_arr {
618            let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
619            copy_str_field(entry, "name", &mut block);
620            copy_str_field(entry, "base_url", &mut block);
621            copy_str_field(entry, "model", &mut block);
622            copy_int_field(entry, "max_tokens", &mut block);
623            copy_str_field(entry, "embedding_model", &mut block);
624            blocks.push(block);
625        }
626    }
627    blocks
628}
629
630// Returns (provider_blocks, routing)
631#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
632fn migrate_orchestrator_provider(
633    llm: &toml_edit::Table,
634    model: &Option<String>,
635    base_url: &Option<String>,
636    embedding_model: &Option<String>,
637) -> (Vec<String>, Option<String>) {
638    let mut blocks = Vec::new();
639    let routing = 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(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
652            for (name, pcfg_item) in providers {
653                let Some(pcfg) = pcfg_item.as_table() else {
654                    continue;
655                };
656                let ptype = pcfg
657                    .get("type")
658                    .and_then(toml_edit::Item::as_str)
659                    .unwrap_or("ollama");
660                let mut block =
661                    format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
662                if name == default_name {
663                    block.push_str("default = true\n");
664                }
665                if name == embed_name {
666                    block.push_str("embed = true\n");
667                }
668                copy_str_field(pcfg, "model", &mut block);
669                copy_str_field(pcfg, "base_url", &mut block);
670                copy_str_field(pcfg, "embedding_model", &mut block);
671                if ptype == "claude" && !pcfg.contains_key("model") {
672                    if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
673                        copy_str_field(cloud, "model", &mut block);
674                        copy_int_field(cloud, "max_tokens", &mut block);
675                    }
676                }
677                if ptype == "openai" && !pcfg.contains_key("model") {
678                    if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
679                        copy_str_field(openai, "model", &mut block);
680                        copy_str_field(openai, "base_url", &mut block);
681                        copy_int_field(openai, "max_tokens", &mut block);
682                        copy_str_field(openai, "embedding_model", &mut block);
683                    }
684                }
685                if ptype == "ollama" && !pcfg.contains_key("base_url") {
686                    if let Some(u) = base_url {
687                        block.push_str(&format!("base_url = \"{u}\"\n"));
688                    }
689                }
690                if ptype == "ollama" && !pcfg.contains_key("model") {
691                    if let Some(m) = model {
692                        block.push_str(&format!("model = \"{m}\"\n"));
693                    }
694                }
695                if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
696                    if let Some(em) = embedding_model {
697                        block.push_str(&format!("embedding_model = \"{em}\"\n"));
698                    }
699                }
700                blocks.push(block);
701            }
702        }
703    }
704    (blocks, routing)
705}
706
707// Returns (provider_blocks, routing)
708#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
709fn migrate_router_provider(
710    llm: &toml_edit::Table,
711    model: &Option<String>,
712    base_url: &Option<String>,
713    embedding_model: &Option<String>,
714) -> (Vec<String>, Option<String>) {
715    let mut blocks = Vec::new();
716    let mut routing = None;
717    if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
718        let strategy = router
719            .get("strategy")
720            .and_then(toml_edit::Item::as_str)
721            .unwrap_or("ema");
722        routing = Some(strategy.to_owned());
723        if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
724            for item in chain {
725                let name = item.as_str().unwrap_or_default();
726                let ptype = infer_provider_type(name, llm);
727                let mut block =
728                    format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
729                match ptype {
730                    "claude" => {
731                        if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
732                            copy_str_field(cloud, "model", &mut block);
733                            copy_int_field(cloud, "max_tokens", &mut block);
734                        }
735                    }
736                    "openai" => {
737                        if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
738                        {
739                            copy_str_field(openai, "model", &mut block);
740                            copy_str_field(openai, "base_url", &mut block);
741                            copy_int_field(openai, "max_tokens", &mut block);
742                            copy_str_field(openai, "embedding_model", &mut block);
743                        } else {
744                            if let Some(m) = model {
745                                block.push_str(&format!("model = \"{m}\"\n"));
746                            }
747                            if let Some(u) = base_url {
748                                block.push_str(&format!("base_url = \"{u}\"\n"));
749                            }
750                        }
751                    }
752                    "ollama" => {
753                        if let Some(m) = model {
754                            block.push_str(&format!("model = \"{m}\"\n"));
755                        }
756                        if let Some(em) = embedding_model {
757                            block.push_str(&format!("embedding_model = \"{em}\"\n"));
758                        }
759                        if let Some(u) = base_url {
760                            block.push_str(&format!("base_url = \"{u}\"\n"));
761                        }
762                    }
763                    _ => {
764                        if let Some(m) = model {
765                            block.push_str(&format!("model = \"{m}\"\n"));
766                        }
767                    }
768                }
769                blocks.push(block);
770            }
771        }
772    }
773    (blocks, routing)
774}
775
776/// Migrate a TOML config string from the old `[llm]` format (with `provider`, `[llm.cloud]`,
777/// `[llm.openai]`, `[llm.orchestrator]`, `[llm.router]` sections) to the new
778/// `[[llm.providers]]` array format.
779///
780/// If the config does not contain legacy LLM keys, it is returned unchanged.
781/// Removes `routing = "task"` and `[llm.routes]` block lines from a raw TOML string.
782///
783/// Used as a pre-pass before `migrate_llm_to_providers` when the removed variant is detected.
784fn strip_task_routing_keys(toml_src: &str) -> String {
785    let mut in_routes_block = false;
786    let mut out = Vec::new();
787    for line in toml_src.lines() {
788        let trimmed = line.trim();
789        if trimmed == "[llm.routes]" {
790            in_routes_block = true;
791            continue;
792        }
793        if in_routes_block {
794            // Exit the routes block when we hit the next section header.
795            if trimmed.starts_with('[') {
796                in_routes_block = false;
797            } else {
798                continue;
799            }
800        }
801        // Strip bare `routing = "task"` assignment.
802        if trimmed.starts_with("routing") && trimmed.contains("\"task\"") {
803            continue;
804        }
805        out.push(line);
806    }
807    out.join("\n")
808}
809
810/// Creates a `.bak` backup at `backup_path` before writing.
811///
812/// # Errors
813///
814/// Returns `MigrateError::Parse` if the input TOML is invalid.
815#[allow(
816    clippy::too_many_lines,
817    clippy::format_push_string,
818    clippy::manual_let_else,
819    clippy::op_ref,
820    clippy::collapsible_if
821)]
822pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
823    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
824
825    // Detect whether this is a legacy-format config.
826    let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
827        Some(t) => t,
828        None => {
829            // No [llm] section at all — nothing to migrate.
830            return Ok(MigrationResult {
831                output: toml_src.to_owned(),
832                changed_count: 0,
833                sections_changed: Vec::new(),
834            });
835        }
836    };
837
838    // Pre-check: `routing = "task"` was removed as unimplemented (#3248).
839    // Detect on the input document before any block transforms.
840    if llm.get("routing").and_then(toml_edit::Item::as_str) == Some("task") {
841        let routes_count = llm
842            .get("routes")
843            .and_then(toml_edit::Item::as_table)
844            .map_or(0, toml_edit::Table::len);
845        let msg = format!(
846            "routing = \"task\" is no longer supported and has been removed (#3248). \
847             {routes_count} route(s) in [llm.routes] will be dropped. \
848             Falling back to default single-provider routing."
849        );
850        tracing::warn!("{msg}");
851        eprintln!("WARNING: {msg}");
852        // Strip the removed keys and re-run migration on the cleaned source.
853        let cleaned = strip_task_routing_keys(toml_src);
854        return migrate_llm_to_providers(&cleaned);
855    }
856
857    let has_provider_field = llm.contains_key("provider");
858    let has_cloud = llm.contains_key("cloud");
859    let has_openai = llm.contains_key("openai");
860    let has_gemini = llm.contains_key("gemini");
861    let has_orchestrator = llm.contains_key("orchestrator");
862    let has_router = llm.contains_key("router");
863    let has_providers = llm.contains_key("providers");
864
865    if !has_provider_field
866        && !has_cloud
867        && !has_openai
868        && !has_orchestrator
869        && !has_router
870        && !has_gemini
871    {
872        // Already in new format (or empty).
873        return Ok(MigrationResult {
874            output: toml_src.to_owned(),
875            changed_count: 0,
876            sections_changed: Vec::new(),
877        });
878    }
879
880    if has_providers {
881        // Mixed format — refuse to migrate, let the caller handle the error.
882        return Err(MigrateError::Parse(
883            "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
884                .parse::<toml_edit::DocumentMut>()
885                .unwrap_err(),
886        ));
887    }
888
889    // Build new [[llm.providers]] entries from legacy sections.
890    let provider_str = llm
891        .get("provider")
892        .and_then(toml_edit::Item::as_str)
893        .unwrap_or("ollama");
894    let base_url = llm
895        .get("base_url")
896        .and_then(toml_edit::Item::as_str)
897        .map(str::to_owned);
898    let model = llm
899        .get("model")
900        .and_then(toml_edit::Item::as_str)
901        .map(str::to_owned);
902    let embedding_model = llm
903        .get("embedding_model")
904        .and_then(toml_edit::Item::as_str)
905        .map(str::to_owned);
906
907    // Collect provider entries as inline TOML strings.
908    let mut provider_blocks: Vec<String> = Vec::new();
909    let mut routing: Option<String> = None;
910
911    match provider_str {
912        "ollama" => {
913            provider_blocks.extend(migrate_ollama_provider(
914                llm,
915                &model,
916                &base_url,
917                &embedding_model,
918            ));
919        }
920        "claude" => {
921            provider_blocks.extend(migrate_claude_provider(llm, &model));
922        }
923        "openai" => {
924            provider_blocks.extend(migrate_openai_provider(llm, &model));
925        }
926        "gemini" => {
927            provider_blocks.extend(migrate_gemini_provider(llm, &model));
928        }
929        "compatible" => {
930            provider_blocks.extend(migrate_compatible_provider(llm));
931        }
932        "orchestrator" => {
933            let (blocks, r) =
934                migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
935            provider_blocks.extend(blocks);
936            routing = r;
937        }
938        "router" => {
939            let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
940            provider_blocks.extend(blocks);
941            routing = r;
942        }
943        other => {
944            let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
945            if let Some(ref m) = model {
946                block.push_str(&format!("model = \"{m}\"\n"));
947            }
948            provider_blocks.push(block);
949        }
950    }
951
952    if provider_blocks.is_empty() {
953        // Nothing to convert; return as-is.
954        return Ok(MigrationResult {
955            output: toml_src.to_owned(),
956            changed_count: 0,
957            sections_changed: Vec::new(),
958        });
959    }
960
961    // Build the replacement [llm] section.
962    let mut new_llm = "[llm]\n".to_owned();
963    if let Some(ref r) = routing {
964        new_llm.push_str(&format!("routing = \"{r}\"\n"));
965    }
966    // Carry over cross-cutting LLM settings.
967    for key in &[
968        "response_cache_enabled",
969        "response_cache_ttl_secs",
970        "semantic_cache_enabled",
971        "semantic_cache_threshold",
972        "semantic_cache_max_candidates",
973        "summary_model",
974        "instruction_file",
975    ] {
976        if let Some(val) = llm.get(key) {
977            if let Some(v) = val.as_value() {
978                let raw = value_to_toml_string(v);
979                if !raw.is_empty() {
980                    new_llm.push_str(&format!("{key} = {raw}\n"));
981                }
982            }
983        }
984    }
985    new_llm.push('\n');
986
987    for block in &provider_blocks {
988        new_llm.push_str(block);
989        new_llm.push('\n');
990    }
991
992    // Remove old [llm] section and all its sub-sections from the source,
993    // then prepend the new section.
994    let output = replace_llm_section(toml_src, &new_llm);
995
996    Ok(MigrationResult {
997        output,
998        changed_count: provider_blocks.len(),
999        sections_changed: vec!["llm.providers".to_owned()],
1000    })
1001}
1002
1003/// Infer provider type from a name used in router chain.
1004fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
1005    match name {
1006        "claude" => "claude",
1007        "openai" => "openai",
1008        "gemini" => "gemini",
1009        "ollama" => "ollama",
1010        "candle" => "candle",
1011        _ => {
1012            // Check if there's a compatible entry with this name.
1013            if llm.contains_key("compatible") {
1014                "compatible"
1015            } else if llm.contains_key("openai") {
1016                "openai"
1017            } else {
1018                "ollama"
1019            }
1020        }
1021    }
1022}
1023
1024fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1025    use std::fmt::Write as _;
1026    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
1027        let _ = writeln!(out, "{key} = \"{v}\"");
1028    }
1029}
1030
1031fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1032    use std::fmt::Write as _;
1033    if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
1034        let _ = writeln!(out, "{key} = {v}");
1035    }
1036}
1037
1038/// Replace the entire [llm] section (including all [llm.*] sub-sections and
1039/// [[llm.*]] array-of-table entries) with `new_llm_section`.
1040fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
1041    let mut out = String::new();
1042    let mut in_llm = false;
1043    let mut skip_until_next_top = false;
1044
1045    for line in toml_str.lines() {
1046        let trimmed = line.trim();
1047
1048        // Check if this is a top-level section header [something] or [[something]].
1049        let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
1050            && trimmed.ends_with(']')
1051            && !trimmed[1..trimmed.len() - 1].contains('.');
1052        let is_top_aot = trimmed.starts_with("[[")
1053            && trimmed.ends_with("]]")
1054            && !trimmed[2..trimmed.len() - 2].contains('.');
1055        let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
1056            && (trimmed.contains(']'));
1057
1058        if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
1059            in_llm = true;
1060            skip_until_next_top = true;
1061            continue;
1062        }
1063
1064        if is_top_section || is_top_aot {
1065            if skip_until_next_top {
1066                // Emit the new LLM section before the next top-level section.
1067                out.push_str(new_llm_section);
1068                skip_until_next_top = false;
1069            }
1070            in_llm = false;
1071        }
1072
1073        if !skip_until_next_top {
1074            out.push_str(line);
1075            out.push('\n');
1076        }
1077    }
1078
1079    // If [llm] was the last section, append now.
1080    if skip_until_next_top {
1081        out.push_str(new_llm_section);
1082    }
1083
1084    out
1085}
1086
1087/// Fields extracted from `[llm.stt]` that drive the migration decision.
1088struct SttFields {
1089    model: Option<String>,
1090    base_url: Option<String>,
1091    provider_hint: String,
1092}
1093
1094/// Extract migration-relevant fields from `[llm.stt]` in the parsed document.
1095fn extract_stt_fields(doc: &toml_edit::DocumentMut) -> SttFields {
1096    let stt_table = doc
1097        .get("llm")
1098        .and_then(toml_edit::Item::as_table)
1099        .and_then(|llm| llm.get("stt"))
1100        .and_then(toml_edit::Item::as_table);
1101
1102    let model = stt_table
1103        .and_then(|stt| stt.get("model"))
1104        .and_then(toml_edit::Item::as_str)
1105        .map(ToOwned::to_owned);
1106
1107    let base_url = stt_table
1108        .and_then(|stt| stt.get("base_url"))
1109        .and_then(toml_edit::Item::as_str)
1110        .map(ToOwned::to_owned);
1111
1112    let provider_hint = stt_table
1113        .and_then(|stt| stt.get("provider"))
1114        .and_then(toml_edit::Item::as_str)
1115        .map(ToOwned::to_owned)
1116        .unwrap_or_default();
1117
1118    SttFields {
1119        model,
1120        base_url,
1121        provider_hint,
1122    }
1123}
1124
1125/// Find the index of the first `[[llm.providers]]` entry that matches `target_type` or
1126/// `provider_hint`, giving priority to explicit name/type matches over type-only matches.
1127fn find_matching_provider_index(
1128    doc: &toml_edit::DocumentMut,
1129    target_type: &str,
1130    provider_hint: &str,
1131) -> Option<usize> {
1132    let providers = doc
1133        .get("llm")
1134        .and_then(toml_edit::Item::as_table)
1135        .and_then(|llm| llm.get("providers"))
1136        .and_then(toml_edit::Item::as_array_of_tables)?;
1137
1138    providers.iter().enumerate().find_map(|(i, t)| {
1139        let name = t
1140            .get("name")
1141            .and_then(toml_edit::Item::as_str)
1142            .unwrap_or("");
1143        let ptype = t
1144            .get("type")
1145            .and_then(toml_edit::Item::as_str)
1146            .unwrap_or("");
1147        // Match by explicit name hint or by type when hint is a legacy backend string.
1148        let name_match =
1149            !provider_hint.is_empty() && (name == provider_hint || ptype == provider_hint);
1150        let type_match = ptype == target_type;
1151        if name_match || type_match {
1152            Some(i)
1153        } else {
1154            None
1155        }
1156    })
1157}
1158
1159/// Attach `stt_model` (and optionally `base_url`) to an existing `[[llm.providers]]` entry
1160/// at `idx`. Ensures the entry has an explicit `name` (W2 guard) and returns that name.
1161fn attach_stt_to_existing_provider(
1162    doc: &mut toml_edit::DocumentMut,
1163    idx: usize,
1164    stt_model: &str,
1165    stt_base_url: Option<&str>,
1166) -> Result<String, MigrateError> {
1167    let llm_mut = doc
1168        .get_mut("llm")
1169        .and_then(toml_edit::Item::as_table_mut)
1170        .ok_or(MigrateError::InvalidStructure(
1171            "[llm] table not accessible for mutation",
1172        ))?;
1173    let providers_mut = llm_mut
1174        .get_mut("providers")
1175        .and_then(toml_edit::Item::as_array_of_tables_mut)
1176        .ok_or(MigrateError::InvalidStructure(
1177            "[[llm.providers]] array not accessible for mutation",
1178        ))?;
1179    let entry = providers_mut
1180        .iter_mut()
1181        .nth(idx)
1182        .ok_or(MigrateError::InvalidStructure(
1183            "[[llm.providers]] entry index out of range during mutation",
1184        ))?;
1185
1186    // W2: ensure explicit name.
1187    let existing_name = entry
1188        .get("name")
1189        .and_then(toml_edit::Item::as_str)
1190        .map(ToOwned::to_owned);
1191    let entry_name = existing_name.unwrap_or_else(|| {
1192        let t = entry
1193            .get("type")
1194            .and_then(toml_edit::Item::as_str)
1195            .unwrap_or("openai");
1196        format!("{t}-stt")
1197    });
1198    entry.insert("name", toml_edit::value(entry_name.clone()));
1199    entry.insert("stt_model", toml_edit::value(stt_model));
1200    if let Some(url) = stt_base_url
1201        && entry.get("base_url").is_none()
1202    {
1203        entry.insert("base_url", toml_edit::value(url));
1204    }
1205    Ok(entry_name)
1206}
1207
1208/// Append a new `[[llm.providers]]` entry carrying `stt_model`, creating the array if absent.
1209/// Returns the name assigned to the new entry.
1210fn append_new_stt_provider(
1211    doc: &mut toml_edit::DocumentMut,
1212    target_type: &str,
1213    stt_model: &str,
1214    stt_base_url: Option<&str>,
1215) -> Result<String, MigrateError> {
1216    let new_name = if target_type == "candle" {
1217        "local-whisper".to_owned()
1218    } else {
1219        "openai-stt".to_owned()
1220    };
1221    let mut new_entry = toml_edit::Table::new();
1222    new_entry.insert("name", toml_edit::value(new_name.clone()));
1223    new_entry.insert("type", toml_edit::value(target_type));
1224    new_entry.insert("stt_model", toml_edit::value(stt_model));
1225    if let Some(url) = stt_base_url {
1226        new_entry.insert("base_url", toml_edit::value(url));
1227    }
1228    let llm_mut = doc
1229        .get_mut("llm")
1230        .and_then(toml_edit::Item::as_table_mut)
1231        .ok_or(MigrateError::InvalidStructure(
1232            "[llm] table not accessible for mutation",
1233        ))?;
1234    if let Some(item) = llm_mut.get_mut("providers") {
1235        if let Some(arr) = item.as_array_of_tables_mut() {
1236            arr.push(new_entry);
1237        }
1238    } else {
1239        let mut arr = toml_edit::ArrayOfTables::new();
1240        arr.push(new_entry);
1241        llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1242    }
1243    Ok(new_name)
1244}
1245
1246/// Update `[llm.stt]`: set `provider` to `resolved_provider_name` and strip `model`/`base_url`.
1247fn rewrite_stt_section(doc: &mut toml_edit::DocumentMut, resolved_provider_name: &str) {
1248    if let Some(stt_table) = doc
1249        .get_mut("llm")
1250        .and_then(toml_edit::Item::as_table_mut)
1251        .and_then(|llm| llm.get_mut("stt"))
1252        .and_then(toml_edit::Item::as_table_mut)
1253    {
1254        stt_table.insert("provider", toml_edit::value(resolved_provider_name));
1255        stt_table.remove("model");
1256        stt_table.remove("base_url");
1257    }
1258}
1259
1260/// Migrate an old `[llm.stt]` section (with `model` / `base_url` fields) to the new format
1261/// where those fields live on a `[[llm.providers]]` entry via `stt_model`.
1262///
1263/// Transformations:
1264/// - `[llm.stt].model` → `stt_model` on the matching or new `[[llm.providers]]` entry
1265/// - `[llm.stt].base_url` → `base_url` on that entry (skipped when already present)
1266/// - `[llm.stt].provider` is updated to the provider name; the entry is assigned an explicit
1267///   `name` when it lacked one (W2 guard).
1268/// - Old `model` and `base_url` keys are stripped from `[llm.stt]`.
1269///
1270/// If `[llm.stt]` is absent or already uses the new format (no `model` / `base_url`), the
1271/// input is returned unchanged.
1272///
1273/// # Errors
1274///
1275/// Returns `MigrateError::Parse` if the input TOML is invalid.
1276/// Returns `MigrateError::InvalidStructure` if `[llm.stt].model` is present but the `[llm]`
1277/// key is absent or not a table, making mutation impossible.
1278pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1279    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1280    let stt = extract_stt_fields(&doc);
1281
1282    // Nothing to migrate if [llm.stt] does not exist or already lacks the old fields.
1283    if stt.model.is_none() && stt.base_url.is_none() {
1284        return Ok(MigrationResult {
1285            output: toml_src.to_owned(),
1286            changed_count: 0,
1287            sections_changed: Vec::new(),
1288        });
1289    }
1290
1291    let stt_model = stt.model.unwrap_or_else(|| "whisper-1".to_owned());
1292
1293    // Determine the target provider type based on provider hint.
1294    let target_type = match stt.provider_hint.as_str() {
1295        "candle-whisper" | "candle" => "candle",
1296        _ => "openai",
1297    };
1298
1299    let resolved_name = match find_matching_provider_index(&doc, target_type, &stt.provider_hint) {
1300        Some(idx) => {
1301            attach_stt_to_existing_provider(&mut doc, idx, &stt_model, stt.base_url.as_deref())?
1302        }
1303        None => {
1304            append_new_stt_provider(&mut doc, target_type, &stt_model, stt.base_url.as_deref())?
1305        }
1306    };
1307
1308    rewrite_stt_section(&mut doc, &resolved_name);
1309
1310    Ok(MigrationResult {
1311        output: doc.to_string(),
1312        changed_count: 1,
1313        sections_changed: vec!["llm.providers.stt_model".to_owned()],
1314    })
1315}
1316
1317/// Migrate `[orchestration] planner_model` to `planner_provider`.
1318///
1319/// The namespaces differ: `planner_model` held a raw model name (e.g. `"gpt-4o"`),
1320/// while `planner_provider` must reference a `[[llm.providers]]` `name` field. A migrated
1321/// value would cause a silent `warn!` from `build_planner_provider()` when resolution fails,
1322/// so the old value is commented out and a warning is emitted.
1323///
1324/// If `planner_model` is absent, the input is returned unchanged.
1325///
1326/// # Errors
1327///
1328/// Returns `MigrateError::Parse` if the input TOML is invalid.
1329pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1330    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1331
1332    let old_value = doc
1333        .get("orchestration")
1334        .and_then(toml_edit::Item::as_table)
1335        .and_then(|t| t.get("planner_model"))
1336        .and_then(toml_edit::Item::as_value)
1337        .and_then(toml_edit::Value::as_str)
1338        .map(ToOwned::to_owned);
1339
1340    let Some(old_model) = old_value else {
1341        return Ok(MigrationResult {
1342            output: toml_src.to_owned(),
1343            changed_count: 0,
1344            sections_changed: Vec::new(),
1345        });
1346    };
1347
1348    // Remove the old key via text substitution to preserve surrounding comments/formatting.
1349    // We rebuild the section comment in the output rather than using toml_edit mutations,
1350    // following the same line-oriented approach used elsewhere in this file.
1351    let commented_out = format!(
1352        "# planner_provider = \"{old_model}\"  \
1353         # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1354    );
1355
1356    let orch_table = doc
1357        .get_mut("orchestration")
1358        .and_then(toml_edit::Item::as_table_mut)
1359        .ok_or(MigrateError::InvalidStructure(
1360            "[orchestration] is not a table",
1361        ))?;
1362    orch_table.remove("planner_model");
1363    let decor = orch_table.decor_mut();
1364    let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1365    // Append the commented-out entry as a trailing comment on the section.
1366    let new_suffix = if existing_suffix.trim().is_empty() {
1367        format!("\n{commented_out}\n")
1368    } else {
1369        format!("{existing_suffix}\n{commented_out}\n")
1370    };
1371    decor.set_suffix(new_suffix);
1372
1373    eprintln!(
1374        "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1375         and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1376         `name` field, not a raw model name. Update or remove the commented line."
1377    );
1378
1379    Ok(MigrationResult {
1380        output: doc.to_string(),
1381        changed_count: 1,
1382        sections_changed: vec!["orchestration.planner_provider".to_owned()],
1383    })
1384}
1385
1386/// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry
1387/// that lacks an explicit `trust_level`.
1388///
1389/// Before this PR all config-defined servers skipped SSRF validation (equivalent to
1390/// `trust_level = "trusted"`). Without migration, upgrading to the new default
1391/// (`Untrusted`) would silently break remote servers on private networks.
1392///
1393/// This function adds `trust_level = "trusted"` only to entries that are missing the
1394/// field, preserving entries that already have it set.
1395///
1396/// # Errors
1397///
1398/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1399pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1400    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1401    let mut added = 0usize;
1402
1403    let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1404        return Ok(MigrationResult {
1405            output: toml_src.to_owned(),
1406            changed_count: 0,
1407            sections_changed: Vec::new(),
1408        });
1409    };
1410
1411    let Some(servers) = mcp
1412        .get_mut("servers")
1413        .and_then(toml_edit::Item::as_array_of_tables_mut)
1414    else {
1415        return Ok(MigrationResult {
1416            output: toml_src.to_owned(),
1417            changed_count: 0,
1418            sections_changed: Vec::new(),
1419        });
1420    };
1421
1422    for entry in servers.iter_mut() {
1423        if !entry.contains_key("trust_level") {
1424            entry.insert(
1425                "trust_level",
1426                toml_edit::value(toml_edit::Value::from("trusted")),
1427            );
1428            added += 1;
1429        }
1430    }
1431
1432    if added > 0 {
1433        eprintln!(
1434            "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1435             entr{} (preserving previous SSRF-skip behavior). \
1436             Review and adjust trust levels as needed.",
1437            if added == 1 { "y" } else { "ies" }
1438        );
1439    }
1440
1441    Ok(MigrationResult {
1442        output: doc.to_string(),
1443        changed_count: added,
1444        sections_changed: if added > 0 {
1445            vec!["mcp.servers.trust_level".to_owned()]
1446        } else {
1447            Vec::new()
1448        },
1449    })
1450}
1451
1452/// Migrate `[agent].max_tool_retries` → `[tools.retry].max_attempts` and
1453/// `[agent].max_retry_duration_secs` → `[tools.retry].budget_secs`.
1454///
1455/// Old fields are preserved (not removed) to avoid breaking configs that rely on them
1456/// until they are officially deprecated in a future release. The new `[tools.retry]` section
1457/// is added if missing, populated with the migrated values.
1458///
1459/// # Errors
1460///
1461/// Returns `MigrateError::Parse` if the TOML is invalid.
1462pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1463    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1464
1465    let max_retries = doc
1466        .get("agent")
1467        .and_then(toml_edit::Item::as_table)
1468        .and_then(|t| t.get("max_tool_retries"))
1469        .and_then(toml_edit::Item::as_value)
1470        .and_then(toml_edit::Value::as_integer)
1471        .map(i64::cast_unsigned);
1472
1473    let budget_secs = doc
1474        .get("agent")
1475        .and_then(toml_edit::Item::as_table)
1476        .and_then(|t| t.get("max_retry_duration_secs"))
1477        .and_then(toml_edit::Item::as_value)
1478        .and_then(toml_edit::Value::as_integer)
1479        .map(i64::cast_unsigned);
1480
1481    if max_retries.is_none() && budget_secs.is_none() {
1482        return Ok(MigrationResult {
1483            output: toml_src.to_owned(),
1484            changed_count: 0,
1485            sections_changed: Vec::new(),
1486        });
1487    }
1488
1489    // Ensure [tools.retry] section exists.
1490    if !doc.contains_key("tools") {
1491        doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1492    }
1493    let tools_table = doc
1494        .get_mut("tools")
1495        .and_then(toml_edit::Item::as_table_mut)
1496        .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1497
1498    if !tools_table.contains_key("retry") {
1499        tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1500    }
1501    let retry_table = tools_table
1502        .get_mut("retry")
1503        .and_then(toml_edit::Item::as_table_mut)
1504        .ok_or(MigrateError::InvalidStructure(
1505            "[tools.retry] is not a table",
1506        ))?;
1507
1508    let mut changed_count = 0usize;
1509
1510    if let Some(retries) = max_retries
1511        && !retry_table.contains_key("max_attempts")
1512    {
1513        retry_table.insert(
1514            "max_attempts",
1515            toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1516        );
1517        changed_count += 1;
1518    }
1519
1520    if let Some(secs) = budget_secs
1521        && !retry_table.contains_key("budget_secs")
1522    {
1523        retry_table.insert(
1524            "budget_secs",
1525            toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1526        );
1527        changed_count += 1;
1528    }
1529
1530    if changed_count > 0 {
1531        eprintln!(
1532            "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1533             [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1534        );
1535    }
1536
1537    Ok(MigrationResult {
1538        output: doc.to_string(),
1539        changed_count,
1540        sections_changed: if changed_count > 0 {
1541            vec!["tools.retry".to_owned()]
1542        } else {
1543            Vec::new()
1544        },
1545    })
1546}
1547
1548/// Add a commented-out `database_url = ""` entry under `[memory]` if absent.
1549///
1550/// If the `[memory]` section does not exist it is created. This migration surfaces the
1551/// `PostgreSQL` URL option for users upgrading from a pre-postgres config file.
1552///
1553/// # Errors
1554///
1555/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1556pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1557    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1558    if toml_src.contains("database_url") {
1559        return Ok(MigrationResult {
1560            output: toml_src.to_owned(),
1561            changed_count: 0,
1562            sections_changed: Vec::new(),
1563        });
1564    }
1565
1566    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1567
1568    // Ensure [memory] section exists (created if absent so the comment has context).
1569    if !doc.contains_key("memory") {
1570        doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1571    }
1572
1573    let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1574         # Leave empty and store the actual URL in the vault:\n\
1575         #   zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1576         # database_url = \"\"\n";
1577    let raw = doc.to_string();
1578    let output = format!("{raw}{comment}");
1579
1580    Ok(MigrationResult {
1581        output,
1582        changed_count: 1,
1583        sections_changed: vec!["memory.database_url".to_owned()],
1584    })
1585}
1586
1587/// No-op migration for `[tools.shell]` transactional fields added in #2414.
1588///
1589/// All 5 new fields have `#[serde(default)]` so existing configs parse without changes.
1590/// This step adds them as commented-out hints in `[tools.shell]` if not already present.
1591///
1592/// # Errors
1593///
1594/// Returns `MigrateError` if the TOML cannot be parsed or `[tools.shell]` is malformed.
1595pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1596    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1597    if toml_src.contains("transactional") {
1598        return Ok(MigrationResult {
1599            output: toml_src.to_owned(),
1600            changed_count: 0,
1601            sections_changed: Vec::new(),
1602        });
1603    }
1604
1605    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1606
1607    let tools_shell_exists = doc
1608        .get("tools")
1609        .and_then(toml_edit::Item::as_table)
1610        .is_some_and(|t| t.contains_key("shell"));
1611    if !tools_shell_exists {
1612        // No [tools.shell] section — nothing to annotate; new configs will get defaults.
1613        return Ok(MigrationResult {
1614            output: toml_src.to_owned(),
1615            changed_count: 0,
1616            sections_changed: Vec::new(),
1617        });
1618    }
1619
1620    let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1621         # transactional = false\n\
1622         # transaction_scope = []          # glob patterns; empty = all extracted paths\n\
1623         # auto_rollback = false           # rollback when exit code >= 2\n\
1624         # auto_rollback_exit_codes = []   # explicit exit codes; overrides >= 2 heuristic\n\
1625         # snapshot_required = false       # abort if snapshot fails (default: warn and proceed)\n";
1626    let raw = doc.to_string();
1627    let output = format!("{raw}{comment}");
1628
1629    Ok(MigrationResult {
1630        output,
1631        changed_count: 1,
1632        sections_changed: vec!["tools.shell.transactional".to_owned()],
1633    })
1634}
1635
1636/// Migration step: add `budget_hint_enabled` as a commented-out entry under `[agent]` if absent.
1637///
1638/// # Errors
1639///
1640/// Returns an error if the config cannot be parsed or the `[agent]` section is malformed.
1641pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1642    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1643    if toml_src.contains("budget_hint_enabled") {
1644        return Ok(MigrationResult {
1645            output: toml_src.to_owned(),
1646            changed_count: 0,
1647            sections_changed: Vec::new(),
1648        });
1649    }
1650
1651    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1652    if !doc.contains_key("agent") {
1653        return Ok(MigrationResult {
1654            output: toml_src.to_owned(),
1655            changed_count: 0,
1656            sections_changed: Vec::new(),
1657        });
1658    }
1659
1660    let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1661         # budget_hint_enabled = true\n";
1662    let raw = doc.to_string();
1663    let output = format!("{raw}{comment}");
1664
1665    Ok(MigrationResult {
1666        output,
1667        changed_count: 1,
1668        sections_changed: vec!["agent.budget_hint_enabled".to_owned()],
1669    })
1670}
1671
1672/// Add a commented-out `[memory.forgetting]` section if absent (#2397).
1673///
1674/// All forgetting fields have `#[serde(default)]` so existing configs parse without changes.
1675/// This step surfaces the new section for users upgrading from older configs.
1676///
1677/// # Errors
1678///
1679/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1680pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1681    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1682    if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1683        return Ok(MigrationResult {
1684            output: toml_src.to_owned(),
1685            changed_count: 0,
1686            sections_changed: Vec::new(),
1687        });
1688    }
1689
1690    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1691    if !doc.contains_key("memory") {
1692        return Ok(MigrationResult {
1693            output: toml_src.to_owned(),
1694            changed_count: 0,
1695            sections_changed: Vec::new(),
1696        });
1697    }
1698
1699    let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1700         # [memory.forgetting]\n\
1701         # enabled = false\n\
1702         # decay_rate = 0.1                   # per-sweep importance decay\n\
1703         # forgetting_floor = 0.05            # prune below this score\n\
1704         # sweep_interval_secs = 7200         # run every 2 hours\n\
1705         # sweep_batch_size = 500\n\
1706         # protect_recent_hours = 24\n\
1707         # protect_min_access_count = 3\n";
1708    let raw = doc.to_string();
1709    let output = format!("{raw}{comment}");
1710
1711    Ok(MigrationResult {
1712        output,
1713        changed_count: 1,
1714        sections_changed: vec!["memory.forgetting".to_owned()],
1715    })
1716}
1717
1718/// Strip any existing `[memory.compression.predictor]` section from the config (#3251).
1719///
1720/// The compression predictor feature was removed. This migration cleans up both active
1721/// and commented-out sections that previous `--migrate-config` runs may have injected.
1722/// # Errors
1723///
1724/// This function is a pure string operation and always returns `Ok`. The `Result`
1725/// return type is kept for API consistency with other migration functions.
1726pub fn migrate_compression_predictor_config(
1727    toml_src: &str,
1728) -> Result<MigrationResult, MigrateError> {
1729    // Strip any [memory.compression.predictor] section (active or commented-out) that
1730    // prior migrate-config runs may have injected. The feature is removed (#3251).
1731    let has_active = toml_src.contains("[memory.compression.predictor]");
1732    let has_commented = toml_src.contains("# [memory.compression.predictor]");
1733    if !has_active && !has_commented {
1734        return Ok(MigrationResult {
1735            output: toml_src.to_owned(),
1736            changed_count: 0,
1737            sections_changed: Vec::new(),
1738        });
1739    }
1740
1741    // Remove lines that belong to the section header variants and their key lines.
1742    // A line belongs to the section when the section header has been seen and the
1743    // line is not a new `[section]` header (excluding the predictor header itself).
1744    let mut output_lines: Vec<&str> = Vec::new();
1745    let mut in_predictor = false;
1746    for line in toml_src.lines() {
1747        let trimmed = line.trim();
1748        // Detect active or commented-out section header.
1749        if trimmed == "[memory.compression.predictor]"
1750            || trimmed == "# [memory.compression.predictor]"
1751        {
1752            in_predictor = true;
1753            continue;
1754        }
1755        // Any new `[section]` header (not commented-out) ends the predictor block.
1756        if in_predictor && trimmed.starts_with('[') && !trimmed.starts_with("# [") {
1757            in_predictor = false;
1758        }
1759        if !in_predictor {
1760            output_lines.push(line);
1761        }
1762    }
1763    // Preserve trailing newline if original had one.
1764    let mut output = output_lines.join("\n");
1765    if toml_src.ends_with('\n') {
1766        output.push('\n');
1767    }
1768
1769    Ok(MigrationResult {
1770        output,
1771        changed_count: 1,
1772        sections_changed: vec!["memory.compression.predictor".to_owned()],
1773    })
1774}
1775
1776/// Add a commented-out `[memory.microcompact]` block if absent (#2699).
1777///
1778/// # Errors
1779///
1780/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1781pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1782    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1783    if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1784        return Ok(MigrationResult {
1785            output: toml_src.to_owned(),
1786            changed_count: 0,
1787            sections_changed: Vec::new(),
1788        });
1789    }
1790
1791    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1792    if !doc.contains_key("memory") {
1793        return Ok(MigrationResult {
1794            output: toml_src.to_owned(),
1795            changed_count: 0,
1796            sections_changed: Vec::new(),
1797        });
1798    }
1799
1800    let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1801         # [memory.microcompact]\n\
1802         # enabled = false\n\
1803         # gap_threshold_minutes = 60   # idle gap before clearing stale outputs\n\
1804         # keep_recent = 3              # always keep this many recent outputs intact\n";
1805    let raw = doc.to_string();
1806    let output = format!("{raw}{comment}");
1807
1808    Ok(MigrationResult {
1809        output,
1810        changed_count: 1,
1811        sections_changed: vec!["memory.microcompact".to_owned()],
1812    })
1813}
1814
1815/// Add a commented-out `[memory.autodream]` block if absent (#2697).
1816///
1817/// # Errors
1818///
1819/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1820pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1821    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1822    if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1823        return Ok(MigrationResult {
1824            output: toml_src.to_owned(),
1825            changed_count: 0,
1826            sections_changed: Vec::new(),
1827        });
1828    }
1829
1830    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1831    if !doc.contains_key("memory") {
1832        return Ok(MigrationResult {
1833            output: toml_src.to_owned(),
1834            changed_count: 0,
1835            sections_changed: Vec::new(),
1836        });
1837    }
1838
1839    let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1840         # [memory.autodream]\n\
1841         # enabled = false\n\
1842         # min_sessions = 5             # sessions since last consolidation\n\
1843         # min_hours = 8                # hours since last consolidation\n\
1844         # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1845         # max_iterations = 5\n";
1846    let raw = doc.to_string();
1847    let output = format!("{raw}{comment}");
1848
1849    Ok(MigrationResult {
1850        output,
1851        changed_count: 1,
1852        sections_changed: vec!["memory.autodream".to_owned()],
1853    })
1854}
1855
1856/// Add a commented-out `[magic_docs]` block if absent (#2702).
1857///
1858/// # Errors
1859///
1860/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1861pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1862    use toml_edit::{Item, Table};
1863
1864    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1865
1866    if doc.contains_key("magic_docs") {
1867        return Ok(MigrationResult {
1868            output: toml_src.to_owned(),
1869            changed_count: 0,
1870            sections_changed: Vec::new(),
1871        });
1872    }
1873
1874    doc.insert("magic_docs", Item::Table(Table::new()));
1875    let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1876         # [magic_docs]\n\
1877         # enabled = false\n\
1878         # min_turns_between_updates = 10\n\
1879         # update_provider = \"\"         # provider name from [[llm.providers]]; empty = primary\n\
1880         # max_iterations = 3\n";
1881    // Remove the just-inserted empty table and replace with a comment.
1882    doc.remove("magic_docs");
1883    // Append as a trailing comment on the document root.
1884    let raw = doc.to_string();
1885    let output = format!("{raw}\n{comment}");
1886
1887    Ok(MigrationResult {
1888        output,
1889        changed_count: 1,
1890        sections_changed: vec!["magic_docs".to_owned()],
1891    })
1892}
1893
1894/// Add a commented-out `[telemetry]` block if the section is absent (#2846).
1895///
1896/// Existing configs that were written before the `telemetry` section was introduced will have
1897/// the block appended as comments so users can discover and enable it without manual hunting.
1898///
1899/// # Errors
1900///
1901/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1902pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1903    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1904
1905    if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1906        return Ok(MigrationResult {
1907            output: toml_src.to_owned(),
1908            changed_count: 0,
1909            sections_changed: Vec::new(),
1910        });
1911    }
1912
1913    let comment = "\n\
1914         # Profiling and distributed tracing (requires --features profiling). All\n\
1915         # instrumentation points are zero-overhead when the feature is absent.\n\
1916         # [telemetry]\n\
1917         # enabled = false\n\
1918         # backend = \"local\"        # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1919         # trace_dir = \".local/traces\"\n\
1920         # include_args = false\n\
1921         # service_name = \"zeph-agent\"\n\
1922         # sample_rate = 1.0\n\
1923         # otel_filter = \"info\"     # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1924
1925    let raw = doc.to_string();
1926    let output = format!("{raw}{comment}");
1927
1928    Ok(MigrationResult {
1929        output,
1930        changed_count: 1,
1931        sections_changed: vec!["telemetry".to_owned()],
1932    })
1933}
1934
1935/// Add a commented-out `[agent.supervisor]` block if the sub-table is absent (#2883).
1936///
1937/// Appended as comments under `[agent]` so users can discover and tune supervisor limits
1938/// without manual hunting. Safe to call on configs that already have the section.
1939///
1940/// # Errors
1941///
1942/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1943pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1944    // Idempotency: skip if already present (either as real section or commented-out block).
1945    if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1946        return Ok(MigrationResult {
1947            output: toml_src.to_owned(),
1948            changed_count: 0,
1949            sections_changed: Vec::new(),
1950        });
1951    }
1952
1953    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1954
1955    // Only inject the comment block when an [agent] section is already present so we don't
1956    // pollute configs that have no [agent] at all.
1957    if !doc.contains_key("agent") {
1958        return Ok(MigrationResult {
1959            output: toml_src.to_owned(),
1960            changed_count: 0,
1961            sections_changed: Vec::new(),
1962        });
1963    }
1964
1965    let comment = "\n\
1966         # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1967         # [agent.supervisor]\n\
1968         # enrichment_limit = 4\n\
1969         # telemetry_limit = 8\n\
1970         # abort_enrichment_on_turn = false\n";
1971
1972    let raw = doc.to_string();
1973    let output = format!("{raw}{comment}");
1974
1975    Ok(MigrationResult {
1976        output,
1977        changed_count: 1,
1978        sections_changed: vec!["agent.supervisor".to_owned()],
1979    })
1980}
1981
1982/// Add a commented-out `otel_filter` entry under `[telemetry]` if the key is absent (#2997).
1983///
1984/// When `[telemetry]` exists but lacks `otel_filter`, appends the key as a comment so users
1985/// can discover it without manual hunting. Safe to call when the key is already present
1986/// (real or commented-out).
1987///
1988/// # Errors
1989///
1990/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1991pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1992    // Idempotency: skip if key already present (real or commented-out).
1993    if toml_src.contains("otel_filter") {
1994        return Ok(MigrationResult {
1995            output: toml_src.to_owned(),
1996            changed_count: 0,
1997            sections_changed: Vec::new(),
1998        });
1999    }
2000
2001    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
2002
2003    // Only inject when [telemetry] section exists; otherwise the field will be added
2004    // by migrate_telemetry_config which already includes it in the commented block.
2005    if !doc.contains_key("telemetry") {
2006        return Ok(MigrationResult {
2007            output: toml_src.to_owned(),
2008            changed_count: 0,
2009            sections_changed: Vec::new(),
2010        });
2011    }
2012
2013    let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
2014        (tonic=warn etc.) are always appended (#2997).\n\
2015        # otel_filter = \"info\"\n";
2016    let raw = doc.to_string();
2017    // Insert within [telemetry] so the comment stays adjacent to its section.
2018    let output = insert_after_section(&raw, "telemetry", comment);
2019
2020    Ok(MigrationResult {
2021        output,
2022        changed_count: 1,
2023        sections_changed: vec!["telemetry.otel_filter".to_owned()],
2024    })
2025}
2026
2027/// Adds a commented-out `[tools.egress]` section to configs that predate egress logging (#3058).
2028///
2029/// # Errors
2030///
2031/// Returns [`MigrateError`] if the TOML source cannot be parsed.
2032pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2033    if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
2034        return Ok(MigrationResult {
2035            output: toml_src.to_owned(),
2036            changed_count: 0,
2037            sections_changed: Vec::new(),
2038        });
2039    }
2040
2041    let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
2042        # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
2043        # [tools.egress]\n\
2044        # enabled = true           # set to false to disable all egress event recording\n\
2045        # log_blocked = true       # record scheme/domain/SSRF-blocked requests\n\
2046        # log_response_bytes = true\n\
2047        # log_hosts_to_tui = true\n";
2048
2049    let mut output = toml_src.to_owned();
2050    output.push_str(comment);
2051    Ok(MigrationResult {
2052        output,
2053        changed_count: 1,
2054        sections_changed: vec!["tools.egress".to_owned()],
2055    })
2056}
2057
2058/// Adds a commented-out `[security.vigil]` section to configs that predate VIGIL (#3058).
2059///
2060/// # Errors
2061///
2062/// Returns [`MigrateError`] if the TOML source cannot be parsed.
2063pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2064    if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
2065        return Ok(MigrationResult {
2066            output: toml_src.to_owned(),
2067            changed_count: 0,
2068            sections_changed: Vec::new(),
2069        });
2070    }
2071
2072    let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2073        # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2074        # [security.vigil]\n\
2075        # enabled = true          # master switch; false bypasses VIGIL entirely\n\
2076        # strict_mode = false     # true: block (replace with sentinel); false: truncate+annotate\n\
2077        # sanitize_max_chars = 2048\n\
2078        # extra_patterns = []     # operator-supplied additional injection patterns (max 64)\n\
2079        # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2080
2081    let mut output = toml_src.to_owned();
2082    output.push_str(comment);
2083    Ok(MigrationResult {
2084        output,
2085        changed_count: 1,
2086        sections_changed: vec!["security.vigil".to_owned()],
2087    })
2088}
2089
2090/// Adds a commented-out `[tools.sandbox]` section to configs that predate the
2091/// OS subprocess sandbox wizard (#3070). Also referenced by #3077.
2092///
2093/// Idempotent: if the section (or a dotted-key form under `[tools]`) is already
2094/// present, OR if the commented-out block was already appended by a prior run,
2095/// the input is returned unchanged. Uses `toml_edit` parsing to avoid false
2096/// positives from comments that mention `tools.sandbox`.
2097///
2098/// # Errors
2099///
2100/// Returns [`MigrateError`] if the TOML source cannot be parsed.
2101pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2102    let doc: DocumentMut = toml_src.parse()?;
2103    let already_present = doc
2104        .get("tools")
2105        .and_then(|t| t.as_table())
2106        .and_then(|t| t.get("sandbox"))
2107        .is_some();
2108    // Secondary guard: commented-out block appended by a prior run of this
2109    // function is not a real TOML key, so toml_edit would not detect it above.
2110    if already_present || toml_src.contains("# [tools.sandbox]") {
2111        return Ok(MigrationResult {
2112            output: toml_src.to_owned(),
2113            changed_count: 0,
2114            sections_changed: Vec::new(),
2115        });
2116    }
2117
2118    let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2119        # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2120        # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2121        # [tools.sandbox]\n\
2122        # enabled = false                 # set to true to wrap shell commands\n\
2123        # profile = \"workspace\"          # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2124        # backend = \"auto\"               # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2125        # strict = true                   # fail startup if sandbox init fails (fail-closed)\n\
2126        # allow_read = []                 # additional read-allowed absolute paths\n\
2127        # allow_write = []                # additional write-allowed absolute paths\n";
2128
2129    let mut output = toml_src.to_owned();
2130    output.push_str(comment);
2131    Ok(MigrationResult {
2132        output,
2133        changed_count: 1,
2134        sections_changed: vec!["tools.sandbox".to_owned()],
2135    })
2136}
2137
2138/// Insert `denied_domains` and `fail_if_unavailable` into an existing `[tools.sandbox]`
2139/// section when those keys are absent (#3294).
2140///
2141/// Idempotent: if either key is already present (active or commented), the function is a no-op.
2142///
2143/// # Errors
2144///
2145/// Returns [`MigrateError`] if the TOML document cannot be parsed.
2146pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2147    // Only inject when [tools.sandbox] already exists.
2148    if !toml_src.contains("[tools.sandbox]") {
2149        return Ok(MigrationResult {
2150            output: toml_src.to_owned(),
2151            changed_count: 0,
2152            sections_changed: Vec::new(),
2153        });
2154    }
2155
2156    let already_has_denied =
2157        toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
2158    let already_has_fail =
2159        toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
2160
2161    if already_has_denied && already_has_fail {
2162        return Ok(MigrationResult {
2163            output: toml_src.to_owned(),
2164            changed_count: 0,
2165            sections_changed: Vec::new(),
2166        });
2167    }
2168
2169    let mut comment = String::new();
2170    if !already_has_denied {
2171        comment.push_str(
2172            "# denied_domains = []       \
2173             # hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
2174        );
2175    }
2176    if !already_has_fail {
2177        comment.push_str(
2178            "# fail_if_unavailable = false  \
2179             # abort startup when no effective OS sandbox is available\n",
2180        );
2181    }
2182
2183    let output = toml_src.replacen(
2184        "[tools.sandbox]\n",
2185        &format!("[tools.sandbox]\n{comment}"),
2186        1,
2187    );
2188    Ok(MigrationResult {
2189        output,
2190        changed_count: 1,
2191        sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
2192    })
2193}
2194
2195/// Add a commented-out `persistence_enabled` key under `[orchestration]` when absent (#3107).
2196///
2197/// Existing configs that omit this key pick up `true` via `#[serde(default)]`, so this
2198/// migration is informational — it surfaces the new option without changing behaviour.
2199///
2200/// # Errors
2201///
2202/// Returns [`MigrateError`] if the TOML document cannot be parsed.
2203pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2204    // Skip if the key is already present (active or commented).
2205    if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2206        return Ok(MigrationResult {
2207            output: toml_src.to_owned(),
2208            changed_count: 0,
2209            sections_changed: Vec::new(),
2210        });
2211    }
2212
2213    // Only inject under an existing [orchestration] section.
2214    if !toml_src.contains("[orchestration]") {
2215        return Ok(MigrationResult {
2216            output: toml_src.to_owned(),
2217            changed_count: 0,
2218            sections_changed: Vec::new(),
2219        });
2220    }
2221
2222    // Insert the commented key right after the `[orchestration]` header line.
2223    let comment = "# persistence_enabled = true  \
2224        # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2225    let output = toml_src.replacen(
2226        "[orchestration]\n",
2227        &format!("[orchestration]\n{comment}"),
2228        1,
2229    );
2230    Ok(MigrationResult {
2231        output,
2232        changed_count: 1,
2233        sections_changed: vec!["orchestration.persistence_enabled".to_owned()],
2234    })
2235}
2236
2237/// Add commented-out `[session.recap]` block if absent (#3064).
2238///
2239/// All recap fields have `#[serde(default)]` so existing configs parse without changes.
2240///
2241/// # Errors
2242///
2243/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
2244pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2245    // Idempotency: check both active and commented forms.
2246    if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2247        return Ok(MigrationResult {
2248            output: toml_src.to_owned(),
2249            changed_count: 0,
2250            sections_changed: Vec::new(),
2251        });
2252    }
2253
2254    let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2255         # [session.recap]\n\
2256         # on_resume = true\n\
2257         # max_tokens = 200\n\
2258         # provider = \"\"\n\
2259         # max_input_messages = 20\n";
2260    let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2261    let output = format!("{raw}{comment}");
2262
2263    Ok(MigrationResult {
2264        output,
2265        changed_count: 1,
2266        sections_changed: vec!["session.recap".to_owned()],
2267    })
2268}
2269
2270/// Add commented-out MCP elicitation keys to `[mcp]` section if absent (#3141).
2271///
2272/// All elicitation fields have `#[serde(default)]` so existing configs parse without changes.
2273///
2274/// # Errors
2275///
2276/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
2277pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2278    // Idempotency: check for any elicitation key presence.
2279    if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2280        return Ok(MigrationResult {
2281            output: toml_src.to_owned(),
2282            changed_count: 0,
2283            sections_changed: Vec::new(),
2284        });
2285    }
2286
2287    // Only inject under an existing [mcp] section.
2288    if !toml_src.contains("[mcp]") {
2289        return Ok(MigrationResult {
2290            output: toml_src.to_owned(),
2291            changed_count: 0,
2292            sections_changed: Vec::new(),
2293        });
2294    }
2295
2296    // Guard against configs that have `[mcp]` but with Windows line endings or at EOF.
2297    if !toml_src.contains("[mcp]\n") {
2298        return Ok(MigrationResult {
2299            output: toml_src.to_owned(),
2300            changed_count: 0,
2301            sections_changed: Vec::new(),
2302        });
2303    }
2304
2305    let comment = "# elicitation_enabled = false          \
2306        # opt-in: servers may request user input mid-task (#3141)\n\
2307        # elicitation_timeout = 120            # seconds to wait for user response\n\
2308        # elicitation_queue_capacity = 16      # beyond this limit requests are auto-declined\n\
2309        # elicitation_warn_sensitive_fields = true  # warn before prompting for password/token/etc.\n";
2310    let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2311
2312    Ok(MigrationResult {
2313        output,
2314        changed_count: 1,
2315        sections_changed: vec!["mcp.elicitation".to_owned()],
2316    })
2317}
2318
2319/// Add a commented-out `max_connect_attempts` key under `[mcp]` if absent (#3568).
2320///
2321/// This key was introduced alongside the MCP startup auto-retry feature. All prior
2322/// configs omit it and get the default value of `3`. This migration surfaces the key
2323/// as a comment so users can discover and tune it.
2324///
2325/// # Errors
2326///
2327/// Returns `Ok` with unchanged output when the key is already present or `[mcp]` is absent.
2328pub fn migrate_mcp_max_connect_attempts(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2329    if toml_src.contains("max_connect_attempts") {
2330        return Ok(MigrationResult {
2331            output: toml_src.to_owned(),
2332            changed_count: 0,
2333            sections_changed: Vec::new(),
2334        });
2335    }
2336
2337    if !toml_src.contains("[mcp]\n") {
2338        return Ok(MigrationResult {
2339            output: toml_src.to_owned(),
2340            changed_count: 0,
2341            sections_changed: Vec::new(),
2342        });
2343    }
2344
2345    let comment = "# max_connect_attempts = 3  \
2346        # startup retry count per server (1 = no retry, 1..=10, backoff: 500ms/1s/2s/...)\n";
2347    let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2348
2349    Ok(MigrationResult {
2350        output,
2351        changed_count: 1,
2352        sections_changed: vec!["mcp".to_owned()],
2353    })
2354}
2355
2356/// Add a commented-out `[quality]` block if the config lacks it (#3228).
2357///
2358/// Introduced alongside the MARCH self-check pipeline (#3226). All `QualityConfig`
2359/// fields have `#[serde(default)]` so existing configs parse without changes; this
2360/// migration only surfaces the section so users can discover and enable it.
2361///
2362/// # Errors
2363///
2364/// This function is infallible in practice; the `Result` return type matches the
2365/// migration function convention for use in chained pipelines.
2366pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2367    // Idempotency: line-anchored check avoids false-positives on [quality.foo] subtables.
2368    if toml_src
2369        .lines()
2370        .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2371    {
2372        return Ok(MigrationResult {
2373            output: toml_src.to_owned(),
2374            changed_count: 0,
2375            sections_changed: Vec::new(),
2376        });
2377    }
2378
2379    let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2380         # [quality]\n\
2381         # self_check = false                    # enable post-response self-check\n\
2382         # trigger = \"has_retrieval\"             # has_retrieval | always | manual\n\
2383         # latency_budget_ms = 4000              # hard ceiling for the whole pipeline\n\
2384         # proposer_provider = \"\"                # optional: provider name from [[llm.providers]]\n\
2385         # checker_provider = \"\"                 # optional: provider name from [[llm.providers]]\n\
2386         # min_evidence = 0.6                    # 0.0..1.0; below → flag assertion\n\
2387         # async_run = false                     # true = fire-and-forget (non-blocking)\n\
2388         # per_call_timeout_ms = 2000            # per-LLM-call timeout\n\
2389         # max_assertions = 12                   # maximum assertions extracted from one response\n\
2390         # max_response_chars = 8000             # skip pipeline when response exceeds this\n\
2391         # cache_disabled_for_checker = true     # suppress prompt-cache on Checker provider\n\
2392         # flag_marker = \"[verify]\"              # marker appended when assertions are flagged\n";
2393    let output = format!("{toml_src}{comment}");
2394
2395    Ok(MigrationResult {
2396        output,
2397        changed_count: 1,
2398        sections_changed: vec!["quality".to_owned()],
2399    })
2400}
2401
2402/// Add a commented-out `[acp.subagents]` block if the config lacks it (#3304).
2403///
2404/// Introduced alongside the ACP sub-agent delegation feature (#3289). All `AcpSubagentsConfig`
2405/// fields have `#[serde(default)]` so existing configs parse without changes; this migration
2406/// only surfaces the section so users can discover and enable it.
2407///
2408/// # Errors
2409///
2410/// This function is infallible in practice; the `Result` return type matches the
2411/// migration function convention for use in chained pipelines.
2412pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2413    if toml_src
2414        .lines()
2415        .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2416    {
2417        return Ok(MigrationResult {
2418            output: toml_src.to_owned(),
2419            changed_count: 0,
2420            sections_changed: Vec::new(),
2421        });
2422    }
2423
2424    let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2425         # [acp.subagents]\n\
2426         # enabled = false\n\
2427         #\n\
2428         # [[acp.subagents.presets]]\n\
2429         # name = \"inner\"                         # identifier used in /subagent commands\n\
2430         # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2431         # # cwd = \"/path/to/agent\"              # optional working directory\n\
2432         # # handshake_timeout_secs = 30          # initialize+session/new timeout\n\
2433         # # prompt_timeout_secs = 600            # single round-trip timeout\n";
2434    let output = format!("{toml_src}{comment}");
2435
2436    Ok(MigrationResult {
2437        output,
2438        changed_count: 1,
2439        sections_changed: vec!["acp.subagents".to_owned()],
2440    })
2441}
2442
2443/// Add a commented-out `[[hooks.permission_denied]]` block if the config lacks it (#3309).
2444///
2445/// Introduced alongside the reactive env hooks and MCP tool dispatch feature (#3303).
2446/// All hook arrays have `#[serde(default)]` so existing configs parse without changes;
2447/// this migration surfaces the section for discoverability.
2448///
2449/// # Errors
2450///
2451/// This function is infallible in practice; the `Result` return type matches the
2452/// migration function convention for use in chained pipelines.
2453pub fn migrate_hooks_permission_denied_config(
2454    toml_src: &str,
2455) -> Result<MigrationResult, MigrateError> {
2456    if toml_src.lines().any(|l| {
2457        l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2458    }) {
2459        return Ok(MigrationResult {
2460            output: toml_src.to_owned(),
2461            changed_count: 0,
2462            sections_changed: Vec::new(),
2463        });
2464    }
2465
2466    let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2467         # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2468         # [[hooks.permission_denied]]\n\
2469         # [hooks.permission_denied.action]\n\
2470         # type = \"command\"\n\
2471         # command = \"echo denied: $ZEPH_TOOL\"\n";
2472    let output = format!("{toml_src}{comment}");
2473
2474    Ok(MigrationResult {
2475        output,
2476        changed_count: 1,
2477        sections_changed: vec!["hooks.permission_denied".to_owned()],
2478    })
2479}
2480
2481/// Add commented-out `[memory.graph]` retrieval strategy options if the config lacks them (#3317).
2482///
2483/// Introduced alongside the multi-strategy graph retrieval and experience memory feature (#3311).
2484/// All `MemoryGraphConfig` fields have `#[serde(default)]` so existing configs parse without
2485/// changes; this migration surfaces the new options for discoverability.
2486///
2487/// # Errors
2488///
2489/// This function is infallible in practice; the `Result` return type matches the
2490/// migration function convention for use in chained pipelines.
2491pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2492    if toml_src.contains("retrieval_strategy")
2493        || toml_src.contains("[memory.graph.beam_search]")
2494        || toml_src.contains("# [memory.graph.beam_search]")
2495    {
2496        return Ok(MigrationResult {
2497            output: toml_src.to_owned(),
2498            changed_count: 0,
2499            sections_changed: Vec::new(),
2500        });
2501    }
2502
2503    let comment = "\n# [memory.graph] retrieval strategy options (#3311).\n\
2504         # retrieval_strategy = \"synapse\"    # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2505         #\n\
2506         # [memory.graph.beam_search]        # active when retrieval_strategy = \"beam_search\"\n\
2507         # beam_width = 10                   # top-K candidates kept per hop\n\
2508         #\n\
2509         # [memory.graph.watercircles]       # active when retrieval_strategy = \"watercircles\"\n\
2510         # ring_limit = 0                    # max facts per ring; 0 = auto\n\
2511         #\n\
2512         # [memory.graph.experience]         # experience memory recording\n\
2513         # enabled = false\n\
2514         # evolution_sweep_enabled = false\n\
2515         # confidence_prune_threshold = 0.1  # prune edges below this threshold\n\
2516         # evolution_sweep_interval = 50     # turns between sweeps\n";
2517    let output = format!("{toml_src}{comment}");
2518
2519    Ok(MigrationResult {
2520        output,
2521        changed_count: 1,
2522        sections_changed: vec!["memory.graph.retrieval".to_owned()],
2523    })
2524}
2525
2526/// Add a commented-out `[scheduler.daemon]` block if the config lacks it (#3332).
2527///
2528/// Introduced alongside the `zeph serve` daemon mode (#3332). All `DaemonConfig` fields
2529/// have defaults so existing configs parse without changes; this migration surfaces the
2530/// section so users can discover and configure the daemon process.
2531///
2532/// # Errors
2533///
2534/// This function is infallible in practice; the `Result` return type matches the
2535/// migration function convention for use in chained pipelines.
2536pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2537    if toml_src
2538        .lines()
2539        .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2540    {
2541        return Ok(MigrationResult {
2542            output: toml_src.to_owned(),
2543            changed_count: 0,
2544            sections_changed: Vec::new(),
2545        });
2546    }
2547
2548    let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2549         # [scheduler.daemon]\n\
2550         # pid_file = \"/tmp/zeph-scheduler.pid\"   # PID file path (must be on a local filesystem)\n\
2551         # log_file = \"/tmp/zeph-scheduler.log\"   # daemon log file path (append-only; rotate externally)\n\
2552         # tick_secs = 60                           # scheduler tick interval in seconds (clamped 5..=3600)\n\
2553         # shutdown_grace_secs = 30                 # grace period after SIGTERM before process exits\n\
2554         # catch_up = true                          # replay missed cron tasks on daemon restart\n";
2555    let output = format!("{toml_src}{comment}");
2556
2557    Ok(MigrationResult {
2558        output,
2559        changed_count: 1,
2560        sections_changed: vec!["scheduler.daemon".to_owned()],
2561    })
2562}
2563
2564/// Add a commented-out `[memory.retrieval]` block if the config lacks it (#3340).
2565///
2566/// MemMachine-inspired retrieval-stage tuning: ANN candidate depth, search-prompt template,
2567/// and context snippet format. All fields have defaults so existing configs parse unchanged;
2568/// this migration surfaces the section for discoverability.
2569///
2570/// # Errors
2571///
2572/// This function is infallible in practice; the `Result` return type matches the migration
2573/// function convention for use in chained pipelines.
2574pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2575    if toml_src
2576        .lines()
2577        .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2578    {
2579        return Ok(MigrationResult {
2580            output: toml_src.to_owned(),
2581            changed_count: 0,
2582            sections_changed: Vec::new(),
2583        });
2584    }
2585
2586    let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2587         # [memory.retrieval]\n\
2588         # depth = 0                          # ANN candidates fetched from the vector store, directly.\n\
2589         #                                    # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2590         #                                    # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2591         # search_prompt_template = \"\"        # embedding query template; {query} = raw user query; empty = identity\n\
2592         # context_format = \"structured\"      # structured | plain — memory snippet rendering format\n\
2593         # query_bias_correction = true        # shift first-person queries towards user profile centroid (MM-F3)\n\
2594         # query_bias_profile_weight = 0.25    # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2595         # query_bias_centroid_ttl_secs = 300  # seconds before profile centroid cache is recomputed\n";
2596    let output = format!("{toml_src}{comment}");
2597
2598    Ok(MigrationResult {
2599        output,
2600        changed_count: 1,
2601        sections_changed: vec!["memory.retrieval".to_owned()],
2602    })
2603}
2604
2605/// Add a commented-out `[memory.reasoning]` block if the config lacks it (#3369).
2606///
2607/// `ReasoningBank` distilled strategy memory was added in v0.19.3 (commit b99b2d30).
2608/// All fields have defaults so existing configs parse unchanged; this migration
2609/// surfaces the section for discoverability.
2610///
2611/// # Errors
2612///
2613/// This function is infallible in practice; the `Result` return type matches the migration
2614/// function convention for use in chained pipelines.
2615pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2616    if toml_src
2617        .lines()
2618        .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2619    {
2620        return Ok(MigrationResult {
2621            output: toml_src.to_owned(),
2622            changed_count: 0,
2623            sections_changed: Vec::new(),
2624        });
2625    }
2626
2627    let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2628         # [memory.reasoning]\n\
2629         # enabled = false\n\
2630         # extract_provider = \"\"         # SLM: self-judge (JSON response) — leave blank to use primary\n\
2631         # distill_provider = \"\"         # SLM: strategy distillation — leave blank to use primary\n\
2632         # top_k = 3                      # strategies injected per turn\n\
2633         # store_limit = 1000             # max rows in reasoning_strategies table\n\
2634         # context_budget_tokens = 500\n\
2635         # extraction_timeout_secs = 30\n\
2636         # distill_timeout_secs = 30\n\
2637         # max_messages = 6\n\
2638         # min_messages = 2\n\
2639         # max_message_chars = 2000\n";
2640    let output = format!("{toml_src}{comment}");
2641
2642    Ok(MigrationResult {
2643        output,
2644        changed_count: 1,
2645        sections_changed: vec!["memory.reasoning".to_owned()],
2646    })
2647}
2648
2649/// Insert commented-out `self_judge_window` and `min_assistant_chars` keys under an existing
2650/// `[memory.reasoning]` block when they are absent (#3383).
2651///
2652/// Configs that lack a `[memory.reasoning]` section are returned unchanged (the
2653/// [`migrate_memory_reasoning_config`] step is responsible for adding the section).
2654/// Idempotent when either key is already present.
2655///
2656/// # Errors
2657///
2658/// This function is infallible in practice; the `Result` return type matches the migration
2659/// function convention for use in chained pipelines.
2660pub fn migrate_memory_reasoning_judge_config(
2661    toml_src: &str,
2662) -> Result<MigrationResult, MigrateError> {
2663    let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2664    if !has_section {
2665        return Ok(MigrationResult {
2666            output: toml_src.to_owned(),
2667            changed_count: 0,
2668            sections_changed: Vec::new(),
2669        });
2670    }
2671
2672    // Check if both keys are already present (active or commented).
2673    let has_window = toml_src.lines().any(|l| {
2674        let t = l.trim().trim_start_matches('#').trim();
2675        t.starts_with("self_judge_window")
2676    });
2677    let has_min_chars = toml_src.lines().any(|l| {
2678        let t = l.trim().trim_start_matches('#').trim();
2679        t.starts_with("min_assistant_chars")
2680    });
2681    if has_window && has_min_chars {
2682        return Ok(MigrationResult {
2683            output: toml_src.to_owned(),
2684            changed_count: 0,
2685            sections_changed: Vec::new(),
2686        });
2687    }
2688
2689    // Append the new keys after the last line belonging to [memory.reasoning].
2690    // Strategy: find the last line of the [memory.reasoning] block (before the next section
2691    // header) and insert the commented-out keys after it.
2692    let lines: Vec<&str> = toml_src.lines().collect();
2693    let mut section_start = None;
2694    let mut insert_after = None;
2695
2696    for (i, line) in lines.iter().enumerate() {
2697        if line.trim() == "[memory.reasoning]" {
2698            section_start = Some(i);
2699        }
2700        if let Some(start) = section_start {
2701            let trimmed = line.trim();
2702            // A new top-level section header ends the current section.
2703            if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2704                break;
2705            }
2706            insert_after = Some(i);
2707        }
2708    }
2709
2710    let Some(insert_idx) = insert_after else {
2711        return Ok(MigrationResult {
2712            output: toml_src.to_owned(),
2713            changed_count: 0,
2714            sections_changed: Vec::new(),
2715        });
2716    };
2717
2718    let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2719    let mut additions = Vec::new();
2720    if !has_window {
2721        additions.push(
2722            "# self_judge_window = 2   # max recent messages passed to self-judge (#3383)"
2723                .to_owned(),
2724        );
2725    }
2726    if !has_min_chars {
2727        additions.push(
2728            "# min_assistant_chars = 50  # skip self-judge for short replies (#3383)".to_owned(),
2729        );
2730    }
2731    for (offset, line) in additions.iter().enumerate() {
2732        new_lines.insert(insert_idx + 1 + offset, line.clone());
2733    }
2734
2735    let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2736    Ok(MigrationResult {
2737        output,
2738        changed_count: additions.len(),
2739        sections_changed: vec!["memory.reasoning".to_owned()],
2740    })
2741}
2742
2743/// Append a commented-out `[memory.hebbian]` block to `toml_src` when it is absent (HL-F1/F2, #3344).
2744///
2745/// Idempotent: if a `[memory.hebbian]` or `# [memory.hebbian]` line already exists,
2746/// the input is returned unchanged with `changed_count = 0`.
2747///
2748/// # Errors
2749///
2750/// This function is infallible in practice; the `Result` return type matches the migration
2751/// function convention for use in chained pipelines.
2752pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2753    if toml_src
2754        .lines()
2755        .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2756    {
2757        return Ok(MigrationResult {
2758            output: toml_src.to_owned(),
2759            changed_count: 0,
2760            sections_changed: Vec::new(),
2761        });
2762    }
2763
2764    let comment = "\n# [memory.hebbian]                       # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2765         # [memory.hebbian]\n\
2766         # enabled = false                        # opt-in master switch; no DB writes when false\n\
2767         # hebbian_lr = 0.1                       # weight increment per co-activation (0.01–0.5)\n";
2768    let output = format!("{toml_src}{comment}");
2769
2770    Ok(MigrationResult {
2771        output,
2772        changed_count: 1,
2773        sections_changed: vec!["memory.hebbian".to_owned()],
2774    })
2775}
2776
2777/// Splice missing HL-F3/F4 consolidation fields into an existing `[memory.hebbian]` section
2778/// (HL-F3/F4, #3345).
2779///
2780/// Three branches:
2781/// - Section absent → no-op (handled by `migrate_memory_hebbian_config`).
2782/// - Section present but missing consolidation fields → append commented-out defaults.
2783/// - Section present with all fields → no-op.
2784///
2785/// # Errors
2786///
2787/// Infallible in practice; `Result` matches the migration convention.
2788pub fn migrate_memory_hebbian_consolidation_config(
2789    toml_src: &str,
2790) -> Result<MigrationResult, MigrateError> {
2791    let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2792
2793    if !has_section {
2794        return Ok(MigrationResult {
2795            output: toml_src.to_owned(),
2796            changed_count: 0,
2797            sections_changed: Vec::new(),
2798        });
2799    }
2800
2801    // Check if all consolidation fields already present (active or commented).
2802    let has_interval = toml_src
2803        .lines()
2804        .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2805    let has_threshold = toml_src
2806        .lines()
2807        .any(|l| l.trim().starts_with("consolidation_threshold"));
2808    let has_provider = toml_src
2809        .lines()
2810        .any(|l| l.trim().starts_with("consolidate_provider"));
2811
2812    if has_interval && has_threshold && has_provider {
2813        return Ok(MigrationResult {
2814            output: toml_src.to_owned(),
2815            changed_count: 0,
2816            sections_changed: Vec::new(),
2817        });
2818    }
2819
2820    let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2821        # consolidation_interval_secs = 3600   # how often the sweep runs (0 = disabled)\n\
2822        # consolidation_threshold = 5.0        # degree × avg_weight score to qualify\n\
2823        # consolidate_provider = \"fast\"        # provider name for LLM distillation\n\
2824        # max_candidates_per_sweep = 10\n\
2825        # consolidation_cooldown_secs = 86400  # re-consolidation cooldown per entity\n\
2826        # consolidation_prompt_timeout_secs = 30\n\
2827        # consolidation_max_neighbors = 20\n";
2828
2829    let output = format!("{toml_src}{extra}");
2830    Ok(MigrationResult {
2831        output,
2832        changed_count: 1,
2833        sections_changed: vec!["memory.hebbian".to_owned()],
2834    })
2835}
2836
2837/// Splice missing HL-F5 spreading-activation fields into an existing `[memory.hebbian]` section
2838/// (HL-F5, #3346).
2839///
2840/// Three branches:
2841/// - Section absent → no-op (handled by `migrate_memory_hebbian_config`).
2842/// - Section present but missing HL-F5 fields → append commented-out defaults.
2843/// - Section present with all fields → no-op.
2844///
2845/// # Errors
2846///
2847/// Infallible in practice; `Result` matches the migration convention.
2848pub fn migrate_memory_hebbian_spread_config(
2849    toml_src: &str,
2850) -> Result<MigrationResult, MigrateError> {
2851    let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2852
2853    if !has_section {
2854        return Ok(MigrationResult {
2855            output: toml_src.to_owned(),
2856            changed_count: 0,
2857            sections_changed: Vec::new(),
2858        });
2859    }
2860
2861    // Check if all HL-F5 fields are already present (active or commented).
2862    let has_spreading = toml_src
2863        .lines()
2864        .any(|l| l.trim().starts_with("spreading_activation"));
2865    let has_depth = toml_src
2866        .lines()
2867        .any(|l| l.trim().starts_with("spread_depth"));
2868    let has_budget = toml_src
2869        .lines()
2870        .any(|l| l.trim().starts_with("step_budget_ms"));
2871    let has_embed_timeout = toml_src
2872        .lines()
2873        .any(|l| l.trim().starts_with("embed_timeout_secs"));
2874
2875    if has_spreading && has_depth && has_budget && has_embed_timeout {
2876        return Ok(MigrationResult {
2877            output: toml_src.to_owned(),
2878            changed_count: 0,
2879            sections_changed: Vec::new(),
2880        });
2881    }
2882
2883    let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2884        # spreading_activation = false   # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2885        # spread_depth = 2               # BFS hops, clamped [1,6]\n\
2886        # spread_edge_types = []         # MAGMA edge types to traverse; empty = all\n\
2887        # step_budget_ms = 8             # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n\
2888        # embed_timeout_secs = 5         # timeout for the initial query embedding call (0 = disabled)\n";
2889
2890    let output = format!("{toml_src}{extra}");
2891    Ok(MigrationResult {
2892        output,
2893        changed_count: 1,
2894        sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2895    })
2896}
2897
2898/// Append a commented-out `[[hooks.turn_complete]]` block to `toml_src` when it is absent (#3308).
2899///
2900/// Idempotent: if a `[[hooks.turn_complete]]` or `# [[hooks.turn_complete]]` line already exists,
2901/// the input is returned unchanged with `changed_count = 0`.
2902///
2903/// The template uses a single `command` string (not `args`) to match the `HookAction::Command`
2904/// schema, and avoids embedding `$ZEPH_TURN_PREVIEW` directly in the command string to prevent
2905/// shell injection.
2906///
2907/// # Errors
2908///
2909/// This function is infallible in practice; the `Result` return type matches the migration
2910/// function convention for use in chained pipelines.
2911pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2912    if toml_src
2913        .lines()
2914        .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2915    {
2916        return Ok(MigrationResult {
2917            output: toml_src.to_owned(),
2918            changed_count: 0,
2919            sections_changed: Vec::new(),
2920        });
2921    }
2922
2923    let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2924         # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2925         # ZEPH_TURN_LLM_REQUESTS.\n\
2926         # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2927         # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2928         # [[hooks.turn_complete]]\n\
2929         # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2930         # timeout_secs = 3\n\
2931         # fail_closed = false\n";
2932    let output = format!("{toml_src}{comment}");
2933
2934    Ok(MigrationResult {
2935        output,
2936        changed_count: 1,
2937        sections_changed: vec!["hooks.turn_complete".to_owned()],
2938    })
2939}
2940
2941/// Inject a commented-out `auto_consolidate_min_window` key into `[agent.focus]` if absent (#3313).
2942///
2943/// All `FocusConfig` fields have `#[serde(default)]`, so existing configs deserialize without
2944/// changes. This step surfaces the new field for users upgrading from older configs.
2945///
2946/// The comment is inserted *inside* the `[agent.focus]` section using [`insert_after_section`],
2947/// so it ends up in the correct table regardless of where that section appears in the file.
2948///
2949/// Idempotent: if `auto_consolidate_min_window` already appears anywhere in the source,
2950/// the input is returned unchanged with `changed_count = 0`.
2951/// No-op when `[agent.focus]` is absent or only exists as a comment line.
2952///
2953/// # Errors
2954///
2955/// This function is infallible in practice; the `Result` return type matches the migration
2956/// function convention for use in chained pipelines.
2957pub fn migrate_focus_auto_consolidate_min_window(
2958    toml_src: &str,
2959) -> Result<MigrationResult, MigrateError> {
2960    if toml_src.contains("auto_consolidate_min_window") {
2961        return Ok(MigrationResult {
2962            output: toml_src.to_owned(),
2963            changed_count: 0,
2964            sections_changed: Vec::new(),
2965        });
2966    }
2967
2968    // Only inject when [agent.focus] exists as a live section (not a comment).
2969    if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
2970        return Ok(MigrationResult {
2971            output: toml_src.to_owned(),
2972            changed_count: 0,
2973            sections_changed: Vec::new(),
2974        });
2975    }
2976
2977    let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
2978         runs (#3313).\n\
2979         # auto_consolidate_min_window = 6\n";
2980    let output = insert_after_section(toml_src, "agent.focus", comment);
2981
2982    Ok(MigrationResult {
2983        output,
2984        changed_count: 1,
2985        sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
2986    })
2987}
2988
2989/// Add `[session]` with `provider_persistence = true` to configs that lack the section (#3308).
2990///
2991/// Provider persistence was verified stable in CI-608 (restored persisted provider preference
2992/// from `SQLite`). Configs that already declare `[session]` or the commented `# [session]` are
2993/// returned unchanged.
2994///
2995/// # Errors
2996///
2997/// Infallible in practice; `Result` matches the migration convention.
2998pub fn migrate_session_provider_persistence(
2999    toml_src: &str,
3000) -> Result<MigrationResult, MigrateError> {
3001    if toml_src
3002        .lines()
3003        .any(|l| l.trim() == "[session]" || l.trim() == "# [session]")
3004    {
3005        return Ok(MigrationResult {
3006            output: toml_src.to_owned(),
3007            changed_count: 0,
3008            sections_changed: Vec::new(),
3009        });
3010    }
3011
3012    let comment = "\n# [session] — session-scoped user experience settings (#3308).\n\
3013         [session]\n\
3014         # Persist the last-used provider per channel across restarts.\n\
3015         # When true, the agent saves the active provider name to SQLite after each\n\
3016         # /provider switch and restores it on the next session start for the same channel.\n\
3017         provider_persistence = true\n";
3018    let output = format!("{toml_src}{comment}");
3019
3020    Ok(MigrationResult {
3021        output,
3022        changed_count: 1,
3023        sections_changed: vec!["session".to_owned()],
3024    })
3025}
3026
3027/// Add `[memory.retrieval]` with `query_bias_correction = true` if the section is absent.
3028///
3029/// `query_bias_correction` shifts first-person queries toward the user profile centroid
3030/// (MM-F3, #3341) and is verified working in CI-604/CI-605. It is a no-op when the persona
3031/// table is empty, so enabling it by default is safe.
3032///
3033/// Idempotent: the section header (live or commented) suppresses re-injection.
3034///
3035/// # Errors
3036///
3037/// Infallible in practice; `Result` matches the migration convention.
3038pub fn migrate_memory_retrieval_query_bias(
3039    toml_src: &str,
3040) -> Result<MigrationResult, MigrateError> {
3041    // Already handled by migrate_memory_retrieval_config if the whole section is absent.
3042    // This step only splices the key into an existing [memory.retrieval] section.
3043    if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
3044        return Ok(MigrationResult {
3045            output: toml_src.to_owned(),
3046            changed_count: 0,
3047            sections_changed: Vec::new(),
3048        });
3049    }
3050
3051    // Idempotent: key already present (active or as comment).
3052    if toml_src
3053        .lines()
3054        .any(|l| l.trim().starts_with("query_bias_correction"))
3055    {
3056        return Ok(MigrationResult {
3057            output: toml_src.to_owned(),
3058            changed_count: 0,
3059            sections_changed: Vec::new(),
3060        });
3061    }
3062
3063    let comment = "\n# MM-F3 (#3341): shift first-person queries toward the user profile centroid.\n\
3064         # No-op when the persona table is empty.\n\
3065         # query_bias_correction = true\n";
3066    let output = insert_after_section(toml_src, "memory.retrieval", comment);
3067
3068    Ok(MigrationResult {
3069        output,
3070        changed_count: 1,
3071        sections_changed: vec!["memory.retrieval.query_bias_correction".to_owned()],
3072    })
3073}
3074
3075/// Add a commented-out `[memory.persona]` stub to configs that lack the section.
3076///
3077/// The persona profile drives query-bias correction (MM-F3, #3341) and is verified working
3078/// in CI-604/CI-605. Adding the stub makes the section discoverable via `migrate-config`.
3079///
3080/// # Errors
3081///
3082/// Infallible in practice; `Result` matches the migration convention.
3083pub fn migrate_memory_persona_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3084    if toml_src
3085        .lines()
3086        .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]")
3087    {
3088        return Ok(MigrationResult {
3089            output: toml_src.to_owned(),
3090            changed_count: 0,
3091            sections_changed: Vec::new(),
3092        });
3093    }
3094
3095    let comment = "\n# [memory.persona] — user persona profile for query-bias correction (#3341).\n\
3096         # Verified working in CI-604/CI-605. No-op when disabled.\n\
3097         # [memory.persona]\n\
3098         # enabled = true\n\
3099         # min_messages = 2       # minimum user messages before persona extraction fires\n\
3100         # min_confidence = 0.5   # minimum extraction confidence threshold (0.0–1.0)\n";
3101    let output = format!("{toml_src}{comment}");
3102
3103    Ok(MigrationResult {
3104        output,
3105        changed_count: 1,
3106        sections_changed: vec!["memory.persona".to_owned()],
3107    })
3108}
3109
3110/// No-op migration for the optional `qdrant_api_key` field added in #3543.
3111///
3112/// The field has `#[serde(default)]` so existing configs parse as `None` without changes.
3113/// This step adds a commented-out hint under `[memory]` if not already present.
3114///
3115/// # Errors
3116///
3117/// Returns `MigrateError` if the TOML cannot be parsed or `[memory]` is malformed.
3118pub fn migrate_qdrant_api_key(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3119    if toml_src.contains("qdrant_api_key") {
3120        return Ok(MigrationResult {
3121            output: toml_src.to_owned(),
3122            changed_count: 0,
3123            sections_changed: Vec::new(),
3124        });
3125    }
3126
3127    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3128
3129    if !doc.contains_key("memory") {
3130        doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
3131    }
3132
3133    let comment = "\n# Qdrant API key (optional; required when connecting to remote/managed Qdrant clusters).\n\
3134         # Leave empty for local Qdrant instances. Store the actual key in the vault:\n\
3135         #   zeph vault set ZEPH_QDRANT_API_KEY \"<key>\"\n\
3136         # qdrant_api_key = \"\"\n";
3137    let raw = doc.to_string();
3138    let output = format!("{raw}{comment}");
3139
3140    Ok(MigrationResult {
3141        output,
3142        changed_count: 1,
3143        sections_changed: vec!["memory.qdrant_api_key".to_owned()],
3144    })
3145}
3146
3147/// Add the `[goals]` section as commented-out defaults when it is absent.
3148///
3149/// # Errors
3150///
3151/// Returns [`MigrateError::Parse`] when `toml_src` is not valid TOML.
3152pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3153    if toml_src.contains("[goals]") {
3154        return Ok(MigrationResult {
3155            output: toml_src.to_owned(),
3156            changed_count: 0,
3157            sections_changed: Vec::new(),
3158        });
3159    }
3160
3161    let comment = "\n# Long-horizon goal lifecycle tracking (#3567).\n\
3162         # [goals]\n\
3163         # enabled = false\n\
3164         # inject_into_system_prompt = true\n\
3165         # max_text_chars = 2000\n\
3166         # max_history = 50\n";
3167
3168    Ok(MigrationResult {
3169        output: format!("{toml_src}{comment}"),
3170        changed_count: 1,
3171        sections_changed: vec!["goals".to_owned()],
3172    })
3173}
3174
3175/// Add the `[tools.compression]` section as commented-out defaults when it is absent.
3176///
3177/// # Errors
3178///
3179/// Returns [`MigrateError::Parse`] when `toml_src` is not valid TOML.
3180pub fn migrate_tools_compression_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3181    if toml_src.contains("tools.compression")
3182        || toml_src.contains("[tools]\n") && toml_src.contains("compression")
3183    {
3184        return Ok(MigrationResult {
3185            output: toml_src.to_owned(),
3186            changed_count: 0,
3187            sections_changed: Vec::new(),
3188        });
3189    }
3190
3191    let comment = "\n# TACO self-evolving tool output compression (#3306).\n\
3192         # [tools.compression]\n\
3193         # enabled = false\n\
3194         # min_lines_to_compress = 10\n\
3195         # evolution_provider = \"\"\n\
3196         # evolution_min_interval_secs = 3600\n\
3197         # max_rules = 200\n";
3198
3199    Ok(MigrationResult {
3200        output: format!("{toml_src}{comment}"),
3201        changed_count: 1,
3202        sections_changed: vec!["tools.compression".to_owned()],
3203    })
3204}
3205
3206/// Add `orchestrator_provider` as a commented-out entry in `[orchestration]` when absent.
3207///
3208/// # Errors
3209///
3210/// Returns [`MigrateError::Parse`] when `toml_src` is not valid TOML.
3211pub fn migrate_orchestration_orchestrator_provider(
3212    toml_src: &str,
3213) -> Result<MigrationResult, MigrateError> {
3214    if toml_src.contains("orchestrator_provider") {
3215        return Ok(MigrationResult {
3216            output: toml_src.to_owned(),
3217            changed_count: 0,
3218            sections_changed: Vec::new(),
3219        });
3220    }
3221
3222    let comment = "\n# Provider for scheduling-tier LLM calls (aggregation, predicate, verify fallback).\n\
3223         # Set to a cheap/fast model to reduce orchestration cost. Empty = primary provider.\n\
3224         # Add under the orchestration section in your config:\n\
3225         # orchestrator_provider = \"\"\n";
3226
3227    Ok(MigrationResult {
3228        output: format!("{toml_src}{comment}"),
3229        changed_count: 1,
3230        sections_changed: vec!["orchestration".to_owned()],
3231    })
3232}
3233
3234/// Add a commented-out `max_concurrent` hint to `[[llm.providers]]` entries when absent.
3235///
3236/// `max_concurrent` limits how many orchestrated sub-agent calls may be in-flight to a
3237/// given provider simultaneously. The field is optional (`None` = unlimited), so existing
3238/// configs continue to work without changes.
3239///
3240/// # Errors
3241///
3242/// Returns [`MigrateError::Parse`] when `toml_src` is not valid TOML.
3243pub fn migrate_provider_max_concurrent(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3244    if toml_src.contains("max_concurrent") {
3245        return Ok(MigrationResult {
3246            output: toml_src.to_owned(),
3247            changed_count: 0,
3248            sections_changed: Vec::new(),
3249        });
3250    }
3251
3252    if !toml_src.contains("[[llm.providers]]") {
3253        return Ok(MigrationResult {
3254            output: toml_src.to_owned(),
3255            changed_count: 0,
3256            sections_changed: Vec::new(),
3257        });
3258    }
3259
3260    let comment = "\n# Optional: maximum concurrent sub-agent calls to this provider (admission control).\n\
3261         # Remove the comment to enable; omit or set to 0 for unlimited.\n\
3262         # max_concurrent = 4\n";
3263
3264    Ok(MigrationResult {
3265        output: format!("{toml_src}{comment}"),
3266        changed_count: 1,
3267        sections_changed: vec!["llm.providers".to_owned()],
3268    })
3269}
3270
3271// ── Migration trait and registry ────────────────────────────────────────────────────────────────
3272
3273/// A single idempotent config migration step.
3274///
3275/// Each impl wraps one of the free-standing `migrate_*` functions and gives it a stable
3276/// name used in logs and test assertions. The trait is object-safe so that steps can be
3277/// stored in a `Vec<Box<dyn Migration + Send + Sync>>`.
3278///
3279/// # Contract for implementors
3280///
3281/// - `apply` **must** be idempotent: calling it twice on the same source must return the
3282///   same output as calling it once.
3283/// - On a no-op (nothing to migrate), `apply` returns a [`MigrationResult`] with
3284///   `changed_count == 0`.
3285///
3286/// # Examples
3287///
3288/// ```rust
3289/// use zeph_config::migrate::{Migration, MIGRATIONS};
3290///
3291/// // The registry is ordered chronologically; apply each step in sequence.
3292/// let mut toml = "[agent]\nname = \"zeph\"\n".to_owned();
3293/// for m in MIGRATIONS.iter() {
3294///     toml = m.apply(&toml).expect("migration failed").output;
3295/// }
3296/// ```
3297pub trait Migration: Send + Sync {
3298    /// Human-readable identifier used in diagnostics and ordering assertions.
3299    fn name(&self) -> &'static str;
3300
3301    /// Apply this migration step to `toml_src`.
3302    ///
3303    /// # Errors
3304    ///
3305    /// Propagates any [`MigrateError`] from the underlying free function.
3306    fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
3307}
3308
3309mod steps;
3310use steps::{
3311    MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
3312    MigrateAutodreamConfig, MigrateCocoonProviderNotice, MigrateCompressionPredictorConfig,
3313    MigrateDatabaseUrl, MigrateEgressConfig, MigrateFocusAutoConsolidateMinWindow,
3314    MigrateForgettingConfig, MigrateGoalsConfig, MigrateGonkagateToGonka,
3315    MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateMagicDocsConfig,
3316    MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpTrustLevels,
3317    MigrateMemoryGraph, MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation,
3318    MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig, MigrateMemoryReasoning,
3319    MigrateMemoryReasoningJudge, MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias,
3320    MigrateMicrocompactConfig, MigrateOrchestrationPersistence, MigrateOrchestratorProvider,
3321    MigrateOtelFilter, MigratePlannerModelToProvider, MigrateProviderMaxConcurrent,
3322    MigrateQdrantApiKey, MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter,
3323    MigrateSchedulerDaemon, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
3324    MigrateShellTransactional, MigrateSttToProvider, MigrateSupervisorConfig,
3325    MigrateTelemetryConfig, MigrateToolsCompressionConfig, MigrateTraceMetadata,
3326    MigrateVigilConfig,
3327};
3328
3329/// Step 45: add an advisory comment above `GonkaGate` provider entries pointing users to
3330/// the native Gonka provider option.
3331///
3332/// This is advisory only — no automatic conversion is performed. The comment informs
3333/// users that a native Gonka provider is now available and links to migration docs.
3334pub(crate) fn migrate_gonkagate_to_gonka(toml_src: &str) -> MigrationResult {
3335    // Advisory-only: add a comment before GonkaGate provider entries when found.
3336    const MARKER: &str = "# [migration] GonkaGate detected: consider migrating to type = \"gonka\"";
3337
3338    if !toml_src.contains("gonkagate") {
3339        return MigrationResult {
3340            output: toml_src.to_owned(),
3341            changed_count: 0,
3342            sections_changed: vec![],
3343        };
3344    }
3345
3346    let mut changed_count = 0;
3347    let mut lines: Vec<String> = toml_src.lines().map(str::to_owned).collect();
3348
3349    // Walk backwards from each line containing "gonkagate" to find the nearest preceding
3350    // [[llm.providers]] table header and insert the advisory comment before it.
3351    // We iterate indices in reverse so that inserting at a position does not shift later targets.
3352    let indices: Vec<usize> = lines
3353        .iter()
3354        .enumerate()
3355        .filter(|(_, l)| l.contains("gonkagate"))
3356        .map(|(i, _)| i)
3357        .rev()
3358        .collect();
3359
3360    for gonka_idx in indices {
3361        // Find the most recent [[...]] header at or before gonka_idx.
3362        let header_idx = (0..=gonka_idx)
3363            .rev()
3364            .find(|&i| lines[i].starts_with("[["))
3365            .unwrap_or(gonka_idx);
3366
3367        // Skip if the comment is already present just before the header.
3368        let already_marked = header_idx > 0 && lines[header_idx - 1].contains(MARKER);
3369        if already_marked {
3370            continue;
3371        }
3372
3373        lines.insert(
3374            header_idx,
3375            format!("{MARKER} (see docs/guides/gonka-native.md)"),
3376        );
3377        changed_count += 1;
3378    }
3379
3380    let output = lines.join("\n");
3381    let output = if toml_src.ends_with('\n') {
3382        format!("{output}\n")
3383    } else {
3384        output
3385    };
3386
3387    MigrationResult {
3388        output,
3389        changed_count,
3390        sections_changed: if changed_count > 0 {
3391            vec!["llm".into()]
3392        } else {
3393            vec![]
3394        },
3395    }
3396}
3397
3398/// Advisory-only no-op step for Cocoon provider introduction.
3399///
3400/// Returns the input unchanged. Exists so the migration registry stays sequential
3401/// and `--migrate-config` informs users that Cocoon is now available.
3402///
3403/// # Errors
3404///
3405/// This function never returns an error.
3406pub fn migrate_cocoon_provider_notice(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3407    Ok(MigrationResult {
3408        output: toml_src.to_owned(),
3409        changed_count: 0,
3410        sections_changed: vec![],
3411    })
3412}
3413
3414/// Adds a commented-out `[telemetry.trace_metadata]` example to configs that have a
3415/// `[telemetry]` section but no `trace_metadata` key (#4160).
3416///
3417/// # Errors
3418///
3419/// Returns [`MigrateError`] if the TOML source cannot be parsed.
3420pub fn migrate_trace_metadata(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3421    if toml_src.contains("trace_metadata") {
3422        return Ok(MigrationResult {
3423            output: toml_src.to_owned(),
3424            changed_count: 0,
3425            sections_changed: Vec::new(),
3426        });
3427    }
3428
3429    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3430
3431    if !doc.contains_key("telemetry") {
3432        return Ok(MigrationResult {
3433            output: toml_src.to_owned(),
3434            changed_count: 0,
3435            sections_changed: Vec::new(),
3436        });
3437    }
3438
3439    let comment = "\n# Custom key/value pairs attached as OpenTelemetry resource attributes (#4160).\n\
3440        # Appear on every exported span. Values are plaintext — do not store secrets here.\n\
3441        # [telemetry.trace_metadata]\n\
3442        # \"deployment.environment\" = \"production\"\n\
3443        # \"vcs.revision\" = \"abc1234\"\n";
3444    let raw = doc.to_string();
3445    let output = insert_after_section(&raw, "telemetry", comment);
3446
3447    Ok(MigrationResult {
3448        output,
3449        changed_count: 1,
3450        sections_changed: vec!["telemetry.trace_metadata".to_owned()],
3451    })
3452}
3453
3454/// Ordered registry of all sequential migration steps (steps 1–47).
3455///
3456/// Each entry wraps the corresponding free function and is evaluated lazily at first access.
3457/// The ordering is chronological; the dispatch loop in `src/commands/migrate.rs` iterates
3458/// this registry rather than calling free functions individually.
3459///
3460/// # Examples
3461///
3462/// ```rust
3463/// use zeph_config::migrate::MIGRATIONS;
3464///
3465/// // Every step in the registry has a non-empty name.
3466/// for m in MIGRATIONS.iter() {
3467///     assert!(!m.name().is_empty());
3468/// }
3469/// ```
3470pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3471    std::sync::LazyLock::new(|| {
3472        vec![
3473            // Steps 1–25 (pre-existing migrations)
3474            Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3475            Box::new(MigratePlannerModelToProvider),
3476            Box::new(MigrateMcpTrustLevels),
3477            Box::new(MigrateAgentRetryToToolsRetry),
3478            Box::new(MigrateDatabaseUrl),
3479            Box::new(MigrateShellTransactional),
3480            Box::new(MigrateAgentBudgetHint),
3481            Box::new(MigrateForgettingConfig),
3482            Box::new(MigrateCompressionPredictorConfig),
3483            Box::new(MigrateMicrocompactConfig),
3484            Box::new(MigrateAutodreamConfig),
3485            Box::new(MigrateMagicDocsConfig),
3486            Box::new(MigrateTelemetryConfig),
3487            Box::new(MigrateSupervisorConfig),
3488            Box::new(MigrateOtelFilter),
3489            Box::new(MigrateEgressConfig),
3490            Box::new(MigrateVigilConfig),
3491            Box::new(MigrateSandboxConfig),
3492            Box::new(MigrateSandboxEgressFilter),
3493            Box::new(MigrateOrchestrationPersistence),
3494            Box::new(MigrateSessionRecapConfig),
3495            Box::new(MigrateMcpElicitationConfig),
3496            Box::new(MigrateQualityConfig),
3497            Box::new(MigrateAcpSubagentsConfig),
3498            Box::new(MigrateHooksPermissionDeniedConfig),
3499            // Steps 26–35 (most recent migrations, pre-stable-defaults)
3500            Box::new(MigrateMemoryGraph),
3501            Box::new(MigrateSchedulerDaemon),
3502            Box::new(MigrateMemoryRetrieval),
3503            Box::new(MigrateMemoryReasoning),
3504            Box::new(MigrateMemoryReasoningJudge),
3505            Box::new(MigrateMemoryHebbian),
3506            Box::new(MigrateMemoryHebbianConsolidation),
3507            Box::new(MigrateMemoryHebbianSpread),
3508            Box::new(MigrateHooksTurnComplete),
3509            Box::new(MigrateFocusAutoConsolidateMinWindow),
3510            // Steps 36–38 (stable-defaults: flip verified-stable config keys to on)
3511            Box::new(MigrateSessionProviderPersistence),
3512            Box::new(MigrateMemoryRetrievalQueryBias),
3513            Box::new(MigrateMemoryPersonaConfig),
3514            // Step 39 — optional Qdrant API key (#3543)
3515            Box::new(MigrateQdrantApiKey),
3516            // Step 40 — MCP startup auto-retry max_connect_attempts (#3568)
3517            Box::new(MigrateMcpMaxConnectAttempts),
3518            // Steps 41–42 — goal lifecycle and TACO compression (#3567, #3306)
3519            Box::new(MigrateGoalsConfig),
3520            Box::new(MigrateToolsCompressionConfig),
3521            // Step 43 — orchestrator_provider for scheduling-tier LLM calls (#3300)
3522            Box::new(MigrateOrchestratorProvider),
3523            // Step 44 — max_concurrent per-provider admission control hint (#3299)
3524            Box::new(MigrateProviderMaxConcurrent),
3525            // Step 45 — advisory notice for GonkaGate → native Gonka upgrade path (#3613)
3526            Box::new(MigrateGonkagateToGonka),
3527            // Step 46 — advisory notice for Cocoon decentralized inference provider (#3671)
3528            Box::new(MigrateCocoonProviderNotice),
3529            // Step 47 — telemetry.trace_metadata OTEL resource attributes (#4160)
3530            Box::new(MigrateTraceMetadata),
3531        ]
3532    });
3533
3534// Helper to create a formatted value (used in tests).
3535#[cfg(test)]
3536fn make_formatted_str(s: &str) -> Value {
3537    use toml_edit::Formatted;
3538    Value::String(Formatted::new(s.to_owned()))
3539}
3540
3541#[cfg(test)]
3542mod tests {
3543    use super::*;
3544
3545    #[test]
3546    fn migrations_registry_has_all_steps() {
3547        assert_eq!(
3548            MIGRATIONS.len(),
3549            47,
3550            "MIGRATIONS registry must contain all 47 sequential steps"
3551        );
3552        for m in MIGRATIONS.iter() {
3553            assert!(
3554                !m.name().is_empty(),
3555                "each migration must have a non-empty name"
3556            );
3557        }
3558    }
3559
3560    #[test]
3561    fn migrations_registry_applies_to_empty_config() {
3562        let mut toml = String::new();
3563        for m in MIGRATIONS.iter() {
3564            toml = m
3565                .apply(&toml)
3566                .expect("migration must not fail on empty config")
3567                .output;
3568        }
3569        // After all steps, the output should at minimum be valid TOML (parseable).
3570        toml.parse::<toml_edit::DocumentMut>()
3571            .expect("registry output must be valid TOML");
3572    }
3573
3574    #[test]
3575    fn empty_config_gets_sections_as_comments() {
3576        let migrator = ConfigMigrator::new();
3577        let result = migrator.migrate("").expect("migrate empty");
3578        // Should have added sections since reference is non-empty.
3579        assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
3580        // Output should mention at least agent section.
3581        assert!(
3582            result.output.contains("[agent]") || result.output.contains("# [agent]"),
3583            "expected agent section in output, got:\n{}",
3584            result.output
3585        );
3586    }
3587
3588    #[test]
3589    fn existing_values_not_overwritten() {
3590        let user = r#"
3591[agent]
3592name = "MyAgent"
3593max_tool_iterations = 5
3594"#;
3595        let migrator = ConfigMigrator::new();
3596        let result = migrator.migrate(user).expect("migrate");
3597        // Original name preserved.
3598        assert!(
3599            result.output.contains("name = \"MyAgent\""),
3600            "user value should be preserved"
3601        );
3602        assert!(
3603            result.output.contains("max_tool_iterations = 5"),
3604            "user value should be preserved"
3605        );
3606        // Should not appear as commented default.
3607        assert!(
3608            !result.output.contains("# max_tool_iterations = 10"),
3609            "already-set key should not appear as comment"
3610        );
3611    }
3612
3613    #[test]
3614    fn missing_nested_key_added_as_comment() {
3615        // User has [memory] but is missing some keys.
3616        let user = r#"
3617[memory]
3618sqlite_path = ".zeph/data/zeph.db"
3619"#;
3620        let migrator = ConfigMigrator::new();
3621        let result = migrator.migrate(user).expect("migrate");
3622        // history_limit should be added as comment since it's in reference.
3623        assert!(
3624            result.output.contains("# history_limit"),
3625            "missing key should be added as comment, got:\n{}",
3626            result.output
3627        );
3628    }
3629
3630    #[test]
3631    fn unknown_user_keys_preserved() {
3632        let user = r#"
3633[agent]
3634name = "Test"
3635my_custom_key = "preserved"
3636"#;
3637        let migrator = ConfigMigrator::new();
3638        let result = migrator.migrate(user).expect("migrate");
3639        assert!(
3640            result.output.contains("my_custom_key = \"preserved\""),
3641            "custom user keys must not be removed"
3642        );
3643    }
3644
3645    #[test]
3646    fn idempotent() {
3647        let migrator = ConfigMigrator::new();
3648        let first = migrator
3649            .migrate("[agent]\nname = \"Zeph\"\n")
3650            .expect("first migrate");
3651        let second = migrator.migrate(&first.output).expect("second migrate");
3652        assert_eq!(
3653            first.output, second.output,
3654            "idempotent: full output must be identical on second run"
3655        );
3656    }
3657
3658    #[test]
3659    fn malformed_input_returns_error() {
3660        let migrator = ConfigMigrator::new();
3661        let err = migrator
3662            .migrate("[[invalid toml [[[")
3663            .expect_err("should error");
3664        assert!(
3665            matches!(err, MigrateError::Parse(_)),
3666            "expected Parse error"
3667        );
3668    }
3669
3670    #[test]
3671    fn array_of_tables_preserved() {
3672        let user = r#"
3673[mcp]
3674allowed_commands = ["npx"]
3675
3676[[mcp.servers]]
3677id = "my-server"
3678command = "npx"
3679args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3680"#;
3681        let migrator = ConfigMigrator::new();
3682        let result = migrator.migrate(user).expect("migrate");
3683        // User's [[mcp.servers]] entry must survive.
3684        assert!(
3685            result.output.contains("[[mcp.servers]]"),
3686            "array-of-tables entries must be preserved"
3687        );
3688        assert!(result.output.contains("id = \"my-server\""));
3689    }
3690
3691    #[test]
3692    fn canonical_ordering_applied() {
3693        // Put memory before agent intentionally.
3694        let user = r#"
3695[memory]
3696sqlite_path = ".zeph/data/zeph.db"
3697
3698[agent]
3699name = "Test"
3700"#;
3701        let migrator = ConfigMigrator::new();
3702        let result = migrator.migrate(user).expect("migrate");
3703        // agent should appear before memory in canonical order.
3704        let agent_pos = result.output.find("[agent]");
3705        let memory_pos = result.output.find("[memory]");
3706        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3707            assert!(a < m, "agent section should precede memory section");
3708        }
3709    }
3710
3711    #[test]
3712    fn value_to_toml_string_formats_correctly() {
3713        use toml_edit::Formatted;
3714
3715        let s = make_formatted_str("hello");
3716        assert_eq!(value_to_toml_string(&s), "\"hello\"");
3717
3718        let i = Value::Integer(Formatted::new(42_i64));
3719        assert_eq!(value_to_toml_string(&i), "42");
3720
3721        let b = Value::Boolean(Formatted::new(true));
3722        assert_eq!(value_to_toml_string(&b), "true");
3723
3724        let f = Value::Float(Formatted::new(1.0_f64));
3725        assert_eq!(value_to_toml_string(&f), "1.0");
3726
3727        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3728        assert_eq!(value_to_toml_string(&f2), "3.14");
3729
3730        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3731        let arr_val = Value::Array(arr);
3732        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3733
3734        let empty_arr = Value::Array(Array::new());
3735        assert_eq!(value_to_toml_string(&empty_arr), "[]");
3736    }
3737
3738    #[test]
3739    fn idempotent_full_output_unchanged() {
3740        // Stronger idempotency: the entire output string must not change on a second pass.
3741        let migrator = ConfigMigrator::new();
3742        let first = migrator
3743            .migrate("[agent]\nname = \"Zeph\"\n")
3744            .expect("first migrate");
3745        let second = migrator.migrate(&first.output).expect("second migrate");
3746        assert_eq!(
3747            first.output, second.output,
3748            "full output string must be identical after second migration pass"
3749        );
3750    }
3751
3752    #[test]
3753    fn full_config_produces_zero_additions() {
3754        // Migrating the reference config itself should add nothing new.
3755        let reference = include_str!("../../config/default.toml");
3756        let migrator = ConfigMigrator::new();
3757        let result = migrator.migrate(reference).expect("migrate reference");
3758        assert_eq!(
3759            result.changed_count, 0,
3760            "migrating the canonical reference should add nothing (changed_count = {})",
3761            result.changed_count
3762        );
3763        assert!(
3764            result.sections_changed.is_empty(),
3765            "migrating the canonical reference should report no sections_changed: {:?}",
3766            result.sections_changed
3767        );
3768    }
3769
3770    #[test]
3771    fn empty_config_changed_count_is_positive() {
3772        // Stricter variant of empty_config_gets_sections_as_comments.
3773        let migrator = ConfigMigrator::new();
3774        let result = migrator.migrate("").expect("migrate empty");
3775        assert!(
3776            result.changed_count > 0,
3777            "empty config must report changed_count > 0"
3778        );
3779    }
3780
3781    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
3782    // for a pre-guardrail config that has [security] but no [security.guardrail].
3783    #[test]
3784    fn security_without_guardrail_gets_guardrail_commented() {
3785        let user = "[security]\nredact_secrets = true\n";
3786        let migrator = ConfigMigrator::new();
3787        let result = migrator.migrate(user).expect("migrate");
3788        // The generic diff mechanism must add guardrail keys as commented defaults.
3789        assert!(
3790            result.output.contains("guardrail"),
3791            "migration must add guardrail keys for configs without [security.guardrail]: \
3792             got:\n{}",
3793            result.output
3794        );
3795    }
3796
3797    #[test]
3798    fn migrate_reference_contains_tools_policy() {
3799        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
3800        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
3801        // `zeph --migrate-config` will surface the section to users as a discoverable commented
3802        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
3803        let reference = include_str!("../../config/default.toml");
3804        assert!(
3805            reference.contains("[tools.policy]"),
3806            "default.toml must contain [tools.policy] section so migrate-config can surface it"
3807        );
3808        assert!(
3809            reference.contains("enabled = false"),
3810            "tools.policy section must include enabled = false default"
3811        );
3812    }
3813
3814    #[test]
3815    fn migrate_reference_contains_probe_section() {
3816        // default.toml must contain the probe section comment block so users can discover it
3817        // when reading the file directly or after running --migrate-config.
3818        let reference = include_str!("../../config/default.toml");
3819        assert!(
3820            reference.contains("[memory.compression.probe]"),
3821            "default.toml must contain [memory.compression.probe] section comment"
3822        );
3823        assert!(
3824            reference.contains("hard_fail_threshold"),
3825            "probe section must include hard_fail_threshold default"
3826        );
3827    }
3828
3829    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
3830
3831    #[test]
3832    fn migrate_llm_no_llm_section_is_noop() {
3833        let src = "[agent]\nname = \"Zeph\"\n";
3834        let result = migrate_llm_to_providers(src).expect("migrate");
3835        assert_eq!(result.changed_count, 0);
3836        assert_eq!(result.output, src);
3837    }
3838
3839    #[test]
3840    fn migrate_llm_already_new_format_is_noop() {
3841        let src = r#"
3842[llm]
3843[[llm.providers]]
3844type = "ollama"
3845model = "qwen3:8b"
3846"#;
3847        let result = migrate_llm_to_providers(src).expect("migrate");
3848        assert_eq!(result.changed_count, 0);
3849    }
3850
3851    #[test]
3852    fn migrate_llm_ollama_produces_providers_block() {
3853        let src = r#"
3854[llm]
3855provider = "ollama"
3856model = "qwen3:8b"
3857base_url = "http://localhost:11434"
3858embedding_model = "nomic-embed-text"
3859"#;
3860        let result = migrate_llm_to_providers(src).expect("migrate");
3861        assert!(
3862            result.output.contains("[[llm.providers]]"),
3863            "should contain [[llm.providers]]:\n{}",
3864            result.output
3865        );
3866        assert!(
3867            result.output.contains("type = \"ollama\""),
3868            "{}",
3869            result.output
3870        );
3871        assert!(
3872            result.output.contains("model = \"qwen3:8b\""),
3873            "{}",
3874            result.output
3875        );
3876    }
3877
3878    #[test]
3879    fn migrate_llm_claude_produces_providers_block() {
3880        let src = r#"
3881[llm]
3882provider = "claude"
3883
3884[llm.cloud]
3885model = "claude-sonnet-4-6"
3886max_tokens = 8192
3887server_compaction = true
3888"#;
3889        let result = migrate_llm_to_providers(src).expect("migrate");
3890        assert!(
3891            result.output.contains("[[llm.providers]]"),
3892            "{}",
3893            result.output
3894        );
3895        assert!(
3896            result.output.contains("type = \"claude\""),
3897            "{}",
3898            result.output
3899        );
3900        assert!(
3901            result.output.contains("model = \"claude-sonnet-4-6\""),
3902            "{}",
3903            result.output
3904        );
3905        assert!(
3906            result.output.contains("server_compaction = true"),
3907            "{}",
3908            result.output
3909        );
3910    }
3911
3912    #[test]
3913    fn migrate_llm_openai_copies_fields() {
3914        let src = r#"
3915[llm]
3916provider = "openai"
3917
3918[llm.openai]
3919base_url = "https://api.openai.com/v1"
3920model = "gpt-4o"
3921max_tokens = 4096
3922"#;
3923        let result = migrate_llm_to_providers(src).expect("migrate");
3924        assert!(
3925            result.output.contains("type = \"openai\""),
3926            "{}",
3927            result.output
3928        );
3929        assert!(
3930            result
3931                .output
3932                .contains("base_url = \"https://api.openai.com/v1\""),
3933            "{}",
3934            result.output
3935        );
3936    }
3937
3938    #[test]
3939    fn migrate_llm_gemini_copies_fields() {
3940        let src = r#"
3941[llm]
3942provider = "gemini"
3943
3944[llm.gemini]
3945model = "gemini-2.0-flash"
3946max_tokens = 8192
3947base_url = "https://generativelanguage.googleapis.com"
3948"#;
3949        let result = migrate_llm_to_providers(src).expect("migrate");
3950        assert!(
3951            result.output.contains("type = \"gemini\""),
3952            "{}",
3953            result.output
3954        );
3955        assert!(
3956            result.output.contains("model = \"gemini-2.0-flash\""),
3957            "{}",
3958            result.output
3959        );
3960    }
3961
3962    #[test]
3963    fn migrate_llm_compatible_copies_multiple_entries() {
3964        let src = r#"
3965[llm]
3966provider = "compatible"
3967
3968[[llm.compatible]]
3969name = "proxy-a"
3970base_url = "http://proxy-a:8080/v1"
3971model = "llama3"
3972max_tokens = 4096
3973
3974[[llm.compatible]]
3975name = "proxy-b"
3976base_url = "http://proxy-b:8080/v1"
3977model = "mistral"
3978max_tokens = 2048
3979"#;
3980        let result = migrate_llm_to_providers(src).expect("migrate");
3981        // Both compatible entries should be emitted.
3982        let count = result.output.matches("[[llm.providers]]").count();
3983        assert_eq!(
3984            count, 2,
3985            "expected 2 [[llm.providers]] blocks:\n{}",
3986            result.output
3987        );
3988        assert!(
3989            result.output.contains("name = \"proxy-a\""),
3990            "{}",
3991            result.output
3992        );
3993        assert!(
3994            result.output.contains("name = \"proxy-b\""),
3995            "{}",
3996            result.output
3997        );
3998    }
3999
4000    #[test]
4001    fn migrate_llm_mixed_format_errors() {
4002        // Legacy + new format together should produce an error.
4003        let src = r#"
4004[llm]
4005provider = "ollama"
4006
4007[[llm.providers]]
4008type = "ollama"
4009"#;
4010        assert!(
4011            migrate_llm_to_providers(src).is_err(),
4012            "mixed format must return error"
4013        );
4014    }
4015
4016    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
4017
4018    #[test]
4019    fn stt_migration_no_stt_section_returns_unchanged() {
4020        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
4021        let result = migrate_stt_to_provider(src).unwrap();
4022        assert_eq!(result.changed_count, 0);
4023        assert_eq!(result.output, src);
4024    }
4025
4026    #[test]
4027    fn stt_migration_no_model_or_base_url_returns_unchanged() {
4028        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
4029        let result = migrate_stt_to_provider(src).unwrap();
4030        assert_eq!(result.changed_count, 0);
4031    }
4032
4033    #[test]
4034    fn stt_migration_moves_model_to_provider_entry() {
4035        let src = r#"
4036[llm]
4037
4038[[llm.providers]]
4039type = "openai"
4040name = "quality"
4041model = "gpt-5.4"
4042
4043[llm.stt]
4044provider = "quality"
4045model = "gpt-4o-mini-transcribe"
4046language = "en"
4047"#;
4048        let result = migrate_stt_to_provider(src).unwrap();
4049        assert_eq!(result.changed_count, 1);
4050        // stt_model should appear in providers entry.
4051        assert!(
4052            result.output.contains("stt_model"),
4053            "stt_model must be in output"
4054        );
4055        // model should be removed from [llm.stt].
4056        // The output should parse cleanly.
4057        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4058        let stt = doc
4059            .get("llm")
4060            .and_then(toml_edit::Item::as_table)
4061            .and_then(|l| l.get("stt"))
4062            .and_then(toml_edit::Item::as_table)
4063            .unwrap();
4064        assert!(
4065            stt.get("model").is_none(),
4066            "model must be removed from [llm.stt]"
4067        );
4068        assert_eq!(
4069            stt.get("provider").and_then(toml_edit::Item::as_str),
4070            Some("quality")
4071        );
4072    }
4073
4074    #[test]
4075    fn stt_migration_creates_new_provider_when_no_match() {
4076        let src = r#"
4077[llm]
4078
4079[[llm.providers]]
4080type = "ollama"
4081name = "local"
4082model = "qwen3:8b"
4083
4084[llm.stt]
4085provider = "whisper"
4086model = "whisper-1"
4087base_url = "https://api.openai.com/v1"
4088language = "en"
4089"#;
4090        let result = migrate_stt_to_provider(src).unwrap();
4091        assert!(
4092            result.output.contains("openai-stt"),
4093            "new entry name must be openai-stt"
4094        );
4095        assert!(
4096            result.output.contains("stt_model"),
4097            "stt_model must be in output"
4098        );
4099    }
4100
4101    #[test]
4102    fn stt_migration_candle_whisper_creates_candle_entry() {
4103        let src = r#"
4104[llm]
4105
4106[llm.stt]
4107provider = "candle-whisper"
4108model = "openai/whisper-tiny"
4109language = "auto"
4110"#;
4111        let result = migrate_stt_to_provider(src).unwrap();
4112        assert!(
4113            result.output.contains("local-whisper"),
4114            "candle entry name must be local-whisper"
4115        );
4116        assert!(result.output.contains("candle"), "type must be candle");
4117    }
4118
4119    #[test]
4120    fn stt_migration_w2_assigns_explicit_name() {
4121        // Provider has no explicit name (type = "openai") — migration must assign one.
4122        let src = r#"
4123[llm]
4124
4125[[llm.providers]]
4126type = "openai"
4127model = "gpt-5.4"
4128
4129[llm.stt]
4130provider = "openai"
4131model = "whisper-1"
4132language = "auto"
4133"#;
4134        let result = migrate_stt_to_provider(src).unwrap();
4135        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4136        let providers = doc
4137            .get("llm")
4138            .and_then(toml_edit::Item::as_table)
4139            .and_then(|l| l.get("providers"))
4140            .and_then(toml_edit::Item::as_array_of_tables)
4141            .unwrap();
4142        let entry = providers
4143            .iter()
4144            .find(|t| t.get("stt_model").is_some())
4145            .unwrap();
4146        // Must have an explicit `name` field (W2).
4147        assert!(
4148            entry.get("name").is_some(),
4149            "migrated entry must have explicit name"
4150        );
4151    }
4152
4153    #[test]
4154    fn stt_migration_removes_base_url_from_stt_table() {
4155        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
4156        let src = r#"
4157[llm]
4158
4159[[llm.providers]]
4160type = "openai"
4161name = "quality"
4162model = "gpt-5.4"
4163
4164[llm.stt]
4165provider = "quality"
4166model = "whisper-1"
4167base_url = "https://api.openai.com/v1"
4168language = "en"
4169"#;
4170        let result = migrate_stt_to_provider(src).unwrap();
4171        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4172        let stt = doc
4173            .get("llm")
4174            .and_then(toml_edit::Item::as_table)
4175            .and_then(|l| l.get("stt"))
4176            .and_then(toml_edit::Item::as_table)
4177            .unwrap();
4178        assert!(
4179            stt.get("model").is_none(),
4180            "model must be removed from [llm.stt]"
4181        );
4182        assert!(
4183            stt.get("base_url").is_none(),
4184            "base_url must be removed from [llm.stt]"
4185        );
4186    }
4187
4188    #[test]
4189    fn migrate_planner_model_to_provider_with_field() {
4190        let input = r#"
4191[orchestration]
4192enabled = true
4193planner_model = "gpt-4o"
4194max_tasks = 20
4195"#;
4196        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4197        assert_eq!(result.changed_count, 1, "changed_count must be 1");
4198        assert!(
4199            !result.output.contains("planner_model = "),
4200            "planner_model key must be removed from output"
4201        );
4202        assert!(
4203            result.output.contains("# planner_provider"),
4204            "commented-out planner_provider entry must be present"
4205        );
4206        assert!(
4207            result.output.contains("gpt-4o"),
4208            "old value must appear in the comment"
4209        );
4210        assert!(
4211            result.output.contains("MIGRATED"),
4212            "comment must include MIGRATED marker"
4213        );
4214    }
4215
4216    #[test]
4217    fn migrate_planner_model_to_provider_no_op() {
4218        let input = r"
4219[orchestration]
4220enabled = true
4221max_tasks = 20
4222";
4223        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4224        assert_eq!(
4225            result.changed_count, 0,
4226            "changed_count must be 0 when field is absent"
4227        );
4228        assert_eq!(
4229            result.output, input,
4230            "output must equal input when nothing to migrate"
4231        );
4232    }
4233
4234    #[test]
4235    fn migrate_error_invalid_structure_formats_correctly() {
4236        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
4237        // produces a human-readable message. The error path is triggered when the [llm] item
4238        // is present but cannot be obtained as a mutable table (defensive guard replacing the
4239        // previous .expect() calls that would have panicked).
4240        let err = MigrateError::InvalidStructure("test sentinel");
4241        assert!(
4242            matches!(err, MigrateError::InvalidStructure(_)),
4243            "variant must match"
4244        );
4245        let msg = err.to_string();
4246        assert!(
4247            msg.contains("invalid TOML structure"),
4248            "error message must mention 'invalid TOML structure', got: {msg}"
4249        );
4250        assert!(
4251            msg.contains("test sentinel"),
4252            "message must include reason: {msg}"
4253        );
4254    }
4255
4256    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
4257
4258    #[test]
4259    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
4260        let src = r#"
4261[mcp]
4262allowed_commands = ["npx"]
4263
4264[[mcp.servers]]
4265id = "srv-a"
4266command = "npx"
4267args = ["-y", "some-mcp"]
4268
4269[[mcp.servers]]
4270id = "srv-b"
4271command = "npx"
4272args = ["-y", "other-mcp"]
4273"#;
4274        let result = migrate_mcp_trust_levels(src).expect("migrate");
4275        assert_eq!(
4276            result.changed_count, 2,
4277            "both entries must get trust_level added"
4278        );
4279        assert!(
4280            result
4281                .sections_changed
4282                .contains(&"mcp.servers.trust_level".to_owned()),
4283            "sections_changed must report mcp.servers.trust_level"
4284        );
4285        // Both entries must now contain trust_level = "trusted"
4286        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
4287        assert_eq!(
4288            occurrences, 2,
4289            "each entry must have trust_level = \"trusted\""
4290        );
4291    }
4292
4293    #[test]
4294    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
4295        let src = r#"
4296[[mcp.servers]]
4297id = "srv-a"
4298command = "npx"
4299trust_level = "sandboxed"
4300tool_allowlist = ["read_file"]
4301
4302[[mcp.servers]]
4303id = "srv-b"
4304command = "npx"
4305"#;
4306        let result = migrate_mcp_trust_levels(src).expect("migrate");
4307        // Only srv-b has no trust_level, so only 1 entry should be updated
4308        assert_eq!(
4309            result.changed_count, 1,
4310            "only entry without trust_level gets updated"
4311        );
4312        // srv-a's sandboxed value must not be overwritten
4313        assert!(
4314            result.output.contains("trust_level = \"sandboxed\""),
4315            "existing trust_level must not be overwritten"
4316        );
4317        // srv-b gets trusted
4318        assert!(
4319            result.output.contains("trust_level = \"trusted\""),
4320            "entry without trust_level must get trusted"
4321        );
4322    }
4323
4324    #[test]
4325    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4326        let src = "[agent]\nname = \"Zeph\"\n";
4327        let result = migrate_mcp_trust_levels(src).expect("migrate");
4328        assert_eq!(result.changed_count, 0);
4329        assert!(result.sections_changed.is_empty());
4330        assert_eq!(result.output, src);
4331    }
4332
4333    #[test]
4334    fn migrate_mcp_trust_levels_no_servers_is_noop() {
4335        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4336        let result = migrate_mcp_trust_levels(src).expect("migrate");
4337        assert_eq!(result.changed_count, 0);
4338        assert!(result.sections_changed.is_empty());
4339        assert_eq!(result.output, src);
4340    }
4341
4342    #[test]
4343    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4344        let src = r#"
4345[[mcp.servers]]
4346id = "srv-a"
4347trust_level = "trusted"
4348
4349[[mcp.servers]]
4350id = "srv-b"
4351trust_level = "untrusted"
4352"#;
4353        let result = migrate_mcp_trust_levels(src).expect("migrate");
4354        assert_eq!(result.changed_count, 0);
4355        assert!(result.sections_changed.is_empty());
4356    }
4357
4358    #[test]
4359    fn migrate_database_url_adds_comment_when_absent() {
4360        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4361        let result = migrate_database_url(src).expect("migrate");
4362        assert_eq!(result.changed_count, 1);
4363        assert!(
4364            result
4365                .sections_changed
4366                .contains(&"memory.database_url".to_owned())
4367        );
4368        assert!(result.output.contains("# database_url = \"\""));
4369    }
4370
4371    #[test]
4372    fn migrate_database_url_is_noop_when_present() {
4373        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4374        let result = migrate_database_url(src).expect("migrate");
4375        assert_eq!(result.changed_count, 0);
4376        assert!(result.sections_changed.is_empty());
4377        assert_eq!(result.output, src);
4378    }
4379
4380    #[test]
4381    fn migrate_database_url_creates_memory_section_when_absent() {
4382        let src = "[agent]\nname = \"Zeph\"\n";
4383        let result = migrate_database_url(src).expect("migrate");
4384        assert_eq!(result.changed_count, 1);
4385        assert!(result.output.contains("# database_url = \"\""));
4386    }
4387
4388    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
4389
4390    #[test]
4391    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4392        let src = "[agent]\nname = \"Zeph\"\n";
4393        let result = migrate_agent_budget_hint(src).expect("migrate");
4394        assert_eq!(result.changed_count, 1);
4395        assert!(result.output.contains("budget_hint_enabled"));
4396        assert!(
4397            result
4398                .sections_changed
4399                .contains(&"agent.budget_hint_enabled".to_owned())
4400        );
4401    }
4402
4403    #[test]
4404    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4405        let src = "[llm]\nmodel = \"gpt-4o\"\n";
4406        let result = migrate_agent_budget_hint(src).expect("migrate");
4407        assert_eq!(result.changed_count, 0);
4408        assert_eq!(result.output, src);
4409    }
4410
4411    #[test]
4412    fn migrate_agent_budget_hint_already_present_is_noop() {
4413        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4414        let result = migrate_agent_budget_hint(src).expect("migrate");
4415        assert_eq!(result.changed_count, 0);
4416        assert_eq!(result.output, src);
4417    }
4418
4419    #[test]
4420    fn migrate_telemetry_config_empty_config_appends_comment_block() {
4421        let src = "[agent]\nname = \"Zeph\"\n";
4422        let result = migrate_telemetry_config(src).expect("migrate");
4423        assert_eq!(result.changed_count, 1);
4424        assert_eq!(result.sections_changed, vec!["telemetry"]);
4425        assert!(
4426            result.output.contains("# [telemetry]"),
4427            "expected commented-out [telemetry] block in output"
4428        );
4429        assert!(
4430            result.output.contains("enabled = false"),
4431            "expected enabled = false in telemetry comment block"
4432        );
4433    }
4434
4435    #[test]
4436    fn migrate_telemetry_config_existing_section_is_noop() {
4437        let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4438        let result = migrate_telemetry_config(src).expect("migrate");
4439        assert_eq!(result.changed_count, 0);
4440        assert_eq!(result.output, src);
4441    }
4442
4443    #[test]
4444    fn migrate_telemetry_config_existing_comment_is_noop() {
4445        // Idempotency: if the comment block was already added, don't append again.
4446        let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4447        let result = migrate_telemetry_config(src).expect("migrate");
4448        assert_eq!(result.changed_count, 0);
4449        assert_eq!(result.output, src);
4450    }
4451
4452    // ── migrate_otel_filter tests (#2997) ─────────────────────────────────────
4453
4454    #[test]
4455    fn migrate_otel_filter_already_present_is_noop() {
4456        // Real key present — must not modify.
4457        let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4458        let result = migrate_otel_filter(src).expect("migrate");
4459        assert_eq!(result.changed_count, 0);
4460        assert_eq!(result.output, src);
4461    }
4462
4463    #[test]
4464    fn migrate_otel_filter_commented_key_is_noop() {
4465        // Commented-out key already present — idempotent.
4466        let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4467        let result = migrate_otel_filter(src).expect("migrate");
4468        assert_eq!(result.changed_count, 0);
4469        assert_eq!(result.output, src);
4470    }
4471
4472    #[test]
4473    fn migrate_otel_filter_no_telemetry_section_is_noop() {
4474        // [telemetry] absent — must not inject into wrong location.
4475        let src = "[agent]\nname = \"Zeph\"\n";
4476        let result = migrate_otel_filter(src).expect("migrate");
4477        assert_eq!(result.changed_count, 0);
4478        assert_eq!(result.output, src);
4479        assert!(!result.output.contains("otel_filter"));
4480    }
4481
4482    #[test]
4483    fn migrate_otel_filter_injects_within_telemetry_section() {
4484        let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4485        let result = migrate_otel_filter(src).expect("migrate");
4486        assert_eq!(result.changed_count, 1);
4487        assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4488        assert!(
4489            result.output.contains("otel_filter"),
4490            "otel_filter comment must appear"
4491        );
4492        // Comment must appear before [agent] — i.e., within the telemetry section.
4493        let otel_pos = result
4494            .output
4495            .find("otel_filter")
4496            .expect("otel_filter present");
4497        let agent_pos = result.output.find("[agent]").expect("[agent] present");
4498        assert!(
4499            otel_pos < agent_pos,
4500            "otel_filter comment should appear before [agent] section"
4501        );
4502    }
4503
4504    #[test]
4505    fn sandbox_migration_adds_commented_section_when_absent() {
4506        let src = "[agent]\nname = \"Z\"\n";
4507        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4508        assert_eq!(result.changed_count, 1);
4509        assert!(result.output.contains("# [tools.sandbox]"));
4510        assert!(result.output.contains("# profile = \"workspace\""));
4511    }
4512
4513    #[test]
4514    fn sandbox_migration_noop_when_section_present() {
4515        let src = "[tools.sandbox]\nenabled = true\n";
4516        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4517        assert_eq!(result.changed_count, 0);
4518    }
4519
4520    #[test]
4521    fn sandbox_migration_noop_when_dotted_key_present() {
4522        let src = "[tools]\nsandbox = { enabled = true }\n";
4523        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4524        assert_eq!(result.changed_count, 0);
4525    }
4526
4527    #[test]
4528    fn sandbox_migration_false_positive_comment_does_not_block() {
4529        // Comments mentioning tools.sandbox must NOT suppress insertion.
4530        let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
4531        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4532        assert_eq!(result.changed_count, 1);
4533    }
4534
4535    #[test]
4536    fn embedded_default_mentions_tools_sandbox() {
4537        let default_src = include_str!("../../config/default.toml");
4538        assert!(
4539            default_src.contains("tools.sandbox"),
4540            "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
4541        );
4542    }
4543
4544    #[test]
4545    fn sandbox_migration_idempotent_on_own_output() {
4546        let base = "[agent]\nmodel = \"test\"\n";
4547        let first = migrate_sandbox_config(base).unwrap();
4548        assert_eq!(first.changed_count, 1);
4549        let second = migrate_sandbox_config(&first.output).unwrap();
4550        assert_eq!(second.changed_count, 0, "second run must not double-append");
4551        assert_eq!(second.output, first.output);
4552    }
4553
4554    #[test]
4555    fn migrate_agent_budget_hint_idempotent_on_commented_output() {
4556        let base = "[agent]\nname = \"Zeph\"\n";
4557        let first = migrate_agent_budget_hint(base).unwrap();
4558        assert_eq!(first.changed_count, 1);
4559        let second = migrate_agent_budget_hint(&first.output).unwrap();
4560        assert_eq!(second.changed_count, 0, "second run must not double-append");
4561        assert_eq!(second.output, first.output);
4562    }
4563
4564    #[test]
4565    fn migrate_forgetting_config_idempotent_on_commented_output() {
4566        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4567        let first = migrate_forgetting_config(base).unwrap();
4568        assert_eq!(first.changed_count, 1);
4569        let second = migrate_forgetting_config(&first.output).unwrap();
4570        assert_eq!(second.changed_count, 0, "second run must not double-append");
4571        assert_eq!(second.output, first.output);
4572    }
4573
4574    #[test]
4575    fn migrate_microcompact_config_idempotent_on_commented_output() {
4576        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4577        let first = migrate_microcompact_config(base).unwrap();
4578        assert_eq!(first.changed_count, 1);
4579        let second = migrate_microcompact_config(&first.output).unwrap();
4580        assert_eq!(second.changed_count, 0, "second run must not double-append");
4581        assert_eq!(second.output, first.output);
4582    }
4583
4584    #[test]
4585    fn migrate_autodream_config_idempotent_on_commented_output() {
4586        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4587        let first = migrate_autodream_config(base).unwrap();
4588        assert_eq!(first.changed_count, 1);
4589        let second = migrate_autodream_config(&first.output).unwrap();
4590        assert_eq!(second.changed_count, 0, "second run must not double-append");
4591        assert_eq!(second.output, first.output);
4592    }
4593
4594    #[test]
4595    fn migrate_compression_predictor_strips_active_section() {
4596        let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
4597        let result = migrate_compression_predictor_config(base).unwrap();
4598        assert!(!result.output.contains("[memory.compression.predictor]"));
4599        assert!(!result.output.contains("min_samples"));
4600        assert!(result.output.contains("[memory.other]"));
4601        assert_eq!(result.changed_count, 1);
4602    }
4603
4604    #[test]
4605    fn migrate_compression_predictor_strips_commented_section() {
4606        let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
4607        let result = migrate_compression_predictor_config(base).unwrap();
4608        assert!(!result.output.contains("compression.predictor"));
4609        assert!(result.output.contains("[memory.other]"));
4610    }
4611
4612    #[test]
4613    fn migrate_compression_predictor_idempotent() {
4614        let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
4615        let first = migrate_compression_predictor_config(base).unwrap();
4616        let second = migrate_compression_predictor_config(&first.output).unwrap();
4617        assert_eq!(second.output, first.output);
4618        assert_eq!(second.changed_count, 0);
4619    }
4620
4621    #[test]
4622    fn migrate_compression_predictor_noop_when_absent() {
4623        let base = "[memory]\ndb_path = \"test\"\n";
4624        let result = migrate_compression_predictor_config(base).unwrap();
4625        assert_eq!(result.output, base);
4626        assert_eq!(result.changed_count, 0);
4627    }
4628
4629    #[test]
4630    fn migrate_database_url_idempotent_on_commented_output() {
4631        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4632        let first = migrate_database_url(base).unwrap();
4633        assert_eq!(first.changed_count, 1);
4634        let second = migrate_database_url(&first.output).unwrap();
4635        assert_eq!(second.changed_count, 0, "second run must not double-append");
4636        assert_eq!(second.output, first.output);
4637    }
4638
4639    #[test]
4640    fn migrate_shell_transactional_idempotent_on_commented_output() {
4641        let base = "[tools]\n[tools.shell]\nallow_list = []\n";
4642        let first = migrate_shell_transactional(base).unwrap();
4643        assert_eq!(first.changed_count, 1);
4644        let second = migrate_shell_transactional(&first.output).unwrap();
4645        assert_eq!(second.changed_count, 0, "second run must not double-append");
4646        assert_eq!(second.output, first.output);
4647    }
4648
4649    #[test]
4650    fn migrate_otel_filter_idempotent_on_commented_output() {
4651        let base = "[telemetry]\nenabled = true\n";
4652        let first = migrate_otel_filter(base).unwrap();
4653        assert_eq!(first.changed_count, 1);
4654        let second = migrate_otel_filter(&first.output).unwrap();
4655        assert_eq!(second.changed_count, 0, "second run must not double-append");
4656        assert_eq!(second.output, first.output);
4657    }
4658
4659    #[test]
4660    fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4661        let migrator = ConfigMigrator::new();
4662        let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4663        let result = migrator.migrate(src).expect("migrate");
4664        let sec_body_start = result
4665            .output
4666            .find("[security.content_isolation]")
4667            .unwrap_or(0);
4668        let sec_body = &result.output[sec_body_start..];
4669        let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4670        let sec_slice = &sec_body[..next_header];
4671        assert!(
4672            sec_slice.contains("# enabled"),
4673            "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4674        );
4675    }
4676
4677    #[test]
4678    fn config_migrator_idempotent_on_realistic_config() {
4679        let base = r#"
4680[agent]
4681name = "Zeph"
4682
4683[memory]
4684db_path = "~/.zeph/memory.db"
4685soft_compaction_threshold = 0.6
4686
4687[index]
4688max_chunks = 12
4689
4690[tools]
4691[tools.shell]
4692allow_list = []
4693
4694[telemetry]
4695enabled = false
4696
4697[security]
4698[security.content_isolation]
4699enabled = true
4700"#;
4701        let migrator = ConfigMigrator::new();
4702        let first = migrator.migrate(base).expect("first migrate");
4703        let second = migrator.migrate(&first.output).expect("second migrate");
4704        assert_eq!(
4705            second.changed_count, 0,
4706            "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4707            second.changed_count
4708        );
4709        assert_eq!(
4710            first.output, second.output,
4711            "output must be identical on second run"
4712        );
4713        for line in first.output.lines() {
4714            if line.starts_with('[') && !line.starts_with("[[") {
4715                assert!(
4716                    !line.contains('#'),
4717                    "section header must not have inline comment: {line:?}"
4718                );
4719            }
4720        }
4721    }
4722
4723    #[test]
4724    fn migrate_claude_prompt_cache_ttl_1h_survives() {
4725        let src = r#"
4726[llm]
4727provider = "claude"
4728
4729[llm.cloud]
4730model = "claude-sonnet-4-6"
4731prompt_cache_ttl = "1h"
4732"#;
4733        let result = migrate_llm_to_providers(src).expect("migrate");
4734        assert!(
4735            result.output.contains("prompt_cache_ttl = \"1h\""),
4736            "1h TTL must be preserved in migrated output:\n{}",
4737            result.output
4738        );
4739    }
4740
4741    #[test]
4742    fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4743        let src = r#"
4744[llm]
4745provider = "claude"
4746
4747[llm.cloud]
4748model = "claude-sonnet-4-6"
4749prompt_cache_ttl = "ephemeral"
4750"#;
4751        let result = migrate_llm_to_providers(src).expect("migrate");
4752        assert!(
4753            !result.output.contains("prompt_cache_ttl"),
4754            "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4755            result.output
4756        );
4757    }
4758
4759    #[test]
4760    fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4761        let src = r#"
4762[[llm.providers]]
4763type = "claude"
4764model = "claude-sonnet-4-6"
4765prompt_cache_ttl = "1h"
4766"#;
4767        let migrator = ConfigMigrator::new();
4768        let first = migrator.migrate(src).expect("first migrate");
4769        let second = migrator.migrate(&first.output).expect("second migrate");
4770        assert_eq!(
4771            first.output, second.output,
4772            "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4773        );
4774    }
4775
4776    // ── migrate_session_recap_config ──────────────────────────────────────────
4777
4778    #[test]
4779    fn migrate_session_recap_adds_block_when_absent() {
4780        let src = "[agent]\nname = \"Zeph\"\n";
4781        let result = migrate_session_recap_config(src).expect("migrate");
4782        assert_eq!(result.changed_count, 1);
4783        assert!(
4784            result
4785                .sections_changed
4786                .contains(&"session.recap".to_owned())
4787        );
4788        assert!(result.output.contains("# [session.recap]"));
4789        assert!(result.output.contains("on_resume = true"));
4790    }
4791
4792    #[test]
4793    fn migrate_session_recap_idempotent_on_commented_block() {
4794        let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4795        let result = migrate_session_recap_config(src).expect("migrate");
4796        assert_eq!(result.changed_count, 0);
4797        assert_eq!(result.output, src);
4798    }
4799
4800    #[test]
4801    fn migrate_session_recap_idempotent_on_active_section() {
4802        let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4803        let result = migrate_session_recap_config(src).expect("migrate");
4804        assert_eq!(result.changed_count, 0);
4805        assert_eq!(result.output, src);
4806    }
4807
4808    // ── migrate_mcp_elicitation_config ────────────────────────────────────────
4809
4810    #[test]
4811    fn migrate_mcp_elicitation_adds_keys_when_absent() {
4812        let src = "[mcp]\nallowed_commands = []\n";
4813        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4814        assert_eq!(result.changed_count, 1);
4815        assert!(
4816            result
4817                .sections_changed
4818                .contains(&"mcp.elicitation".to_owned())
4819        );
4820        assert!(result.output.contains("# elicitation_enabled = false"));
4821        assert!(result.output.contains("# elicitation_timeout = 120"));
4822    }
4823
4824    #[test]
4825    fn migrate_mcp_elicitation_idempotent_when_key_present() {
4826        let src = "[mcp]\nelicitation_enabled = true\n";
4827        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4828        assert_eq!(result.changed_count, 0);
4829        assert_eq!(result.output, src);
4830    }
4831
4832    #[test]
4833    fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4834        let src = "[agent]\nname = \"Zeph\"\n";
4835        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4836        assert_eq!(result.changed_count, 0);
4837        assert_eq!(result.output, src);
4838    }
4839
4840    #[test]
4841    fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4842        // Edge case: `[mcp]` at EOF with no `\n` — replacen would be a no-op.
4843        let src = "[mcp]";
4844        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4845        assert_eq!(result.changed_count, 0);
4846        assert_eq!(result.output, src);
4847    }
4848
4849    // ── migrate_quality_config ────────────────────────────────────────────────
4850
4851    #[test]
4852    fn migrate_quality_adds_block_when_absent() {
4853        let src = "[agent]\nname = \"Zeph\"\n";
4854        let result = migrate_quality_config(src).expect("migrate");
4855        assert_eq!(result.changed_count, 1);
4856        assert!(result.sections_changed.contains(&"quality".to_owned()));
4857        assert!(result.output.contains("# [quality]"));
4858        assert!(result.output.contains("self_check = false"));
4859        assert!(result.output.contains("trigger = \"has_retrieval\""));
4860    }
4861
4862    #[test]
4863    fn migrate_quality_idempotent_on_commented_block() {
4864        let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4865        let result = migrate_quality_config(src).expect("migrate");
4866        assert_eq!(result.changed_count, 0);
4867        assert_eq!(result.output, src);
4868    }
4869
4870    #[test]
4871    fn migrate_quality_idempotent_on_active_section() {
4872        let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4873        let result = migrate_quality_config(src).expect("migrate");
4874        assert_eq!(result.changed_count, 0);
4875        assert_eq!(result.output, src);
4876    }
4877
4878    // ── migrate_acp_subagents_config ─────────────────────────────────────────
4879
4880    #[test]
4881    fn migrate_acp_subagents_adds_block_when_absent() {
4882        let src = "[agent]\nname = \"Zeph\"\n";
4883        let result = migrate_acp_subagents_config(src).expect("migrate");
4884        assert_eq!(result.changed_count, 1);
4885        assert!(
4886            result
4887                .sections_changed
4888                .contains(&"acp.subagents".to_owned())
4889        );
4890        assert!(result.output.contains("# [acp.subagents]"));
4891        assert!(result.output.contains("enabled = false"));
4892    }
4893
4894    #[test]
4895    fn migrate_acp_subagents_idempotent_on_existing_block() {
4896        let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4897        let result = migrate_acp_subagents_config(src).expect("migrate");
4898        assert_eq!(result.changed_count, 0);
4899        assert_eq!(result.output, src);
4900    }
4901
4902    // ── migrate_hooks_permission_denied_config ────────────────────────────────
4903
4904    #[test]
4905    fn migrate_hooks_permission_denied_adds_block_when_absent() {
4906        let src = "[agent]\nname = \"Zeph\"\n";
4907        let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4908        assert_eq!(result.changed_count, 1);
4909        assert!(
4910            result
4911                .sections_changed
4912                .contains(&"hooks.permission_denied".to_owned())
4913        );
4914        assert!(result.output.contains("# [[hooks.permission_denied]]"));
4915        assert!(result.output.contains("ZEPH_TOOL"));
4916    }
4917
4918    #[test]
4919    fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4920        let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4921        let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4922        assert_eq!(result.changed_count, 0);
4923        assert_eq!(result.output, src);
4924    }
4925
4926    // ── migrate_memory_graph_config ───────────────────────────────────────────
4927
4928    #[test]
4929    fn migrate_memory_graph_adds_block_when_absent() {
4930        let src = "[agent]\nname = \"Zeph\"\n";
4931        let result = migrate_memory_graph_config(src).expect("migrate");
4932        assert_eq!(result.changed_count, 1);
4933        assert!(
4934            result
4935                .sections_changed
4936                .contains(&"memory.graph.retrieval".to_owned())
4937        );
4938        assert!(result.output.contains("retrieval_strategy"));
4939        assert!(result.output.contains("# [memory.graph.beam_search]"));
4940    }
4941
4942    #[test]
4943    fn migrate_memory_graph_idempotent_on_existing_block() {
4944        let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4945        let result = migrate_memory_graph_config(src).expect("migrate");
4946        assert_eq!(result.changed_count, 0);
4947        assert_eq!(result.output, src);
4948    }
4949
4950    // ── migrate_scheduler_daemon_config ──────────────────────────────────────
4951
4952    #[test]
4953    fn migrate_scheduler_daemon_adds_block_when_absent() {
4954        let src = "[agent]\nname = \"Zeph\"\n";
4955        let result = migrate_scheduler_daemon_config(src).expect("migrate");
4956        assert_eq!(result.changed_count, 1);
4957        assert!(
4958            result
4959                .sections_changed
4960                .contains(&"scheduler.daemon".to_owned())
4961        );
4962        assert!(result.output.contains("# [scheduler.daemon]"));
4963        assert!(result.output.contains("pid_file"));
4964        assert!(result.output.contains("tick_secs = 60"));
4965        assert!(result.output.contains("shutdown_grace_secs = 30"));
4966        assert!(result.output.contains("catch_up = true"));
4967    }
4968
4969    #[test]
4970    fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4971        let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4972        let result = migrate_scheduler_daemon_config(src).expect("migrate");
4973        assert_eq!(result.changed_count, 0);
4974        assert_eq!(result.output, src);
4975    }
4976
4977    // ── migrate_memory_retrieval_config ──────────────────────────────────────
4978
4979    #[test]
4980    fn migrate_memory_retrieval_adds_block_when_absent() {
4981        let src = "[agent]\nname = \"Zeph\"\n";
4982        let result = migrate_memory_retrieval_config(src).expect("migrate");
4983        assert_eq!(result.changed_count, 1);
4984        assert!(
4985            result
4986                .sections_changed
4987                .contains(&"memory.retrieval".to_owned())
4988        );
4989        assert!(result.output.contains("# [memory.retrieval]"));
4990        assert!(result.output.contains("depth = 0"));
4991        assert!(result.output.contains("context_format"));
4992    }
4993
4994    #[test]
4995    fn migrate_memory_retrieval_idempotent_on_active_section() {
4996        let src = "[memory.retrieval]\ndepth = 40\n";
4997        let result = migrate_memory_retrieval_config(src).expect("migrate");
4998        assert_eq!(result.changed_count, 0);
4999        assert_eq!(result.output, src);
5000    }
5001
5002    #[test]
5003    fn migrate_memory_retrieval_idempotent_on_commented_section() {
5004        let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
5005        let result = migrate_memory_retrieval_config(src).expect("migrate");
5006        assert_eq!(result.changed_count, 0);
5007        assert_eq!(result.output, src);
5008    }
5009
5010    // ── acp PR4 migration ─────────────────────────────────────────────────────
5011
5012    #[test]
5013    fn migrate_adds_pr4_acp_keys_commented() {
5014        let migrator = ConfigMigrator::new();
5015        let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
5016        let out = migrator.migrate(input).expect("migrate");
5017        assert!(
5018            out.output.contains("# additional_directories = []"),
5019            "expected commented additional_directories; got:\n{}",
5020            out.output
5021        );
5022        assert!(
5023            out.output.contains("# auth_methods = [\"agent\"]"),
5024            "expected commented auth_methods; got:\n{}",
5025            out.output
5026        );
5027        assert!(
5028            out.output.contains("# message_ids_enabled = true"),
5029            "expected commented message_ids_enabled; got:\n{}",
5030            out.output
5031        );
5032    }
5033
5034    // ── migrate_memory_reasoning_config ──────────────────────────────────────
5035
5036    #[test]
5037    fn migrate_memory_reasoning_adds_block_when_absent() {
5038        let input = "[agent]\nmodel = \"gpt-4o\"\n";
5039        let result = migrate_memory_reasoning_config(input).unwrap();
5040        assert_eq!(result.changed_count, 1);
5041        assert!(
5042            result
5043                .sections_changed
5044                .contains(&"memory.reasoning".to_owned())
5045        );
5046        assert!(result.output.contains("# [memory.reasoning]"));
5047        assert!(result.output.contains("extraction_timeout_secs = 30"));
5048        assert!(result.output.contains("max_message_chars = 2000"));
5049    }
5050
5051    #[test]
5052    fn migrate_memory_reasoning_idempotent_on_existing_block() {
5053        let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
5054        let result = migrate_memory_reasoning_config(input).unwrap();
5055        assert_eq!(result.changed_count, 0);
5056        assert!(result.sections_changed.is_empty());
5057        assert_eq!(result.output, input);
5058    }
5059
5060    // ── migrate_hooks_turn_complete_config ────────────────────────────────────
5061
5062    #[test]
5063    fn migrate_hooks_turn_complete_adds_block_when_absent() {
5064        let input = "[agent]\nmodel = \"gpt-4o\"\n";
5065        let result = migrate_hooks_turn_complete_config(input).unwrap();
5066        assert_eq!(result.changed_count, 1);
5067        assert!(
5068            result
5069                .sections_changed
5070                .contains(&"hooks.turn_complete".to_owned())
5071        );
5072        assert!(result.output.contains("# [[hooks.turn_complete]]"));
5073        assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
5074        assert!(result.output.contains("timeout_secs = 3"));
5075    }
5076
5077    #[test]
5078    fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
5079        let input =
5080            "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
5081        let result = migrate_hooks_turn_complete_config(input).unwrap();
5082        assert_eq!(result.changed_count, 0);
5083        assert!(result.sections_changed.is_empty());
5084        assert_eq!(result.output, input);
5085    }
5086
5087    // ── migrate_focus_auto_consolidate_min_window ──────────────────────────────
5088
5089    /// S5: the comment must land inside [agent.focus], not after a subsequent section.
5090    #[test]
5091    fn migrate_focus_auto_consolidate_injects_inside_section() {
5092        let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
5093        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5094        assert_eq!(result.changed_count, 1);
5095        let comment_pos = result
5096            .output
5097            .find("auto_consolidate_min_window")
5098            .expect("comment must be present");
5099        let other_pos = result
5100            .output
5101            .find("[other]")
5102            .expect("[other] must be present");
5103        assert!(
5104            comment_pos < other_pos,
5105            "auto_consolidate_min_window comment must appear before [other] section"
5106        );
5107    }
5108
5109    #[test]
5110    fn migrate_focus_auto_consolidate_idempotent() {
5111        let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
5112        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5113        assert_eq!(result.changed_count, 0);
5114        assert_eq!(result.output, input);
5115    }
5116
5117    #[test]
5118    fn migrate_focus_auto_consolidate_noop_when_section_absent() {
5119        let input = "[agent]\nname = \"zeph\"\n";
5120        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5121        assert_eq!(result.changed_count, 0);
5122        assert_eq!(result.output, input);
5123    }
5124
5125    #[test]
5126    fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
5127        let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
5128        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5129        assert_eq!(result.changed_count, 0);
5130        assert_eq!(result.output, input);
5131    }
5132
5133    // ── Migration registry ────────────────────────────────────────────────────
5134
5135    #[test]
5136    fn registry_has_forty_seven_entries() {
5137        assert_eq!(MIGRATIONS.len(), 47);
5138    }
5139
5140    #[test]
5141    fn registry_names_are_unique_and_non_empty() {
5142        let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5143        for name in &names {
5144            assert!(!name.is_empty(), "migration name must not be empty");
5145        }
5146        let mut deduped = names.clone();
5147        deduped.sort_unstable();
5148        deduped.dedup();
5149        assert_eq!(deduped.len(), names.len(), "migration names must be unique");
5150    }
5151
5152    #[test]
5153    fn registry_is_idempotent_on_empty_input() {
5154        // Migrations that append comment blocks cannot be idempotent by design:
5155        // comment text is not parsed as TOML keys, so presence checks always fail.
5156        const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
5157
5158        let mut toml = String::new();
5159        for m in MIGRATIONS.iter() {
5160            let result = m.apply(&toml).expect("registry migration must not fail");
5161            toml = result.output;
5162        }
5163        for m in MIGRATIONS.iter() {
5164            if COMMENT_ONLY.contains(&m.name()) {
5165                continue;
5166            }
5167            let result = m
5168                .apply(&toml)
5169                .expect("registry migration must not fail on second pass");
5170            assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
5171        }
5172    }
5173
5174    #[test]
5175    fn registry_preserves_order_matches_dispatch() {
5176        // Names must follow the documented step order (steps 1–45).
5177        let expected = [
5178            "migrate_stt_to_provider",
5179            "migrate_planner_model_to_provider",
5180            "migrate_mcp_trust_levels",
5181            "migrate_agent_retry_to_tools_retry",
5182            "migrate_database_url",
5183            "migrate_shell_transactional",
5184            "migrate_agent_budget_hint",
5185            "migrate_forgetting_config",
5186            "migrate_compression_predictor_config",
5187            "migrate_microcompact_config",
5188            "migrate_autodream_config",
5189            "migrate_magic_docs_config",
5190            "migrate_telemetry_config",
5191            "migrate_supervisor_config",
5192            "migrate_otel_filter",
5193            "migrate_egress_config",
5194            "migrate_vigil_config",
5195            "migrate_sandbox_config",
5196            "migrate_sandbox_egress_filter",
5197            "migrate_orchestration_persistence",
5198            "migrate_session_recap_config",
5199            "migrate_mcp_elicitation_config",
5200            "migrate_quality_config",
5201            "migrate_acp_subagents_config",
5202            "migrate_hooks_permission_denied_config",
5203            "migrate_memory_graph_config",
5204            "migrate_scheduler_daemon_config",
5205            "migrate_memory_retrieval_config",
5206            "migrate_memory_reasoning_config",
5207            "migrate_memory_reasoning_judge_config",
5208            "migrate_memory_hebbian_config",
5209            "migrate_memory_hebbian_consolidation_config",
5210            "migrate_memory_hebbian_spread_config",
5211            "migrate_hooks_turn_complete_config",
5212            "migrate_focus_auto_consolidate_min_window",
5213            "migrate_session_provider_persistence",
5214            "migrate_memory_retrieval_query_bias",
5215            "migrate_memory_persona_config",
5216            "migrate_qdrant_api_key",
5217            "migrate_mcp_max_connect_attempts",
5218            "migrate_goals_config",
5219            "migrate_tools_compression_config",
5220            "migrate_orchestrator_provider",
5221            "migrate_provider_max_concurrent",
5222            "migrate_gonkagate_to_gonka",
5223            "migrate_cocoon_provider_notice",
5224            "migrate_trace_metadata",
5225        ];
5226        let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5227        assert_eq!(actual, expected);
5228    }
5229
5230    // ── migrate_trace_metadata tests (#4160) ─────────────────────────────────
5231
5232    #[test]
5233    fn migrate_trace_metadata_noop_when_already_present() {
5234        let src = "[telemetry]\nenabled = true\n\n[telemetry.trace_metadata]\n\"env\" = \"prod\"\n";
5235        let result = migrate_trace_metadata(src).unwrap();
5236        assert_eq!(result.changed_count, 0);
5237        assert_eq!(result.output, src);
5238    }
5239
5240    #[test]
5241    fn migrate_trace_metadata_noop_when_no_telemetry_section() {
5242        let src = "[agent]\nmax_turns = 10\n";
5243        let result = migrate_trace_metadata(src).unwrap();
5244        assert_eq!(result.changed_count, 0);
5245        assert_eq!(result.output, src);
5246    }
5247
5248    #[test]
5249    fn migrate_trace_metadata_injects_comment_when_telemetry_present() {
5250        let src = "[telemetry]\nenabled = true\nservice_name = \"zeph\"\n";
5251        let result = migrate_trace_metadata(src).unwrap();
5252        assert_eq!(result.changed_count, 1);
5253        assert!(result.output.contains("trace_metadata"));
5254        assert!(
5255            result
5256                .sections_changed
5257                .contains(&"telemetry.trace_metadata".to_owned())
5258        );
5259        // Idempotent: running again is a no-op.
5260        let result2 = migrate_trace_metadata(&result.output).unwrap();
5261        assert_eq!(result2.changed_count, 0);
5262    }
5263
5264    // ── migrate_qdrant_api_key tests (#3543) ─────────────────────────────────
5265
5266    #[test]
5267    fn migrate_qdrant_api_key_adds_comment_when_absent() {
5268        let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5269        let result = migrate_qdrant_api_key(src).expect("migrate");
5270        assert_eq!(result.changed_count, 1);
5271        assert!(
5272            result
5273                .sections_changed
5274                .contains(&"memory.qdrant_api_key".to_owned())
5275        );
5276        assert!(result.output.contains("# qdrant_api_key = \"\""));
5277    }
5278
5279    #[test]
5280    fn migrate_qdrant_api_key_is_noop_when_present() {
5281        let src =
5282            "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
5283        let result = migrate_qdrant_api_key(src).expect("migrate");
5284        assert_eq!(result.changed_count, 0);
5285        assert!(result.sections_changed.is_empty());
5286        assert_eq!(result.output, src);
5287    }
5288
5289    #[test]
5290    fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
5291        let src = "[agent]\nname = \"Zeph\"\n";
5292        let result = migrate_qdrant_api_key(src).expect("migrate");
5293        assert_eq!(result.changed_count, 1);
5294        assert!(result.output.contains("# qdrant_api_key = \"\""));
5295    }
5296
5297    #[test]
5298    fn migrate_qdrant_api_key_idempotent_on_commented_output() {
5299        let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5300        let first = migrate_qdrant_api_key(base).unwrap();
5301        assert_eq!(first.changed_count, 1);
5302        let second = migrate_qdrant_api_key(&first.output).unwrap();
5303        assert_eq!(second.changed_count, 0, "second run must not double-append");
5304        assert_eq!(second.output, first.output);
5305    }
5306
5307    #[test]
5308    fn migrate_mcp_max_connect_attempts_adds_comment_when_absent() {
5309        let src = "[mcp]\nallowed_commands = []\n";
5310        let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5311        assert_eq!(result.changed_count, 1);
5312        assert!(
5313            result.output.contains("max_connect_attempts"),
5314            "output must mention max_connect_attempts"
5315        );
5316    }
5317
5318    #[test]
5319    fn migrate_mcp_max_connect_attempts_idempotent_when_present() {
5320        let src = "[mcp]\n# max_connect_attempts = 3\nallowed_commands = []\n";
5321        let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5322        assert_eq!(
5323            result.changed_count, 0,
5324            "must not modify already-present key"
5325        );
5326        assert_eq!(result.output, src);
5327    }
5328
5329    #[test]
5330    fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() {
5331        let src = "[agent]\nname = \"Zeph\"\n";
5332        let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5333        assert_eq!(result.changed_count, 0);
5334        assert_eq!(result.output, src);
5335    }
5336
5337    // ── Step 43 — orchestrator_provider ──────────────────────────────────────────────────────────
5338
5339    #[test]
5340    fn step43_adds_orchestrator_provider_comment_when_absent() {
5341        let src = "[orchestration]\nenabled = true\n";
5342        let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5343        assert_eq!(result.changed_count, 1);
5344        assert!(
5345            result.output.contains("orchestrator_provider"),
5346            "migration must inject orchestrator_provider hint"
5347        );
5348    }
5349
5350    #[test]
5351    fn step43_noop_when_orchestrator_provider_already_present() {
5352        let src = "[orchestration]\nenabled = true\norchestrator_provider = \"\"\n";
5353        let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5354        assert_eq!(
5355            result.changed_count, 0,
5356            "must not modify already-present key"
5357        );
5358        assert_eq!(result.output, src);
5359    }
5360
5361    // ── Step 44 — max_concurrent per-provider ────────────────────────────────────────────────────
5362
5363    #[test]
5364    fn step44_adds_max_concurrent_comment_when_providers_present() {
5365        let src = "[[llm.providers]]\nname = \"quality\"\ntype = \"openai\"\n";
5366        let result = migrate_provider_max_concurrent(src).expect("migrate");
5367        assert_eq!(result.changed_count, 1);
5368        assert!(
5369            result.output.contains("max_concurrent"),
5370            "migration must inject max_concurrent hint"
5371        );
5372    }
5373
5374    #[test]
5375    fn step44_noop_when_max_concurrent_already_present() {
5376        let src = "[[llm.providers]]\nname = \"quality\"\nmax_concurrent = 4\n";
5377        let result = migrate_provider_max_concurrent(src).expect("migrate");
5378        assert_eq!(
5379            result.changed_count, 0,
5380            "must not modify already-present key"
5381        );
5382        assert_eq!(result.output, src);
5383    }
5384
5385    #[test]
5386    fn step44_noop_when_no_providers_section() {
5387        let src = "[agent]\nname = \"Zeph\"\n";
5388        let result = migrate_provider_max_concurrent(src).expect("migrate");
5389        assert_eq!(result.changed_count, 0);
5390        assert_eq!(result.output, src);
5391    }
5392
5393    // ── Step 45 — migrate_gonkagate_to_gonka ─────────────────────────────────
5394
5395    #[test]
5396    fn step45_adds_advisory_comment_when_gonkagate_present() {
5397        let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5398        let result = migrate_gonkagate_to_gonka(src);
5399        assert!(result.changed_count > 0, "must detect gonkagate entry");
5400        assert!(
5401            result.output.contains("[migration] GonkaGate detected"),
5402            "advisory comment must be added"
5403        );
5404        // Comment must appear before the [[llm.providers]] table header, not inside it.
5405        let comment_pos = result
5406            .output
5407            .find("[migration] GonkaGate detected")
5408            .unwrap();
5409        let header_pos = result.output.find("[[llm.providers]]").unwrap();
5410        assert!(
5411            comment_pos < header_pos,
5412            "advisory comment must precede the [[llm.providers]] header"
5413        );
5414    }
5415
5416    #[test]
5417    fn step45_noop_when_no_gonkagate() {
5418        let src = "[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n";
5419        let result = migrate_gonkagate_to_gonka(src);
5420        assert_eq!(result.changed_count, 0);
5421        assert_eq!(result.output, src);
5422    }
5423
5424    #[test]
5425    fn step45_does_not_double_insert_comment() {
5426        let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5427        let first = migrate_gonkagate_to_gonka(src);
5428        let second = migrate_gonkagate_to_gonka(&first.output);
5429        // Second run must not add another comment line.
5430        assert_eq!(second.changed_count, 0, "idempotent on second run");
5431    }
5432
5433    // ── Step 46 — Cocoon provider notice ──────────────────────────────────────
5434
5435    #[test]
5436    fn migrate_cocoon_noop_empty_config() {
5437        let src = "";
5438        let result = migrate_cocoon_provider_notice(src).unwrap();
5439        assert_eq!(result.changed_count, 0);
5440        assert_eq!(result.output, src);
5441    }
5442
5443    #[test]
5444    fn migrate_cocoon_noop_existing_config() {
5445        let src = "[agent]\nname = \"zeph\"\n\n[[llm.providers]]\ntype = \"ollama\"\n";
5446        let result = migrate_cocoon_provider_notice(src).unwrap();
5447        assert_eq!(result.changed_count, 0);
5448        assert_eq!(result.output, src);
5449    }
5450
5451    #[test]
5452    fn migrate_cocoon_idempotent() {
5453        let src = "[[llm.providers]]\ntype = \"cocoon\"\nname = \"tee\"\n";
5454        let first = migrate_cocoon_provider_notice(src).unwrap();
5455        let second = migrate_cocoon_provider_notice(&first.output).unwrap();
5456        assert_eq!(second.output, first.output);
5457        assert_eq!(second.changed_count, 0);
5458    }
5459}