Skip to main content

zeph_config/migrate/
mod.rs

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