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