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, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
3309    MigrateEgressConfig, MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig,
3310    MigrateGoalsConfig, MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig,
3311    MigrateHooksTurnComplete, MigrateMagicDocsConfig, MigrateMcpElicitationConfig,
3312    MigrateMcpMaxConnectAttempts, MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryHebbian,
3313    MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
3314    MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
3315    MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, MigrateOrchestrationPersistence,
3316    MigrateOrchestratorProvider, MigrateOtelFilter, MigratePlannerModelToProvider,
3317    MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQualityConfig, MigrateSandboxConfig,
3318    MigrateSandboxEgressFilter, MigrateSchedulerDaemon, MigrateSessionProviderPersistence,
3319    MigrateSessionRecapConfig, MigrateShellTransactional, MigrateSttToProvider,
3320    MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
3321    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/// Ordered registry of all sequential migration steps (steps 1–45).
3394///
3395/// Each entry wraps the corresponding free function and is evaluated lazily at first access.
3396/// The ordering is chronological; the dispatch loop in `src/commands/migrate.rs` iterates
3397/// this registry rather than calling free functions individually.
3398///
3399/// # Examples
3400///
3401/// ```rust
3402/// use zeph_config::migrate::MIGRATIONS;
3403///
3404/// // Every step in the registry has a non-empty name.
3405/// for m in MIGRATIONS.iter() {
3406///     assert!(!m.name().is_empty());
3407/// }
3408/// ```
3409pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3410    std::sync::LazyLock::new(|| {
3411        vec![
3412            // Steps 1–25 (pre-existing migrations)
3413            Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3414            Box::new(MigratePlannerModelToProvider),
3415            Box::new(MigrateMcpTrustLevels),
3416            Box::new(MigrateAgentRetryToToolsRetry),
3417            Box::new(MigrateDatabaseUrl),
3418            Box::new(MigrateShellTransactional),
3419            Box::new(MigrateAgentBudgetHint),
3420            Box::new(MigrateForgettingConfig),
3421            Box::new(MigrateCompressionPredictorConfig),
3422            Box::new(MigrateMicrocompactConfig),
3423            Box::new(MigrateAutodreamConfig),
3424            Box::new(MigrateMagicDocsConfig),
3425            Box::new(MigrateTelemetryConfig),
3426            Box::new(MigrateSupervisorConfig),
3427            Box::new(MigrateOtelFilter),
3428            Box::new(MigrateEgressConfig),
3429            Box::new(MigrateVigilConfig),
3430            Box::new(MigrateSandboxConfig),
3431            Box::new(MigrateSandboxEgressFilter),
3432            Box::new(MigrateOrchestrationPersistence),
3433            Box::new(MigrateSessionRecapConfig),
3434            Box::new(MigrateMcpElicitationConfig),
3435            Box::new(MigrateQualityConfig),
3436            Box::new(MigrateAcpSubagentsConfig),
3437            Box::new(MigrateHooksPermissionDeniedConfig),
3438            // Steps 26–35 (most recent migrations, pre-stable-defaults)
3439            Box::new(MigrateMemoryGraph),
3440            Box::new(MigrateSchedulerDaemon),
3441            Box::new(MigrateMemoryRetrieval),
3442            Box::new(MigrateMemoryReasoning),
3443            Box::new(MigrateMemoryReasoningJudge),
3444            Box::new(MigrateMemoryHebbian),
3445            Box::new(MigrateMemoryHebbianConsolidation),
3446            Box::new(MigrateMemoryHebbianSpread),
3447            Box::new(MigrateHooksTurnComplete),
3448            Box::new(MigrateFocusAutoConsolidateMinWindow),
3449            // Steps 36–38 (stable-defaults: flip verified-stable config keys to on)
3450            Box::new(MigrateSessionProviderPersistence),
3451            Box::new(MigrateMemoryRetrievalQueryBias),
3452            Box::new(MigrateMemoryPersonaConfig),
3453            // Step 39 — optional Qdrant API key (#3543)
3454            Box::new(MigrateQdrantApiKey),
3455            // Step 40 — MCP startup auto-retry max_connect_attempts (#3568)
3456            Box::new(MigrateMcpMaxConnectAttempts),
3457            // Steps 41–42 — goal lifecycle and TACO compression (#3567, #3306)
3458            Box::new(MigrateGoalsConfig),
3459            Box::new(MigrateToolsCompressionConfig),
3460            // Step 43 — orchestrator_provider for scheduling-tier LLM calls (#3300)
3461            Box::new(MigrateOrchestratorProvider),
3462            // Step 44 — max_concurrent per-provider admission control hint (#3299)
3463            Box::new(MigrateProviderMaxConcurrent),
3464            // Step 45 — advisory notice for GonkaGate → native Gonka upgrade path (#3613)
3465            Box::new(MigrateGonkagateToGonka),
3466        ]
3467    });
3468
3469// Helper to create a formatted value (used in tests).
3470#[cfg(test)]
3471fn make_formatted_str(s: &str) -> Value {
3472    use toml_edit::Formatted;
3473    Value::String(Formatted::new(s.to_owned()))
3474}
3475
3476#[cfg(test)]
3477mod tests {
3478    use super::*;
3479
3480    #[test]
3481    fn migrations_registry_has_all_steps() {
3482        assert_eq!(
3483            MIGRATIONS.len(),
3484            45,
3485            "MIGRATIONS registry must contain all 45 sequential steps"
3486        );
3487        for m in MIGRATIONS.iter() {
3488            assert!(
3489                !m.name().is_empty(),
3490                "each migration must have a non-empty name"
3491            );
3492        }
3493    }
3494
3495    #[test]
3496    fn migrations_registry_applies_to_empty_config() {
3497        let mut toml = String::new();
3498        for m in MIGRATIONS.iter() {
3499            toml = m
3500                .apply(&toml)
3501                .expect("migration must not fail on empty config")
3502                .output;
3503        }
3504        // After all steps, the output should at minimum be valid TOML (parseable).
3505        toml.parse::<toml_edit::DocumentMut>()
3506            .expect("registry output must be valid TOML");
3507    }
3508
3509    #[test]
3510    fn empty_config_gets_sections_as_comments() {
3511        let migrator = ConfigMigrator::new();
3512        let result = migrator.migrate("").expect("migrate empty");
3513        // Should have added sections since reference is non-empty.
3514        assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
3515        // Output should mention at least agent section.
3516        assert!(
3517            result.output.contains("[agent]") || result.output.contains("# [agent]"),
3518            "expected agent section in output, got:\n{}",
3519            result.output
3520        );
3521    }
3522
3523    #[test]
3524    fn existing_values_not_overwritten() {
3525        let user = r#"
3526[agent]
3527name = "MyAgent"
3528max_tool_iterations = 5
3529"#;
3530        let migrator = ConfigMigrator::new();
3531        let result = migrator.migrate(user).expect("migrate");
3532        // Original name preserved.
3533        assert!(
3534            result.output.contains("name = \"MyAgent\""),
3535            "user value should be preserved"
3536        );
3537        assert!(
3538            result.output.contains("max_tool_iterations = 5"),
3539            "user value should be preserved"
3540        );
3541        // Should not appear as commented default.
3542        assert!(
3543            !result.output.contains("# max_tool_iterations = 10"),
3544            "already-set key should not appear as comment"
3545        );
3546    }
3547
3548    #[test]
3549    fn missing_nested_key_added_as_comment() {
3550        // User has [memory] but is missing some keys.
3551        let user = r#"
3552[memory]
3553sqlite_path = ".zeph/data/zeph.db"
3554"#;
3555        let migrator = ConfigMigrator::new();
3556        let result = migrator.migrate(user).expect("migrate");
3557        // history_limit should be added as comment since it's in reference.
3558        assert!(
3559            result.output.contains("# history_limit"),
3560            "missing key should be added as comment, got:\n{}",
3561            result.output
3562        );
3563    }
3564
3565    #[test]
3566    fn unknown_user_keys_preserved() {
3567        let user = r#"
3568[agent]
3569name = "Test"
3570my_custom_key = "preserved"
3571"#;
3572        let migrator = ConfigMigrator::new();
3573        let result = migrator.migrate(user).expect("migrate");
3574        assert!(
3575            result.output.contains("my_custom_key = \"preserved\""),
3576            "custom user keys must not be removed"
3577        );
3578    }
3579
3580    #[test]
3581    fn idempotent() {
3582        let migrator = ConfigMigrator::new();
3583        let first = migrator
3584            .migrate("[agent]\nname = \"Zeph\"\n")
3585            .expect("first migrate");
3586        let second = migrator.migrate(&first.output).expect("second migrate");
3587        assert_eq!(
3588            first.output, second.output,
3589            "idempotent: full output must be identical on second run"
3590        );
3591    }
3592
3593    #[test]
3594    fn malformed_input_returns_error() {
3595        let migrator = ConfigMigrator::new();
3596        let err = migrator
3597            .migrate("[[invalid toml [[[")
3598            .expect_err("should error");
3599        assert!(
3600            matches!(err, MigrateError::Parse(_)),
3601            "expected Parse error"
3602        );
3603    }
3604
3605    #[test]
3606    fn array_of_tables_preserved() {
3607        let user = r#"
3608[mcp]
3609allowed_commands = ["npx"]
3610
3611[[mcp.servers]]
3612id = "my-server"
3613command = "npx"
3614args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3615"#;
3616        let migrator = ConfigMigrator::new();
3617        let result = migrator.migrate(user).expect("migrate");
3618        // User's [[mcp.servers]] entry must survive.
3619        assert!(
3620            result.output.contains("[[mcp.servers]]"),
3621            "array-of-tables entries must be preserved"
3622        );
3623        assert!(result.output.contains("id = \"my-server\""));
3624    }
3625
3626    #[test]
3627    fn canonical_ordering_applied() {
3628        // Put memory before agent intentionally.
3629        let user = r#"
3630[memory]
3631sqlite_path = ".zeph/data/zeph.db"
3632
3633[agent]
3634name = "Test"
3635"#;
3636        let migrator = ConfigMigrator::new();
3637        let result = migrator.migrate(user).expect("migrate");
3638        // agent should appear before memory in canonical order.
3639        let agent_pos = result.output.find("[agent]");
3640        let memory_pos = result.output.find("[memory]");
3641        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3642            assert!(a < m, "agent section should precede memory section");
3643        }
3644    }
3645
3646    #[test]
3647    fn value_to_toml_string_formats_correctly() {
3648        use toml_edit::Formatted;
3649
3650        let s = make_formatted_str("hello");
3651        assert_eq!(value_to_toml_string(&s), "\"hello\"");
3652
3653        let i = Value::Integer(Formatted::new(42_i64));
3654        assert_eq!(value_to_toml_string(&i), "42");
3655
3656        let b = Value::Boolean(Formatted::new(true));
3657        assert_eq!(value_to_toml_string(&b), "true");
3658
3659        let f = Value::Float(Formatted::new(1.0_f64));
3660        assert_eq!(value_to_toml_string(&f), "1.0");
3661
3662        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3663        assert_eq!(value_to_toml_string(&f2), "3.14");
3664
3665        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3666        let arr_val = Value::Array(arr);
3667        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3668
3669        let empty_arr = Value::Array(Array::new());
3670        assert_eq!(value_to_toml_string(&empty_arr), "[]");
3671    }
3672
3673    #[test]
3674    fn idempotent_full_output_unchanged() {
3675        // Stronger idempotency: the entire output string must not change on a second pass.
3676        let migrator = ConfigMigrator::new();
3677        let first = migrator
3678            .migrate("[agent]\nname = \"Zeph\"\n")
3679            .expect("first migrate");
3680        let second = migrator.migrate(&first.output).expect("second migrate");
3681        assert_eq!(
3682            first.output, second.output,
3683            "full output string must be identical after second migration pass"
3684        );
3685    }
3686
3687    #[test]
3688    fn full_config_produces_zero_additions() {
3689        // Migrating the reference config itself should add nothing new.
3690        let reference = include_str!("../../config/default.toml");
3691        let migrator = ConfigMigrator::new();
3692        let result = migrator.migrate(reference).expect("migrate reference");
3693        assert_eq!(
3694            result.changed_count, 0,
3695            "migrating the canonical reference should add nothing (changed_count = {})",
3696            result.changed_count
3697        );
3698        assert!(
3699            result.sections_changed.is_empty(),
3700            "migrating the canonical reference should report no sections_changed: {:?}",
3701            result.sections_changed
3702        );
3703    }
3704
3705    #[test]
3706    fn empty_config_changed_count_is_positive() {
3707        // Stricter variant of empty_config_gets_sections_as_comments.
3708        let migrator = ConfigMigrator::new();
3709        let result = migrator.migrate("").expect("migrate empty");
3710        assert!(
3711            result.changed_count > 0,
3712            "empty config must report changed_count > 0"
3713        );
3714    }
3715
3716    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
3717    // for a pre-guardrail config that has [security] but no [security.guardrail].
3718    #[test]
3719    fn security_without_guardrail_gets_guardrail_commented() {
3720        let user = "[security]\nredact_secrets = true\n";
3721        let migrator = ConfigMigrator::new();
3722        let result = migrator.migrate(user).expect("migrate");
3723        // The generic diff mechanism must add guardrail keys as commented defaults.
3724        assert!(
3725            result.output.contains("guardrail"),
3726            "migration must add guardrail keys for configs without [security.guardrail]: \
3727             got:\n{}",
3728            result.output
3729        );
3730    }
3731
3732    #[test]
3733    fn migrate_reference_contains_tools_policy() {
3734        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
3735        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
3736        // `zeph --migrate-config` will surface the section to users as a discoverable commented
3737        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
3738        let reference = include_str!("../../config/default.toml");
3739        assert!(
3740            reference.contains("[tools.policy]"),
3741            "default.toml must contain [tools.policy] section so migrate-config can surface it"
3742        );
3743        assert!(
3744            reference.contains("enabled = false"),
3745            "tools.policy section must include enabled = false default"
3746        );
3747    }
3748
3749    #[test]
3750    fn migrate_reference_contains_probe_section() {
3751        // default.toml must contain the probe section comment block so users can discover it
3752        // when reading the file directly or after running --migrate-config.
3753        let reference = include_str!("../../config/default.toml");
3754        assert!(
3755            reference.contains("[memory.compression.probe]"),
3756            "default.toml must contain [memory.compression.probe] section comment"
3757        );
3758        assert!(
3759            reference.contains("hard_fail_threshold"),
3760            "probe section must include hard_fail_threshold default"
3761        );
3762    }
3763
3764    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
3765
3766    #[test]
3767    fn migrate_llm_no_llm_section_is_noop() {
3768        let src = "[agent]\nname = \"Zeph\"\n";
3769        let result = migrate_llm_to_providers(src).expect("migrate");
3770        assert_eq!(result.changed_count, 0);
3771        assert_eq!(result.output, src);
3772    }
3773
3774    #[test]
3775    fn migrate_llm_already_new_format_is_noop() {
3776        let src = r#"
3777[llm]
3778[[llm.providers]]
3779type = "ollama"
3780model = "qwen3:8b"
3781"#;
3782        let result = migrate_llm_to_providers(src).expect("migrate");
3783        assert_eq!(result.changed_count, 0);
3784    }
3785
3786    #[test]
3787    fn migrate_llm_ollama_produces_providers_block() {
3788        let src = r#"
3789[llm]
3790provider = "ollama"
3791model = "qwen3:8b"
3792base_url = "http://localhost:11434"
3793embedding_model = "nomic-embed-text"
3794"#;
3795        let result = migrate_llm_to_providers(src).expect("migrate");
3796        assert!(
3797            result.output.contains("[[llm.providers]]"),
3798            "should contain [[llm.providers]]:\n{}",
3799            result.output
3800        );
3801        assert!(
3802            result.output.contains("type = \"ollama\""),
3803            "{}",
3804            result.output
3805        );
3806        assert!(
3807            result.output.contains("model = \"qwen3:8b\""),
3808            "{}",
3809            result.output
3810        );
3811    }
3812
3813    #[test]
3814    fn migrate_llm_claude_produces_providers_block() {
3815        let src = r#"
3816[llm]
3817provider = "claude"
3818
3819[llm.cloud]
3820model = "claude-sonnet-4-6"
3821max_tokens = 8192
3822server_compaction = true
3823"#;
3824        let result = migrate_llm_to_providers(src).expect("migrate");
3825        assert!(
3826            result.output.contains("[[llm.providers]]"),
3827            "{}",
3828            result.output
3829        );
3830        assert!(
3831            result.output.contains("type = \"claude\""),
3832            "{}",
3833            result.output
3834        );
3835        assert!(
3836            result.output.contains("model = \"claude-sonnet-4-6\""),
3837            "{}",
3838            result.output
3839        );
3840        assert!(
3841            result.output.contains("server_compaction = true"),
3842            "{}",
3843            result.output
3844        );
3845    }
3846
3847    #[test]
3848    fn migrate_llm_openai_copies_fields() {
3849        let src = r#"
3850[llm]
3851provider = "openai"
3852
3853[llm.openai]
3854base_url = "https://api.openai.com/v1"
3855model = "gpt-4o"
3856max_tokens = 4096
3857"#;
3858        let result = migrate_llm_to_providers(src).expect("migrate");
3859        assert!(
3860            result.output.contains("type = \"openai\""),
3861            "{}",
3862            result.output
3863        );
3864        assert!(
3865            result
3866                .output
3867                .contains("base_url = \"https://api.openai.com/v1\""),
3868            "{}",
3869            result.output
3870        );
3871    }
3872
3873    #[test]
3874    fn migrate_llm_gemini_copies_fields() {
3875        let src = r#"
3876[llm]
3877provider = "gemini"
3878
3879[llm.gemini]
3880model = "gemini-2.0-flash"
3881max_tokens = 8192
3882base_url = "https://generativelanguage.googleapis.com"
3883"#;
3884        let result = migrate_llm_to_providers(src).expect("migrate");
3885        assert!(
3886            result.output.contains("type = \"gemini\""),
3887            "{}",
3888            result.output
3889        );
3890        assert!(
3891            result.output.contains("model = \"gemini-2.0-flash\""),
3892            "{}",
3893            result.output
3894        );
3895    }
3896
3897    #[test]
3898    fn migrate_llm_compatible_copies_multiple_entries() {
3899        let src = r#"
3900[llm]
3901provider = "compatible"
3902
3903[[llm.compatible]]
3904name = "proxy-a"
3905base_url = "http://proxy-a:8080/v1"
3906model = "llama3"
3907max_tokens = 4096
3908
3909[[llm.compatible]]
3910name = "proxy-b"
3911base_url = "http://proxy-b:8080/v1"
3912model = "mistral"
3913max_tokens = 2048
3914"#;
3915        let result = migrate_llm_to_providers(src).expect("migrate");
3916        // Both compatible entries should be emitted.
3917        let count = result.output.matches("[[llm.providers]]").count();
3918        assert_eq!(
3919            count, 2,
3920            "expected 2 [[llm.providers]] blocks:\n{}",
3921            result.output
3922        );
3923        assert!(
3924            result.output.contains("name = \"proxy-a\""),
3925            "{}",
3926            result.output
3927        );
3928        assert!(
3929            result.output.contains("name = \"proxy-b\""),
3930            "{}",
3931            result.output
3932        );
3933    }
3934
3935    #[test]
3936    fn migrate_llm_mixed_format_errors() {
3937        // Legacy + new format together should produce an error.
3938        let src = r#"
3939[llm]
3940provider = "ollama"
3941
3942[[llm.providers]]
3943type = "ollama"
3944"#;
3945        assert!(
3946            migrate_llm_to_providers(src).is_err(),
3947            "mixed format must return error"
3948        );
3949    }
3950
3951    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
3952
3953    #[test]
3954    fn stt_migration_no_stt_section_returns_unchanged() {
3955        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
3956        let result = migrate_stt_to_provider(src).unwrap();
3957        assert_eq!(result.changed_count, 0);
3958        assert_eq!(result.output, src);
3959    }
3960
3961    #[test]
3962    fn stt_migration_no_model_or_base_url_returns_unchanged() {
3963        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
3964        let result = migrate_stt_to_provider(src).unwrap();
3965        assert_eq!(result.changed_count, 0);
3966    }
3967
3968    #[test]
3969    fn stt_migration_moves_model_to_provider_entry() {
3970        let src = r#"
3971[llm]
3972
3973[[llm.providers]]
3974type = "openai"
3975name = "quality"
3976model = "gpt-5.4"
3977
3978[llm.stt]
3979provider = "quality"
3980model = "gpt-4o-mini-transcribe"
3981language = "en"
3982"#;
3983        let result = migrate_stt_to_provider(src).unwrap();
3984        assert_eq!(result.changed_count, 1);
3985        // stt_model should appear in providers entry.
3986        assert!(
3987            result.output.contains("stt_model"),
3988            "stt_model must be in output"
3989        );
3990        // model should be removed from [llm.stt].
3991        // The output should parse cleanly.
3992        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3993        let stt = doc
3994            .get("llm")
3995            .and_then(toml_edit::Item::as_table)
3996            .and_then(|l| l.get("stt"))
3997            .and_then(toml_edit::Item::as_table)
3998            .unwrap();
3999        assert!(
4000            stt.get("model").is_none(),
4001            "model must be removed from [llm.stt]"
4002        );
4003        assert_eq!(
4004            stt.get("provider").and_then(toml_edit::Item::as_str),
4005            Some("quality")
4006        );
4007    }
4008
4009    #[test]
4010    fn stt_migration_creates_new_provider_when_no_match() {
4011        let src = r#"
4012[llm]
4013
4014[[llm.providers]]
4015type = "ollama"
4016name = "local"
4017model = "qwen3:8b"
4018
4019[llm.stt]
4020provider = "whisper"
4021model = "whisper-1"
4022base_url = "https://api.openai.com/v1"
4023language = "en"
4024"#;
4025        let result = migrate_stt_to_provider(src).unwrap();
4026        assert!(
4027            result.output.contains("openai-stt"),
4028            "new entry name must be openai-stt"
4029        );
4030        assert!(
4031            result.output.contains("stt_model"),
4032            "stt_model must be in output"
4033        );
4034    }
4035
4036    #[test]
4037    fn stt_migration_candle_whisper_creates_candle_entry() {
4038        let src = r#"
4039[llm]
4040
4041[llm.stt]
4042provider = "candle-whisper"
4043model = "openai/whisper-tiny"
4044language = "auto"
4045"#;
4046        let result = migrate_stt_to_provider(src).unwrap();
4047        assert!(
4048            result.output.contains("local-whisper"),
4049            "candle entry name must be local-whisper"
4050        );
4051        assert!(result.output.contains("candle"), "type must be candle");
4052    }
4053
4054    #[test]
4055    fn stt_migration_w2_assigns_explicit_name() {
4056        // Provider has no explicit name (type = "openai") — migration must assign one.
4057        let src = r#"
4058[llm]
4059
4060[[llm.providers]]
4061type = "openai"
4062model = "gpt-5.4"
4063
4064[llm.stt]
4065provider = "openai"
4066model = "whisper-1"
4067language = "auto"
4068"#;
4069        let result = migrate_stt_to_provider(src).unwrap();
4070        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4071        let providers = doc
4072            .get("llm")
4073            .and_then(toml_edit::Item::as_table)
4074            .and_then(|l| l.get("providers"))
4075            .and_then(toml_edit::Item::as_array_of_tables)
4076            .unwrap();
4077        let entry = providers
4078            .iter()
4079            .find(|t| t.get("stt_model").is_some())
4080            .unwrap();
4081        // Must have an explicit `name` field (W2).
4082        assert!(
4083            entry.get("name").is_some(),
4084            "migrated entry must have explicit name"
4085        );
4086    }
4087
4088    #[test]
4089    fn stt_migration_removes_base_url_from_stt_table() {
4090        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
4091        let src = r#"
4092[llm]
4093
4094[[llm.providers]]
4095type = "openai"
4096name = "quality"
4097model = "gpt-5.4"
4098
4099[llm.stt]
4100provider = "quality"
4101model = "whisper-1"
4102base_url = "https://api.openai.com/v1"
4103language = "en"
4104"#;
4105        let result = migrate_stt_to_provider(src).unwrap();
4106        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4107        let stt = doc
4108            .get("llm")
4109            .and_then(toml_edit::Item::as_table)
4110            .and_then(|l| l.get("stt"))
4111            .and_then(toml_edit::Item::as_table)
4112            .unwrap();
4113        assert!(
4114            stt.get("model").is_none(),
4115            "model must be removed from [llm.stt]"
4116        );
4117        assert!(
4118            stt.get("base_url").is_none(),
4119            "base_url must be removed from [llm.stt]"
4120        );
4121    }
4122
4123    #[test]
4124    fn migrate_planner_model_to_provider_with_field() {
4125        let input = r#"
4126[orchestration]
4127enabled = true
4128planner_model = "gpt-4o"
4129max_tasks = 20
4130"#;
4131        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4132        assert_eq!(result.changed_count, 1, "changed_count must be 1");
4133        assert!(
4134            !result.output.contains("planner_model = "),
4135            "planner_model key must be removed from output"
4136        );
4137        assert!(
4138            result.output.contains("# planner_provider"),
4139            "commented-out planner_provider entry must be present"
4140        );
4141        assert!(
4142            result.output.contains("gpt-4o"),
4143            "old value must appear in the comment"
4144        );
4145        assert!(
4146            result.output.contains("MIGRATED"),
4147            "comment must include MIGRATED marker"
4148        );
4149    }
4150
4151    #[test]
4152    fn migrate_planner_model_to_provider_no_op() {
4153        let input = r"
4154[orchestration]
4155enabled = true
4156max_tasks = 20
4157";
4158        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4159        assert_eq!(
4160            result.changed_count, 0,
4161            "changed_count must be 0 when field is absent"
4162        );
4163        assert_eq!(
4164            result.output, input,
4165            "output must equal input when nothing to migrate"
4166        );
4167    }
4168
4169    #[test]
4170    fn migrate_error_invalid_structure_formats_correctly() {
4171        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
4172        // produces a human-readable message. The error path is triggered when the [llm] item
4173        // is present but cannot be obtained as a mutable table (defensive guard replacing the
4174        // previous .expect() calls that would have panicked).
4175        let err = MigrateError::InvalidStructure("test sentinel");
4176        assert!(
4177            matches!(err, MigrateError::InvalidStructure(_)),
4178            "variant must match"
4179        );
4180        let msg = err.to_string();
4181        assert!(
4182            msg.contains("invalid TOML structure"),
4183            "error message must mention 'invalid TOML structure', got: {msg}"
4184        );
4185        assert!(
4186            msg.contains("test sentinel"),
4187            "message must include reason: {msg}"
4188        );
4189    }
4190
4191    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
4192
4193    #[test]
4194    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
4195        let src = r#"
4196[mcp]
4197allowed_commands = ["npx"]
4198
4199[[mcp.servers]]
4200id = "srv-a"
4201command = "npx"
4202args = ["-y", "some-mcp"]
4203
4204[[mcp.servers]]
4205id = "srv-b"
4206command = "npx"
4207args = ["-y", "other-mcp"]
4208"#;
4209        let result = migrate_mcp_trust_levels(src).expect("migrate");
4210        assert_eq!(
4211            result.changed_count, 2,
4212            "both entries must get trust_level added"
4213        );
4214        assert!(
4215            result
4216                .sections_changed
4217                .contains(&"mcp.servers.trust_level".to_owned()),
4218            "sections_changed must report mcp.servers.trust_level"
4219        );
4220        // Both entries must now contain trust_level = "trusted"
4221        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
4222        assert_eq!(
4223            occurrences, 2,
4224            "each entry must have trust_level = \"trusted\""
4225        );
4226    }
4227
4228    #[test]
4229    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
4230        let src = r#"
4231[[mcp.servers]]
4232id = "srv-a"
4233command = "npx"
4234trust_level = "sandboxed"
4235tool_allowlist = ["read_file"]
4236
4237[[mcp.servers]]
4238id = "srv-b"
4239command = "npx"
4240"#;
4241        let result = migrate_mcp_trust_levels(src).expect("migrate");
4242        // Only srv-b has no trust_level, so only 1 entry should be updated
4243        assert_eq!(
4244            result.changed_count, 1,
4245            "only entry without trust_level gets updated"
4246        );
4247        // srv-a's sandboxed value must not be overwritten
4248        assert!(
4249            result.output.contains("trust_level = \"sandboxed\""),
4250            "existing trust_level must not be overwritten"
4251        );
4252        // srv-b gets trusted
4253        assert!(
4254            result.output.contains("trust_level = \"trusted\""),
4255            "entry without trust_level must get trusted"
4256        );
4257    }
4258
4259    #[test]
4260    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4261        let src = "[agent]\nname = \"Zeph\"\n";
4262        let result = migrate_mcp_trust_levels(src).expect("migrate");
4263        assert_eq!(result.changed_count, 0);
4264        assert!(result.sections_changed.is_empty());
4265        assert_eq!(result.output, src);
4266    }
4267
4268    #[test]
4269    fn migrate_mcp_trust_levels_no_servers_is_noop() {
4270        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4271        let result = migrate_mcp_trust_levels(src).expect("migrate");
4272        assert_eq!(result.changed_count, 0);
4273        assert!(result.sections_changed.is_empty());
4274        assert_eq!(result.output, src);
4275    }
4276
4277    #[test]
4278    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4279        let src = r#"
4280[[mcp.servers]]
4281id = "srv-a"
4282trust_level = "trusted"
4283
4284[[mcp.servers]]
4285id = "srv-b"
4286trust_level = "untrusted"
4287"#;
4288        let result = migrate_mcp_trust_levels(src).expect("migrate");
4289        assert_eq!(result.changed_count, 0);
4290        assert!(result.sections_changed.is_empty());
4291    }
4292
4293    #[test]
4294    fn migrate_database_url_adds_comment_when_absent() {
4295        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4296        let result = migrate_database_url(src).expect("migrate");
4297        assert_eq!(result.changed_count, 1);
4298        assert!(
4299            result
4300                .sections_changed
4301                .contains(&"memory.database_url".to_owned())
4302        );
4303        assert!(result.output.contains("# database_url = \"\""));
4304    }
4305
4306    #[test]
4307    fn migrate_database_url_is_noop_when_present() {
4308        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4309        let result = migrate_database_url(src).expect("migrate");
4310        assert_eq!(result.changed_count, 0);
4311        assert!(result.sections_changed.is_empty());
4312        assert_eq!(result.output, src);
4313    }
4314
4315    #[test]
4316    fn migrate_database_url_creates_memory_section_when_absent() {
4317        let src = "[agent]\nname = \"Zeph\"\n";
4318        let result = migrate_database_url(src).expect("migrate");
4319        assert_eq!(result.changed_count, 1);
4320        assert!(result.output.contains("# database_url = \"\""));
4321    }
4322
4323    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
4324
4325    #[test]
4326    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4327        let src = "[agent]\nname = \"Zeph\"\n";
4328        let result = migrate_agent_budget_hint(src).expect("migrate");
4329        assert_eq!(result.changed_count, 1);
4330        assert!(result.output.contains("budget_hint_enabled"));
4331        assert!(
4332            result
4333                .sections_changed
4334                .contains(&"agent.budget_hint_enabled".to_owned())
4335        );
4336    }
4337
4338    #[test]
4339    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4340        let src = "[llm]\nmodel = \"gpt-4o\"\n";
4341        let result = migrate_agent_budget_hint(src).expect("migrate");
4342        assert_eq!(result.changed_count, 0);
4343        assert_eq!(result.output, src);
4344    }
4345
4346    #[test]
4347    fn migrate_agent_budget_hint_already_present_is_noop() {
4348        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4349        let result = migrate_agent_budget_hint(src).expect("migrate");
4350        assert_eq!(result.changed_count, 0);
4351        assert_eq!(result.output, src);
4352    }
4353
4354    #[test]
4355    fn migrate_telemetry_config_empty_config_appends_comment_block() {
4356        let src = "[agent]\nname = \"Zeph\"\n";
4357        let result = migrate_telemetry_config(src).expect("migrate");
4358        assert_eq!(result.changed_count, 1);
4359        assert_eq!(result.sections_changed, vec!["telemetry"]);
4360        assert!(
4361            result.output.contains("# [telemetry]"),
4362            "expected commented-out [telemetry] block in output"
4363        );
4364        assert!(
4365            result.output.contains("enabled = false"),
4366            "expected enabled = false in telemetry comment block"
4367        );
4368    }
4369
4370    #[test]
4371    fn migrate_telemetry_config_existing_section_is_noop() {
4372        let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4373        let result = migrate_telemetry_config(src).expect("migrate");
4374        assert_eq!(result.changed_count, 0);
4375        assert_eq!(result.output, src);
4376    }
4377
4378    #[test]
4379    fn migrate_telemetry_config_existing_comment_is_noop() {
4380        // Idempotency: if the comment block was already added, don't append again.
4381        let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4382        let result = migrate_telemetry_config(src).expect("migrate");
4383        assert_eq!(result.changed_count, 0);
4384        assert_eq!(result.output, src);
4385    }
4386
4387    // ── migrate_otel_filter tests (#2997) ─────────────────────────────────────
4388
4389    #[test]
4390    fn migrate_otel_filter_already_present_is_noop() {
4391        // Real key present — must not modify.
4392        let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4393        let result = migrate_otel_filter(src).expect("migrate");
4394        assert_eq!(result.changed_count, 0);
4395        assert_eq!(result.output, src);
4396    }
4397
4398    #[test]
4399    fn migrate_otel_filter_commented_key_is_noop() {
4400        // Commented-out key already present — idempotent.
4401        let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4402        let result = migrate_otel_filter(src).expect("migrate");
4403        assert_eq!(result.changed_count, 0);
4404        assert_eq!(result.output, src);
4405    }
4406
4407    #[test]
4408    fn migrate_otel_filter_no_telemetry_section_is_noop() {
4409        // [telemetry] absent — must not inject into wrong location.
4410        let src = "[agent]\nname = \"Zeph\"\n";
4411        let result = migrate_otel_filter(src).expect("migrate");
4412        assert_eq!(result.changed_count, 0);
4413        assert_eq!(result.output, src);
4414        assert!(!result.output.contains("otel_filter"));
4415    }
4416
4417    #[test]
4418    fn migrate_otel_filter_injects_within_telemetry_section() {
4419        let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4420        let result = migrate_otel_filter(src).expect("migrate");
4421        assert_eq!(result.changed_count, 1);
4422        assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4423        assert!(
4424            result.output.contains("otel_filter"),
4425            "otel_filter comment must appear"
4426        );
4427        // Comment must appear before [agent] — i.e., within the telemetry section.
4428        let otel_pos = result
4429            .output
4430            .find("otel_filter")
4431            .expect("otel_filter present");
4432        let agent_pos = result.output.find("[agent]").expect("[agent] present");
4433        assert!(
4434            otel_pos < agent_pos,
4435            "otel_filter comment should appear before [agent] section"
4436        );
4437    }
4438
4439    #[test]
4440    fn sandbox_migration_adds_commented_section_when_absent() {
4441        let src = "[agent]\nname = \"Z\"\n";
4442        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4443        assert_eq!(result.changed_count, 1);
4444        assert!(result.output.contains("# [tools.sandbox]"));
4445        assert!(result.output.contains("# profile = \"workspace\""));
4446    }
4447
4448    #[test]
4449    fn sandbox_migration_noop_when_section_present() {
4450        let src = "[tools.sandbox]\nenabled = true\n";
4451        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4452        assert_eq!(result.changed_count, 0);
4453    }
4454
4455    #[test]
4456    fn sandbox_migration_noop_when_dotted_key_present() {
4457        let src = "[tools]\nsandbox = { enabled = true }\n";
4458        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4459        assert_eq!(result.changed_count, 0);
4460    }
4461
4462    #[test]
4463    fn sandbox_migration_false_positive_comment_does_not_block() {
4464        // Comments mentioning tools.sandbox must NOT suppress insertion.
4465        let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
4466        let result = migrate_sandbox_config(src).expect("migrate sandbox");
4467        assert_eq!(result.changed_count, 1);
4468    }
4469
4470    #[test]
4471    fn embedded_default_mentions_tools_sandbox() {
4472        let default_src = include_str!("../../config/default.toml");
4473        assert!(
4474            default_src.contains("tools.sandbox"),
4475            "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
4476        );
4477    }
4478
4479    #[test]
4480    fn sandbox_migration_idempotent_on_own_output() {
4481        let base = "[agent]\nmodel = \"test\"\n";
4482        let first = migrate_sandbox_config(base).unwrap();
4483        assert_eq!(first.changed_count, 1);
4484        let second = migrate_sandbox_config(&first.output).unwrap();
4485        assert_eq!(second.changed_count, 0, "second run must not double-append");
4486        assert_eq!(second.output, first.output);
4487    }
4488
4489    #[test]
4490    fn migrate_agent_budget_hint_idempotent_on_commented_output() {
4491        let base = "[agent]\nname = \"Zeph\"\n";
4492        let first = migrate_agent_budget_hint(base).unwrap();
4493        assert_eq!(first.changed_count, 1);
4494        let second = migrate_agent_budget_hint(&first.output).unwrap();
4495        assert_eq!(second.changed_count, 0, "second run must not double-append");
4496        assert_eq!(second.output, first.output);
4497    }
4498
4499    #[test]
4500    fn migrate_forgetting_config_idempotent_on_commented_output() {
4501        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4502        let first = migrate_forgetting_config(base).unwrap();
4503        assert_eq!(first.changed_count, 1);
4504        let second = migrate_forgetting_config(&first.output).unwrap();
4505        assert_eq!(second.changed_count, 0, "second run must not double-append");
4506        assert_eq!(second.output, first.output);
4507    }
4508
4509    #[test]
4510    fn migrate_microcompact_config_idempotent_on_commented_output() {
4511        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4512        let first = migrate_microcompact_config(base).unwrap();
4513        assert_eq!(first.changed_count, 1);
4514        let second = migrate_microcompact_config(&first.output).unwrap();
4515        assert_eq!(second.changed_count, 0, "second run must not double-append");
4516        assert_eq!(second.output, first.output);
4517    }
4518
4519    #[test]
4520    fn migrate_autodream_config_idempotent_on_commented_output() {
4521        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4522        let first = migrate_autodream_config(base).unwrap();
4523        assert_eq!(first.changed_count, 1);
4524        let second = migrate_autodream_config(&first.output).unwrap();
4525        assert_eq!(second.changed_count, 0, "second run must not double-append");
4526        assert_eq!(second.output, first.output);
4527    }
4528
4529    #[test]
4530    fn migrate_compression_predictor_strips_active_section() {
4531        let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
4532        let result = migrate_compression_predictor_config(base).unwrap();
4533        assert!(!result.output.contains("[memory.compression.predictor]"));
4534        assert!(!result.output.contains("min_samples"));
4535        assert!(result.output.contains("[memory.other]"));
4536        assert_eq!(result.changed_count, 1);
4537    }
4538
4539    #[test]
4540    fn migrate_compression_predictor_strips_commented_section() {
4541        let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
4542        let result = migrate_compression_predictor_config(base).unwrap();
4543        assert!(!result.output.contains("compression.predictor"));
4544        assert!(result.output.contains("[memory.other]"));
4545    }
4546
4547    #[test]
4548    fn migrate_compression_predictor_idempotent() {
4549        let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
4550        let first = migrate_compression_predictor_config(base).unwrap();
4551        let second = migrate_compression_predictor_config(&first.output).unwrap();
4552        assert_eq!(second.output, first.output);
4553        assert_eq!(second.changed_count, 0);
4554    }
4555
4556    #[test]
4557    fn migrate_compression_predictor_noop_when_absent() {
4558        let base = "[memory]\ndb_path = \"test\"\n";
4559        let result = migrate_compression_predictor_config(base).unwrap();
4560        assert_eq!(result.output, base);
4561        assert_eq!(result.changed_count, 0);
4562    }
4563
4564    #[test]
4565    fn migrate_database_url_idempotent_on_commented_output() {
4566        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4567        let first = migrate_database_url(base).unwrap();
4568        assert_eq!(first.changed_count, 1);
4569        let second = migrate_database_url(&first.output).unwrap();
4570        assert_eq!(second.changed_count, 0, "second run must not double-append");
4571        assert_eq!(second.output, first.output);
4572    }
4573
4574    #[test]
4575    fn migrate_shell_transactional_idempotent_on_commented_output() {
4576        let base = "[tools]\n[tools.shell]\nallow_list = []\n";
4577        let first = migrate_shell_transactional(base).unwrap();
4578        assert_eq!(first.changed_count, 1);
4579        let second = migrate_shell_transactional(&first.output).unwrap();
4580        assert_eq!(second.changed_count, 0, "second run must not double-append");
4581        assert_eq!(second.output, first.output);
4582    }
4583
4584    #[test]
4585    fn migrate_otel_filter_idempotent_on_commented_output() {
4586        let base = "[telemetry]\nenabled = true\n";
4587        let first = migrate_otel_filter(base).unwrap();
4588        assert_eq!(first.changed_count, 1);
4589        let second = migrate_otel_filter(&first.output).unwrap();
4590        assert_eq!(second.changed_count, 0, "second run must not double-append");
4591        assert_eq!(second.output, first.output);
4592    }
4593
4594    #[test]
4595    fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4596        let migrator = ConfigMigrator::new();
4597        let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4598        let result = migrator.migrate(src).expect("migrate");
4599        let sec_body_start = result
4600            .output
4601            .find("[security.content_isolation]")
4602            .unwrap_or(0);
4603        let sec_body = &result.output[sec_body_start..];
4604        let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4605        let sec_slice = &sec_body[..next_header];
4606        assert!(
4607            sec_slice.contains("# enabled"),
4608            "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4609        );
4610    }
4611
4612    #[test]
4613    fn config_migrator_idempotent_on_realistic_config() {
4614        let base = r#"
4615[agent]
4616name = "Zeph"
4617
4618[memory]
4619db_path = "~/.zeph/memory.db"
4620soft_compaction_threshold = 0.6
4621
4622[index]
4623max_chunks = 12
4624
4625[tools]
4626[tools.shell]
4627allow_list = []
4628
4629[telemetry]
4630enabled = false
4631
4632[security]
4633[security.content_isolation]
4634enabled = true
4635"#;
4636        let migrator = ConfigMigrator::new();
4637        let first = migrator.migrate(base).expect("first migrate");
4638        let second = migrator.migrate(&first.output).expect("second migrate");
4639        assert_eq!(
4640            second.changed_count, 0,
4641            "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4642            second.changed_count
4643        );
4644        assert_eq!(
4645            first.output, second.output,
4646            "output must be identical on second run"
4647        );
4648        for line in first.output.lines() {
4649            if line.starts_with('[') && !line.starts_with("[[") {
4650                assert!(
4651                    !line.contains('#'),
4652                    "section header must not have inline comment: {line:?}"
4653                );
4654            }
4655        }
4656    }
4657
4658    #[test]
4659    fn migrate_claude_prompt_cache_ttl_1h_survives() {
4660        let src = r#"
4661[llm]
4662provider = "claude"
4663
4664[llm.cloud]
4665model = "claude-sonnet-4-6"
4666prompt_cache_ttl = "1h"
4667"#;
4668        let result = migrate_llm_to_providers(src).expect("migrate");
4669        assert!(
4670            result.output.contains("prompt_cache_ttl = \"1h\""),
4671            "1h TTL must be preserved in migrated output:\n{}",
4672            result.output
4673        );
4674    }
4675
4676    #[test]
4677    fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4678        let src = r#"
4679[llm]
4680provider = "claude"
4681
4682[llm.cloud]
4683model = "claude-sonnet-4-6"
4684prompt_cache_ttl = "ephemeral"
4685"#;
4686        let result = migrate_llm_to_providers(src).expect("migrate");
4687        assert!(
4688            !result.output.contains("prompt_cache_ttl"),
4689            "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4690            result.output
4691        );
4692    }
4693
4694    #[test]
4695    fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4696        let src = r#"
4697[[llm.providers]]
4698type = "claude"
4699model = "claude-sonnet-4-6"
4700prompt_cache_ttl = "1h"
4701"#;
4702        let migrator = ConfigMigrator::new();
4703        let first = migrator.migrate(src).expect("first migrate");
4704        let second = migrator.migrate(&first.output).expect("second migrate");
4705        assert_eq!(
4706            first.output, second.output,
4707            "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4708        );
4709    }
4710
4711    // ── migrate_session_recap_config ──────────────────────────────────────────
4712
4713    #[test]
4714    fn migrate_session_recap_adds_block_when_absent() {
4715        let src = "[agent]\nname = \"Zeph\"\n";
4716        let result = migrate_session_recap_config(src).expect("migrate");
4717        assert_eq!(result.changed_count, 1);
4718        assert!(
4719            result
4720                .sections_changed
4721                .contains(&"session.recap".to_owned())
4722        );
4723        assert!(result.output.contains("# [session.recap]"));
4724        assert!(result.output.contains("on_resume = true"));
4725    }
4726
4727    #[test]
4728    fn migrate_session_recap_idempotent_on_commented_block() {
4729        let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4730        let result = migrate_session_recap_config(src).expect("migrate");
4731        assert_eq!(result.changed_count, 0);
4732        assert_eq!(result.output, src);
4733    }
4734
4735    #[test]
4736    fn migrate_session_recap_idempotent_on_active_section() {
4737        let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4738        let result = migrate_session_recap_config(src).expect("migrate");
4739        assert_eq!(result.changed_count, 0);
4740        assert_eq!(result.output, src);
4741    }
4742
4743    // ── migrate_mcp_elicitation_config ────────────────────────────────────────
4744
4745    #[test]
4746    fn migrate_mcp_elicitation_adds_keys_when_absent() {
4747        let src = "[mcp]\nallowed_commands = []\n";
4748        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4749        assert_eq!(result.changed_count, 1);
4750        assert!(
4751            result
4752                .sections_changed
4753                .contains(&"mcp.elicitation".to_owned())
4754        );
4755        assert!(result.output.contains("# elicitation_enabled = false"));
4756        assert!(result.output.contains("# elicitation_timeout = 120"));
4757    }
4758
4759    #[test]
4760    fn migrate_mcp_elicitation_idempotent_when_key_present() {
4761        let src = "[mcp]\nelicitation_enabled = true\n";
4762        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4763        assert_eq!(result.changed_count, 0);
4764        assert_eq!(result.output, src);
4765    }
4766
4767    #[test]
4768    fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4769        let src = "[agent]\nname = \"Zeph\"\n";
4770        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4771        assert_eq!(result.changed_count, 0);
4772        assert_eq!(result.output, src);
4773    }
4774
4775    #[test]
4776    fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4777        // Edge case: `[mcp]` at EOF with no `\n` — replacen would be a no-op.
4778        let src = "[mcp]";
4779        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4780        assert_eq!(result.changed_count, 0);
4781        assert_eq!(result.output, src);
4782    }
4783
4784    // ── migrate_quality_config ────────────────────────────────────────────────
4785
4786    #[test]
4787    fn migrate_quality_adds_block_when_absent() {
4788        let src = "[agent]\nname = \"Zeph\"\n";
4789        let result = migrate_quality_config(src).expect("migrate");
4790        assert_eq!(result.changed_count, 1);
4791        assert!(result.sections_changed.contains(&"quality".to_owned()));
4792        assert!(result.output.contains("# [quality]"));
4793        assert!(result.output.contains("self_check = false"));
4794        assert!(result.output.contains("trigger = \"has_retrieval\""));
4795    }
4796
4797    #[test]
4798    fn migrate_quality_idempotent_on_commented_block() {
4799        let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4800        let result = migrate_quality_config(src).expect("migrate");
4801        assert_eq!(result.changed_count, 0);
4802        assert_eq!(result.output, src);
4803    }
4804
4805    #[test]
4806    fn migrate_quality_idempotent_on_active_section() {
4807        let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4808        let result = migrate_quality_config(src).expect("migrate");
4809        assert_eq!(result.changed_count, 0);
4810        assert_eq!(result.output, src);
4811    }
4812
4813    // ── migrate_acp_subagents_config ─────────────────────────────────────────
4814
4815    #[test]
4816    fn migrate_acp_subagents_adds_block_when_absent() {
4817        let src = "[agent]\nname = \"Zeph\"\n";
4818        let result = migrate_acp_subagents_config(src).expect("migrate");
4819        assert_eq!(result.changed_count, 1);
4820        assert!(
4821            result
4822                .sections_changed
4823                .contains(&"acp.subagents".to_owned())
4824        );
4825        assert!(result.output.contains("# [acp.subagents]"));
4826        assert!(result.output.contains("enabled = false"));
4827    }
4828
4829    #[test]
4830    fn migrate_acp_subagents_idempotent_on_existing_block() {
4831        let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4832        let result = migrate_acp_subagents_config(src).expect("migrate");
4833        assert_eq!(result.changed_count, 0);
4834        assert_eq!(result.output, src);
4835    }
4836
4837    // ── migrate_hooks_permission_denied_config ────────────────────────────────
4838
4839    #[test]
4840    fn migrate_hooks_permission_denied_adds_block_when_absent() {
4841        let src = "[agent]\nname = \"Zeph\"\n";
4842        let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4843        assert_eq!(result.changed_count, 1);
4844        assert!(
4845            result
4846                .sections_changed
4847                .contains(&"hooks.permission_denied".to_owned())
4848        );
4849        assert!(result.output.contains("# [[hooks.permission_denied]]"));
4850        assert!(result.output.contains("ZEPH_TOOL"));
4851    }
4852
4853    #[test]
4854    fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4855        let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4856        let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4857        assert_eq!(result.changed_count, 0);
4858        assert_eq!(result.output, src);
4859    }
4860
4861    // ── migrate_memory_graph_config ───────────────────────────────────────────
4862
4863    #[test]
4864    fn migrate_memory_graph_adds_block_when_absent() {
4865        let src = "[agent]\nname = \"Zeph\"\n";
4866        let result = migrate_memory_graph_config(src).expect("migrate");
4867        assert_eq!(result.changed_count, 1);
4868        assert!(
4869            result
4870                .sections_changed
4871                .contains(&"memory.graph.retrieval".to_owned())
4872        );
4873        assert!(result.output.contains("retrieval_strategy"));
4874        assert!(result.output.contains("# [memory.graph.beam_search]"));
4875    }
4876
4877    #[test]
4878    fn migrate_memory_graph_idempotent_on_existing_block() {
4879        let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4880        let result = migrate_memory_graph_config(src).expect("migrate");
4881        assert_eq!(result.changed_count, 0);
4882        assert_eq!(result.output, src);
4883    }
4884
4885    // ── migrate_scheduler_daemon_config ──────────────────────────────────────
4886
4887    #[test]
4888    fn migrate_scheduler_daemon_adds_block_when_absent() {
4889        let src = "[agent]\nname = \"Zeph\"\n";
4890        let result = migrate_scheduler_daemon_config(src).expect("migrate");
4891        assert_eq!(result.changed_count, 1);
4892        assert!(
4893            result
4894                .sections_changed
4895                .contains(&"scheduler.daemon".to_owned())
4896        );
4897        assert!(result.output.contains("# [scheduler.daemon]"));
4898        assert!(result.output.contains("pid_file"));
4899        assert!(result.output.contains("tick_secs = 60"));
4900        assert!(result.output.contains("shutdown_grace_secs = 30"));
4901        assert!(result.output.contains("catch_up = true"));
4902    }
4903
4904    #[test]
4905    fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4906        let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4907        let result = migrate_scheduler_daemon_config(src).expect("migrate");
4908        assert_eq!(result.changed_count, 0);
4909        assert_eq!(result.output, src);
4910    }
4911
4912    // ── migrate_memory_retrieval_config ──────────────────────────────────────
4913
4914    #[test]
4915    fn migrate_memory_retrieval_adds_block_when_absent() {
4916        let src = "[agent]\nname = \"Zeph\"\n";
4917        let result = migrate_memory_retrieval_config(src).expect("migrate");
4918        assert_eq!(result.changed_count, 1);
4919        assert!(
4920            result
4921                .sections_changed
4922                .contains(&"memory.retrieval".to_owned())
4923        );
4924        assert!(result.output.contains("# [memory.retrieval]"));
4925        assert!(result.output.contains("depth = 0"));
4926        assert!(result.output.contains("context_format"));
4927    }
4928
4929    #[test]
4930    fn migrate_memory_retrieval_idempotent_on_active_section() {
4931        let src = "[memory.retrieval]\ndepth = 40\n";
4932        let result = migrate_memory_retrieval_config(src).expect("migrate");
4933        assert_eq!(result.changed_count, 0);
4934        assert_eq!(result.output, src);
4935    }
4936
4937    #[test]
4938    fn migrate_memory_retrieval_idempotent_on_commented_section() {
4939        let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
4940        let result = migrate_memory_retrieval_config(src).expect("migrate");
4941        assert_eq!(result.changed_count, 0);
4942        assert_eq!(result.output, src);
4943    }
4944
4945    // ── acp PR4 migration ─────────────────────────────────────────────────────
4946
4947    #[test]
4948    fn migrate_adds_pr4_acp_keys_commented() {
4949        let migrator = ConfigMigrator::new();
4950        let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
4951        let out = migrator.migrate(input).expect("migrate");
4952        assert!(
4953            out.output.contains("# additional_directories = []"),
4954            "expected commented additional_directories; got:\n{}",
4955            out.output
4956        );
4957        assert!(
4958            out.output.contains("# auth_methods = [\"agent\"]"),
4959            "expected commented auth_methods; got:\n{}",
4960            out.output
4961        );
4962        assert!(
4963            out.output.contains("# message_ids_enabled = true"),
4964            "expected commented message_ids_enabled; got:\n{}",
4965            out.output
4966        );
4967    }
4968
4969    // ── migrate_memory_reasoning_config ──────────────────────────────────────
4970
4971    #[test]
4972    fn migrate_memory_reasoning_adds_block_when_absent() {
4973        let input = "[agent]\nmodel = \"gpt-4o\"\n";
4974        let result = migrate_memory_reasoning_config(input).unwrap();
4975        assert_eq!(result.changed_count, 1);
4976        assert!(
4977            result
4978                .sections_changed
4979                .contains(&"memory.reasoning".to_owned())
4980        );
4981        assert!(result.output.contains("# [memory.reasoning]"));
4982        assert!(result.output.contains("extraction_timeout_secs = 30"));
4983        assert!(result.output.contains("max_message_chars = 2000"));
4984    }
4985
4986    #[test]
4987    fn migrate_memory_reasoning_idempotent_on_existing_block() {
4988        let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
4989        let result = migrate_memory_reasoning_config(input).unwrap();
4990        assert_eq!(result.changed_count, 0);
4991        assert!(result.sections_changed.is_empty());
4992        assert_eq!(result.output, input);
4993    }
4994
4995    // ── migrate_hooks_turn_complete_config ────────────────────────────────────
4996
4997    #[test]
4998    fn migrate_hooks_turn_complete_adds_block_when_absent() {
4999        let input = "[agent]\nmodel = \"gpt-4o\"\n";
5000        let result = migrate_hooks_turn_complete_config(input).unwrap();
5001        assert_eq!(result.changed_count, 1);
5002        assert!(
5003            result
5004                .sections_changed
5005                .contains(&"hooks.turn_complete".to_owned())
5006        );
5007        assert!(result.output.contains("# [[hooks.turn_complete]]"));
5008        assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
5009        assert!(result.output.contains("timeout_secs = 3"));
5010    }
5011
5012    #[test]
5013    fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
5014        let input =
5015            "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
5016        let result = migrate_hooks_turn_complete_config(input).unwrap();
5017        assert_eq!(result.changed_count, 0);
5018        assert!(result.sections_changed.is_empty());
5019        assert_eq!(result.output, input);
5020    }
5021
5022    // ── migrate_focus_auto_consolidate_min_window ──────────────────────────────
5023
5024    /// S5: the comment must land inside [agent.focus], not after a subsequent section.
5025    #[test]
5026    fn migrate_focus_auto_consolidate_injects_inside_section() {
5027        let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
5028        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5029        assert_eq!(result.changed_count, 1);
5030        let comment_pos = result
5031            .output
5032            .find("auto_consolidate_min_window")
5033            .expect("comment must be present");
5034        let other_pos = result
5035            .output
5036            .find("[other]")
5037            .expect("[other] must be present");
5038        assert!(
5039            comment_pos < other_pos,
5040            "auto_consolidate_min_window comment must appear before [other] section"
5041        );
5042    }
5043
5044    #[test]
5045    fn migrate_focus_auto_consolidate_idempotent() {
5046        let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
5047        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5048        assert_eq!(result.changed_count, 0);
5049        assert_eq!(result.output, input);
5050    }
5051
5052    #[test]
5053    fn migrate_focus_auto_consolidate_noop_when_section_absent() {
5054        let input = "[agent]\nname = \"zeph\"\n";
5055        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5056        assert_eq!(result.changed_count, 0);
5057        assert_eq!(result.output, input);
5058    }
5059
5060    #[test]
5061    fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
5062        let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
5063        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5064        assert_eq!(result.changed_count, 0);
5065        assert_eq!(result.output, input);
5066    }
5067
5068    // ── Migration registry ────────────────────────────────────────────────────
5069
5070    #[test]
5071    fn registry_has_forty_five_entries() {
5072        assert_eq!(MIGRATIONS.len(), 45);
5073    }
5074
5075    #[test]
5076    fn registry_names_are_unique_and_non_empty() {
5077        let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5078        for name in &names {
5079            assert!(!name.is_empty(), "migration name must not be empty");
5080        }
5081        let mut deduped = names.clone();
5082        deduped.sort_unstable();
5083        deduped.dedup();
5084        assert_eq!(deduped.len(), names.len(), "migration names must be unique");
5085    }
5086
5087    #[test]
5088    fn registry_is_idempotent_on_empty_input() {
5089        // Migrations that append comment blocks cannot be idempotent by design:
5090        // comment text is not parsed as TOML keys, so presence checks always fail.
5091        const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
5092
5093        let mut toml = String::new();
5094        for m in MIGRATIONS.iter() {
5095            let result = m.apply(&toml).expect("registry migration must not fail");
5096            toml = result.output;
5097        }
5098        for m in MIGRATIONS.iter() {
5099            if COMMENT_ONLY.contains(&m.name()) {
5100                continue;
5101            }
5102            let result = m
5103                .apply(&toml)
5104                .expect("registry migration must not fail on second pass");
5105            assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
5106        }
5107    }
5108
5109    #[test]
5110    fn registry_preserves_order_matches_dispatch() {
5111        // Names must follow the documented step order (steps 1–45).
5112        let expected = [
5113            "migrate_stt_to_provider",
5114            "migrate_planner_model_to_provider",
5115            "migrate_mcp_trust_levels",
5116            "migrate_agent_retry_to_tools_retry",
5117            "migrate_database_url",
5118            "migrate_shell_transactional",
5119            "migrate_agent_budget_hint",
5120            "migrate_forgetting_config",
5121            "migrate_compression_predictor_config",
5122            "migrate_microcompact_config",
5123            "migrate_autodream_config",
5124            "migrate_magic_docs_config",
5125            "migrate_telemetry_config",
5126            "migrate_supervisor_config",
5127            "migrate_otel_filter",
5128            "migrate_egress_config",
5129            "migrate_vigil_config",
5130            "migrate_sandbox_config",
5131            "migrate_sandbox_egress_filter",
5132            "migrate_orchestration_persistence",
5133            "migrate_session_recap_config",
5134            "migrate_mcp_elicitation_config",
5135            "migrate_quality_config",
5136            "migrate_acp_subagents_config",
5137            "migrate_hooks_permission_denied_config",
5138            "migrate_memory_graph_config",
5139            "migrate_scheduler_daemon_config",
5140            "migrate_memory_retrieval_config",
5141            "migrate_memory_reasoning_config",
5142            "migrate_memory_reasoning_judge_config",
5143            "migrate_memory_hebbian_config",
5144            "migrate_memory_hebbian_consolidation_config",
5145            "migrate_memory_hebbian_spread_config",
5146            "migrate_hooks_turn_complete_config",
5147            "migrate_focus_auto_consolidate_min_window",
5148            "migrate_session_provider_persistence",
5149            "migrate_memory_retrieval_query_bias",
5150            "migrate_memory_persona_config",
5151            "migrate_qdrant_api_key",
5152            "migrate_mcp_max_connect_attempts",
5153            "migrate_goals_config",
5154            "migrate_tools_compression_config",
5155            "migrate_orchestrator_provider",
5156            "migrate_provider_max_concurrent",
5157            "migrate_gonkagate_to_gonka",
5158        ];
5159        let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5160        assert_eq!(actual, expected);
5161    }
5162
5163    // ── migrate_qdrant_api_key tests (#3543) ─────────────────────────────────
5164
5165    #[test]
5166    fn migrate_qdrant_api_key_adds_comment_when_absent() {
5167        let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5168        let result = migrate_qdrant_api_key(src).expect("migrate");
5169        assert_eq!(result.changed_count, 1);
5170        assert!(
5171            result
5172                .sections_changed
5173                .contains(&"memory.qdrant_api_key".to_owned())
5174        );
5175        assert!(result.output.contains("# qdrant_api_key = \"\""));
5176    }
5177
5178    #[test]
5179    fn migrate_qdrant_api_key_is_noop_when_present() {
5180        let src =
5181            "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
5182        let result = migrate_qdrant_api_key(src).expect("migrate");
5183        assert_eq!(result.changed_count, 0);
5184        assert!(result.sections_changed.is_empty());
5185        assert_eq!(result.output, src);
5186    }
5187
5188    #[test]
5189    fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
5190        let src = "[agent]\nname = \"Zeph\"\n";
5191        let result = migrate_qdrant_api_key(src).expect("migrate");
5192        assert_eq!(result.changed_count, 1);
5193        assert!(result.output.contains("# qdrant_api_key = \"\""));
5194    }
5195
5196    #[test]
5197    fn migrate_qdrant_api_key_idempotent_on_commented_output() {
5198        let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5199        let first = migrate_qdrant_api_key(base).unwrap();
5200        assert_eq!(first.changed_count, 1);
5201        let second = migrate_qdrant_api_key(&first.output).unwrap();
5202        assert_eq!(second.changed_count, 0, "second run must not double-append");
5203        assert_eq!(second.output, first.output);
5204    }
5205
5206    #[test]
5207    fn migrate_mcp_max_connect_attempts_adds_comment_when_absent() {
5208        let src = "[mcp]\nallowed_commands = []\n";
5209        let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5210        assert_eq!(result.changed_count, 1);
5211        assert!(
5212            result.output.contains("max_connect_attempts"),
5213            "output must mention max_connect_attempts"
5214        );
5215    }
5216
5217    #[test]
5218    fn migrate_mcp_max_connect_attempts_idempotent_when_present() {
5219        let src = "[mcp]\n# max_connect_attempts = 3\nallowed_commands = []\n";
5220        let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5221        assert_eq!(
5222            result.changed_count, 0,
5223            "must not modify already-present key"
5224        );
5225        assert_eq!(result.output, src);
5226    }
5227
5228    #[test]
5229    fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() {
5230        let src = "[agent]\nname = \"Zeph\"\n";
5231        let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5232        assert_eq!(result.changed_count, 0);
5233        assert_eq!(result.output, src);
5234    }
5235
5236    // ── Step 43 — orchestrator_provider ──────────────────────────────────────────────────────────
5237
5238    #[test]
5239    fn step43_adds_orchestrator_provider_comment_when_absent() {
5240        let src = "[orchestration]\nenabled = true\n";
5241        let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5242        assert_eq!(result.changed_count, 1);
5243        assert!(
5244            result.output.contains("orchestrator_provider"),
5245            "migration must inject orchestrator_provider hint"
5246        );
5247    }
5248
5249    #[test]
5250    fn step43_noop_when_orchestrator_provider_already_present() {
5251        let src = "[orchestration]\nenabled = true\norchestrator_provider = \"\"\n";
5252        let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5253        assert_eq!(
5254            result.changed_count, 0,
5255            "must not modify already-present key"
5256        );
5257        assert_eq!(result.output, src);
5258    }
5259
5260    // ── Step 44 — max_concurrent per-provider ────────────────────────────────────────────────────
5261
5262    #[test]
5263    fn step44_adds_max_concurrent_comment_when_providers_present() {
5264        let src = "[[llm.providers]]\nname = \"quality\"\ntype = \"openai\"\n";
5265        let result = migrate_provider_max_concurrent(src).expect("migrate");
5266        assert_eq!(result.changed_count, 1);
5267        assert!(
5268            result.output.contains("max_concurrent"),
5269            "migration must inject max_concurrent hint"
5270        );
5271    }
5272
5273    #[test]
5274    fn step44_noop_when_max_concurrent_already_present() {
5275        let src = "[[llm.providers]]\nname = \"quality\"\nmax_concurrent = 4\n";
5276        let result = migrate_provider_max_concurrent(src).expect("migrate");
5277        assert_eq!(
5278            result.changed_count, 0,
5279            "must not modify already-present key"
5280        );
5281        assert_eq!(result.output, src);
5282    }
5283
5284    #[test]
5285    fn step44_noop_when_no_providers_section() {
5286        let src = "[agent]\nname = \"Zeph\"\n";
5287        let result = migrate_provider_max_concurrent(src).expect("migrate");
5288        assert_eq!(result.changed_count, 0);
5289        assert_eq!(result.output, src);
5290    }
5291
5292    // ── Step 45 — migrate_gonkagate_to_gonka ─────────────────────────────────
5293
5294    #[test]
5295    fn step45_adds_advisory_comment_when_gonkagate_present() {
5296        let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5297        let result = migrate_gonkagate_to_gonka(src);
5298        assert!(result.changed_count > 0, "must detect gonkagate entry");
5299        assert!(
5300            result.output.contains("[migration] GonkaGate detected"),
5301            "advisory comment must be added"
5302        );
5303        // Comment must appear before the [[llm.providers]] table header, not inside it.
5304        let comment_pos = result
5305            .output
5306            .find("[migration] GonkaGate detected")
5307            .unwrap();
5308        let header_pos = result.output.find("[[llm.providers]]").unwrap();
5309        assert!(
5310            comment_pos < header_pos,
5311            "advisory comment must precede the [[llm.providers]] header"
5312        );
5313    }
5314
5315    #[test]
5316    fn step45_noop_when_no_gonkagate() {
5317        let src = "[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n";
5318        let result = migrate_gonkagate_to_gonka(src);
5319        assert_eq!(result.changed_count, 0);
5320        assert_eq!(result.output, src);
5321    }
5322
5323    #[test]
5324    fn step45_does_not_double_insert_comment() {
5325        let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5326        let first = migrate_gonkagate_to_gonka(src);
5327        let second = migrate_gonkagate_to_gonka(&first.output);
5328        // Second run must not add another comment line.
5329        assert_eq!(second.changed_count, 0, "idempotent on second run");
5330    }
5331}