Skip to main content

zeph_config/
migrate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Config migration: add missing parameters from the canonical reference as commented-out entries.
5//!
6//! The canonical reference is the checked-in `config/default.toml` file embedded at compile time.
7//! Missing sections and keys are added as `# key = default_value` comments so users can discover
8//! and enable them without hunting through documentation.
9
10use toml_edit::{Array, DocumentMut, Item, Table, Value};
11
12/// Canonical section ordering for top-level keys in the output document.
13static CANONICAL_ORDER: &[&str] = &[
14    "agent",
15    "llm",
16    "skills",
17    "memory",
18    "index",
19    "tools",
20    "mcp",
21    "telegram",
22    "discord",
23    "slack",
24    "a2a",
25    "acp",
26    "gateway",
27    "metrics",
28    "daemon",
29    "scheduler",
30    "orchestration",
31    "classifiers",
32    "security",
33    "vault",
34    "timeouts",
35    "cost",
36    "debug",
37    "logging",
38    "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/// Migrate an old `[llm.stt]` section (with `model` / `base_url` fields) to the new format
1088/// where those fields live on a `[[llm.providers]]` entry via `stt_model`.
1089///
1090/// Transformations:
1091/// - `[llm.stt].model` → `stt_model` on the matching or new `[[llm.providers]]` entry
1092/// - `[llm.stt].base_url` → `base_url` on that entry (skipped when already present)
1093/// - `[llm.stt].provider` is updated to the provider name; the entry is assigned an explicit
1094///   `name` when it lacked one (W2 guard).
1095/// - Old `model` and `base_url` keys are stripped from `[llm.stt]`.
1096///
1097/// If `[llm.stt]` is absent or already uses the new format (no `model` / `base_url`), the
1098/// input is returned unchanged.
1099///
1100/// # Errors
1101///
1102/// Returns `MigrateError::Parse` if the input TOML is invalid.
1103/// Returns `MigrateError::InvalidStructure` if `[llm.stt].model` is present but the `[llm]`
1104/// key is absent or not a table, making mutation impossible.
1105#[allow(clippy::too_many_lines)]
1106pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1107    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1108
1109    // Extract fields from [llm.stt] if present.
1110    let stt_model = doc
1111        .get("llm")
1112        .and_then(toml_edit::Item::as_table)
1113        .and_then(|llm| llm.get("stt"))
1114        .and_then(toml_edit::Item::as_table)
1115        .and_then(|stt| stt.get("model"))
1116        .and_then(toml_edit::Item::as_str)
1117        .map(ToOwned::to_owned);
1118
1119    let stt_base_url = doc
1120        .get("llm")
1121        .and_then(toml_edit::Item::as_table)
1122        .and_then(|llm| llm.get("stt"))
1123        .and_then(toml_edit::Item::as_table)
1124        .and_then(|stt| stt.get("base_url"))
1125        .and_then(toml_edit::Item::as_str)
1126        .map(ToOwned::to_owned);
1127
1128    let stt_provider_hint = doc
1129        .get("llm")
1130        .and_then(toml_edit::Item::as_table)
1131        .and_then(|llm| llm.get("stt"))
1132        .and_then(toml_edit::Item::as_table)
1133        .and_then(|stt| stt.get("provider"))
1134        .and_then(toml_edit::Item::as_str)
1135        .map(ToOwned::to_owned)
1136        .unwrap_or_default();
1137
1138    // Nothing to migrate if [llm.stt] does not exist or already lacks the old fields.
1139    if stt_model.is_none() && stt_base_url.is_none() {
1140        return Ok(MigrationResult {
1141            output: toml_src.to_owned(),
1142            changed_count: 0,
1143            sections_changed: Vec::new(),
1144        });
1145    }
1146
1147    let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1148
1149    // Determine the target provider type based on provider hint.
1150    let target_type = match stt_provider_hint.as_str() {
1151        "candle-whisper" | "candle" => "candle",
1152        _ => "openai",
1153    };
1154
1155    // Find or create a [[llm.providers]] entry to attach stt_model to.
1156    // Priority: entry whose effective name matches the hint, else first entry of matching type.
1157    let providers = doc
1158        .get("llm")
1159        .and_then(toml_edit::Item::as_table)
1160        .and_then(|llm| llm.get("providers"))
1161        .and_then(toml_edit::Item::as_array_of_tables);
1162
1163    let matching_idx = providers.and_then(|arr| {
1164        arr.iter().enumerate().find_map(|(i, t)| {
1165            let name = t
1166                .get("name")
1167                .and_then(toml_edit::Item::as_str)
1168                .unwrap_or("");
1169            let ptype = t
1170                .get("type")
1171                .and_then(toml_edit::Item::as_str)
1172                .unwrap_or("");
1173            // Match by explicit name hint or by type when hint is a legacy backend string.
1174            let name_match = !stt_provider_hint.is_empty()
1175                && (name == stt_provider_hint || ptype == stt_provider_hint);
1176            let type_match = ptype == target_type;
1177            if name_match || type_match {
1178                Some(i)
1179            } else {
1180                None
1181            }
1182        })
1183    });
1184
1185    // Determine the final provider name to write into [llm.stt].provider.
1186    let resolved_provider_name: String;
1187
1188    if let Some(idx) = matching_idx {
1189        // Attach stt_model to the existing entry.
1190        let llm_mut = doc
1191            .get_mut("llm")
1192            .and_then(toml_edit::Item::as_table_mut)
1193            .ok_or(MigrateError::InvalidStructure(
1194                "[llm] table not accessible for mutation",
1195            ))?;
1196        let providers_mut = llm_mut
1197            .get_mut("providers")
1198            .and_then(toml_edit::Item::as_array_of_tables_mut)
1199            .ok_or(MigrateError::InvalidStructure(
1200                "[[llm.providers]] array not accessible for mutation",
1201            ))?;
1202        let entry = providers_mut
1203            .iter_mut()
1204            .nth(idx)
1205            .ok_or(MigrateError::InvalidStructure(
1206                "[[llm.providers]] entry index out of range during mutation",
1207            ))?;
1208
1209        // W2: ensure explicit name.
1210        let existing_name = entry
1211            .get("name")
1212            .and_then(toml_edit::Item::as_str)
1213            .map(ToOwned::to_owned);
1214        let entry_name = existing_name.unwrap_or_else(|| {
1215            let t = entry
1216                .get("type")
1217                .and_then(toml_edit::Item::as_str)
1218                .unwrap_or("openai");
1219            format!("{t}-stt")
1220        });
1221        entry.insert("name", toml_edit::value(entry_name.clone()));
1222        entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1223        if stt_base_url.is_some() && entry.get("base_url").is_none() {
1224            entry.insert(
1225                "base_url",
1226                toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1227            );
1228        }
1229        resolved_provider_name = entry_name;
1230    } else {
1231        // No matching entry — append a new [[llm.providers]] block.
1232        let new_name = if target_type == "candle" {
1233            "local-whisper".to_owned()
1234        } else {
1235            "openai-stt".to_owned()
1236        };
1237        let mut new_entry = toml_edit::Table::new();
1238        new_entry.insert("name", toml_edit::value(new_name.clone()));
1239        new_entry.insert("type", toml_edit::value(target_type));
1240        new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1241        if let Some(ref url) = stt_base_url {
1242            new_entry.insert("base_url", toml_edit::value(url.clone()));
1243        }
1244        // Ensure [[llm.providers]] array exists.
1245        let llm_mut = doc
1246            .get_mut("llm")
1247            .and_then(toml_edit::Item::as_table_mut)
1248            .ok_or(MigrateError::InvalidStructure(
1249                "[llm] table not accessible for mutation",
1250            ))?;
1251        if let Some(item) = llm_mut.get_mut("providers") {
1252            if let Some(arr) = item.as_array_of_tables_mut() {
1253                arr.push(new_entry);
1254            }
1255        } else {
1256            let mut arr = toml_edit::ArrayOfTables::new();
1257            arr.push(new_entry);
1258            llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1259        }
1260        resolved_provider_name = new_name;
1261    }
1262
1263    // Update [llm.stt]: set provider name, remove old fields.
1264    if let Some(stt_table) = doc
1265        .get_mut("llm")
1266        .and_then(toml_edit::Item::as_table_mut)
1267        .and_then(|llm| llm.get_mut("stt"))
1268        .and_then(toml_edit::Item::as_table_mut)
1269    {
1270        stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1271        stt_table.remove("model");
1272        stt_table.remove("base_url");
1273    }
1274
1275    Ok(MigrationResult {
1276        output: doc.to_string(),
1277        changed_count: 1,
1278        sections_changed: vec!["llm.providers.stt_model".to_owned()],
1279    })
1280}
1281
1282/// Migrate `[orchestration] planner_model` to `planner_provider`.
1283///
1284/// The namespaces differ: `planner_model` held a raw model name (e.g. `"gpt-4o"`),
1285/// while `planner_provider` must reference a `[[llm.providers]]` `name` field. A migrated
1286/// value would cause a silent `warn!` from `build_planner_provider()` when resolution fails,
1287/// so the old value is commented out and a warning is emitted.
1288///
1289/// If `planner_model` is absent, the input is returned unchanged.
1290///
1291/// # Errors
1292///
1293/// Returns `MigrateError::Parse` if the input TOML is invalid.
1294pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1295    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1296
1297    let old_value = doc
1298        .get("orchestration")
1299        .and_then(toml_edit::Item::as_table)
1300        .and_then(|t| t.get("planner_model"))
1301        .and_then(toml_edit::Item::as_value)
1302        .and_then(toml_edit::Value::as_str)
1303        .map(ToOwned::to_owned);
1304
1305    let Some(old_model) = old_value else {
1306        return Ok(MigrationResult {
1307            output: toml_src.to_owned(),
1308            changed_count: 0,
1309            sections_changed: Vec::new(),
1310        });
1311    };
1312
1313    // Remove the old key via text substitution to preserve surrounding comments/formatting.
1314    // We rebuild the section comment in the output rather than using toml_edit mutations,
1315    // following the same line-oriented approach used elsewhere in this file.
1316    let commented_out = format!(
1317        "# planner_provider = \"{old_model}\"  \
1318         # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1319    );
1320
1321    let orch_table = doc
1322        .get_mut("orchestration")
1323        .and_then(toml_edit::Item::as_table_mut)
1324        .ok_or(MigrateError::InvalidStructure(
1325            "[orchestration] is not a table",
1326        ))?;
1327    orch_table.remove("planner_model");
1328    let decor = orch_table.decor_mut();
1329    let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1330    // Append the commented-out entry as a trailing comment on the section.
1331    let new_suffix = if existing_suffix.trim().is_empty() {
1332        format!("\n{commented_out}\n")
1333    } else {
1334        format!("{existing_suffix}\n{commented_out}\n")
1335    };
1336    decor.set_suffix(new_suffix);
1337
1338    eprintln!(
1339        "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1340         and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1341         `name` field, not a raw model name. Update or remove the commented line."
1342    );
1343
1344    Ok(MigrationResult {
1345        output: doc.to_string(),
1346        changed_count: 1,
1347        sections_changed: vec!["orchestration.planner_provider".to_owned()],
1348    })
1349}
1350
1351/// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry
1352/// that lacks an explicit `trust_level`.
1353///
1354/// Before this PR all config-defined servers skipped SSRF validation (equivalent to
1355/// `trust_level = "trusted"`). Without migration, upgrading to the new default
1356/// (`Untrusted`) would silently break remote servers on private networks.
1357///
1358/// This function adds `trust_level = "trusted"` only to entries that are missing the
1359/// field, preserving entries that already have it set.
1360///
1361/// # Errors
1362///
1363/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1364pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1365    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1366    let mut added = 0usize;
1367
1368    let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1369        return Ok(MigrationResult {
1370            output: toml_src.to_owned(),
1371            changed_count: 0,
1372            sections_changed: Vec::new(),
1373        });
1374    };
1375
1376    let Some(servers) = mcp
1377        .get_mut("servers")
1378        .and_then(toml_edit::Item::as_array_of_tables_mut)
1379    else {
1380        return Ok(MigrationResult {
1381            output: toml_src.to_owned(),
1382            changed_count: 0,
1383            sections_changed: Vec::new(),
1384        });
1385    };
1386
1387    for entry in servers.iter_mut() {
1388        if !entry.contains_key("trust_level") {
1389            entry.insert(
1390                "trust_level",
1391                toml_edit::value(toml_edit::Value::from("trusted")),
1392            );
1393            added += 1;
1394        }
1395    }
1396
1397    if added > 0 {
1398        eprintln!(
1399            "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1400             entr{} (preserving previous SSRF-skip behavior). \
1401             Review and adjust trust levels as needed.",
1402            if added == 1 { "y" } else { "ies" }
1403        );
1404    }
1405
1406    Ok(MigrationResult {
1407        output: doc.to_string(),
1408        changed_count: added,
1409        sections_changed: if added > 0 {
1410            vec!["mcp.servers.trust_level".to_owned()]
1411        } else {
1412            Vec::new()
1413        },
1414    })
1415}
1416
1417/// Migrate `[agent].max_tool_retries` → `[tools.retry].max_attempts` and
1418/// `[agent].max_retry_duration_secs` → `[tools.retry].budget_secs`.
1419///
1420/// Old fields are preserved (not removed) to avoid breaking configs that rely on them
1421/// until they are officially deprecated in a future release. The new `[tools.retry]` section
1422/// is added if missing, populated with the migrated values.
1423///
1424/// # Errors
1425///
1426/// Returns `MigrateError::Parse` if the TOML is invalid.
1427pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1428    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1429
1430    let max_retries = doc
1431        .get("agent")
1432        .and_then(toml_edit::Item::as_table)
1433        .and_then(|t| t.get("max_tool_retries"))
1434        .and_then(toml_edit::Item::as_value)
1435        .and_then(toml_edit::Value::as_integer)
1436        .map(i64::cast_unsigned);
1437
1438    let budget_secs = doc
1439        .get("agent")
1440        .and_then(toml_edit::Item::as_table)
1441        .and_then(|t| t.get("max_retry_duration_secs"))
1442        .and_then(toml_edit::Item::as_value)
1443        .and_then(toml_edit::Value::as_integer)
1444        .map(i64::cast_unsigned);
1445
1446    if max_retries.is_none() && budget_secs.is_none() {
1447        return Ok(MigrationResult {
1448            output: toml_src.to_owned(),
1449            changed_count: 0,
1450            sections_changed: Vec::new(),
1451        });
1452    }
1453
1454    // Ensure [tools.retry] section exists.
1455    if !doc.contains_key("tools") {
1456        doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1457    }
1458    let tools_table = doc
1459        .get_mut("tools")
1460        .and_then(toml_edit::Item::as_table_mut)
1461        .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1462
1463    if !tools_table.contains_key("retry") {
1464        tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1465    }
1466    let retry_table = tools_table
1467        .get_mut("retry")
1468        .and_then(toml_edit::Item::as_table_mut)
1469        .ok_or(MigrateError::InvalidStructure(
1470            "[tools.retry] is not a table",
1471        ))?;
1472
1473    let mut changed_count = 0usize;
1474
1475    if let Some(retries) = max_retries
1476        && !retry_table.contains_key("max_attempts")
1477    {
1478        retry_table.insert(
1479            "max_attempts",
1480            toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1481        );
1482        changed_count += 1;
1483    }
1484
1485    if let Some(secs) = budget_secs
1486        && !retry_table.contains_key("budget_secs")
1487    {
1488        retry_table.insert(
1489            "budget_secs",
1490            toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1491        );
1492        changed_count += 1;
1493    }
1494
1495    if changed_count > 0 {
1496        eprintln!(
1497            "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1498             [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1499        );
1500    }
1501
1502    Ok(MigrationResult {
1503        output: doc.to_string(),
1504        changed_count,
1505        sections_changed: if changed_count > 0 {
1506            vec!["tools.retry".to_owned()]
1507        } else {
1508            Vec::new()
1509        },
1510    })
1511}
1512
1513/// Add a commented-out `database_url = ""` entry under `[memory]` if absent.
1514///
1515/// If the `[memory]` section does not exist it is created. This migration surfaces the
1516/// `PostgreSQL` URL option for users upgrading from a pre-postgres config file.
1517///
1518/// # Errors
1519///
1520/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1521pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1522    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1523    if toml_src.contains("database_url") {
1524        return Ok(MigrationResult {
1525            output: toml_src.to_owned(),
1526            changed_count: 0,
1527            sections_changed: Vec::new(),
1528        });
1529    }
1530
1531    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1532
1533    // Ensure [memory] section exists (created if absent so the comment has context).
1534    if !doc.contains_key("memory") {
1535        doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1536    }
1537
1538    let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1539         # Leave empty and store the actual URL in the vault:\n\
1540         #   zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1541         # database_url = \"\"\n";
1542    let raw = doc.to_string();
1543    let output = format!("{raw}{comment}");
1544
1545    Ok(MigrationResult {
1546        output,
1547        changed_count: 1,
1548        sections_changed: vec!["memory.database_url".to_owned()],
1549    })
1550}
1551
1552/// No-op migration for `[tools.shell]` transactional fields added in #2414.
1553///
1554/// All 5 new fields have `#[serde(default)]` so existing configs parse without changes.
1555/// This step adds them as commented-out hints in `[tools.shell]` if not already present.
1556///
1557/// # Errors
1558///
1559/// Returns `MigrateError` if the TOML cannot be parsed or `[tools.shell]` is malformed.
1560pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1561    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1562    if toml_src.contains("transactional") {
1563        return Ok(MigrationResult {
1564            output: toml_src.to_owned(),
1565            changed_count: 0,
1566            sections_changed: Vec::new(),
1567        });
1568    }
1569
1570    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1571
1572    let tools_shell_exists = doc
1573        .get("tools")
1574        .and_then(toml_edit::Item::as_table)
1575        .is_some_and(|t| t.contains_key("shell"));
1576    if !tools_shell_exists {
1577        // No [tools.shell] section — nothing to annotate; new configs will get defaults.
1578        return Ok(MigrationResult {
1579            output: toml_src.to_owned(),
1580            changed_count: 0,
1581            sections_changed: Vec::new(),
1582        });
1583    }
1584
1585    let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1586         # transactional = false\n\
1587         # transaction_scope = []          # glob patterns; empty = all extracted paths\n\
1588         # auto_rollback = false           # rollback when exit code >= 2\n\
1589         # auto_rollback_exit_codes = []   # explicit exit codes; overrides >= 2 heuristic\n\
1590         # snapshot_required = false       # abort if snapshot fails (default: warn and proceed)\n";
1591    let raw = doc.to_string();
1592    let output = format!("{raw}{comment}");
1593
1594    Ok(MigrationResult {
1595        output,
1596        changed_count: 1,
1597        sections_changed: vec!["tools.shell.transactional".to_owned()],
1598    })
1599}
1600
1601/// Migration step: add `budget_hint_enabled` as a commented-out entry under `[agent]` if absent.
1602///
1603/// # Errors
1604///
1605/// Returns an error if the config cannot be parsed or the `[agent]` section is malformed.
1606pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1607    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1608    if toml_src.contains("budget_hint_enabled") {
1609        return Ok(MigrationResult {
1610            output: toml_src.to_owned(),
1611            changed_count: 0,
1612            sections_changed: Vec::new(),
1613        });
1614    }
1615
1616    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1617    if !doc.contains_key("agent") {
1618        return Ok(MigrationResult {
1619            output: toml_src.to_owned(),
1620            changed_count: 0,
1621            sections_changed: Vec::new(),
1622        });
1623    }
1624
1625    let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1626         # budget_hint_enabled = true\n";
1627    let raw = doc.to_string();
1628    let output = format!("{raw}{comment}");
1629
1630    Ok(MigrationResult {
1631        output,
1632        changed_count: 1,
1633        sections_changed: vec!["agent.budget_hint_enabled".to_owned()],
1634    })
1635}
1636
1637/// Add a commented-out `[memory.forgetting]` section if absent (#2397).
1638///
1639/// All forgetting fields have `#[serde(default)]` so existing configs parse without changes.
1640/// This step surfaces the new section for users upgrading from older configs.
1641///
1642/// # Errors
1643///
1644/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1645pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1646    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1647    if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1648        return Ok(MigrationResult {
1649            output: toml_src.to_owned(),
1650            changed_count: 0,
1651            sections_changed: Vec::new(),
1652        });
1653    }
1654
1655    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1656    if !doc.contains_key("memory") {
1657        return Ok(MigrationResult {
1658            output: toml_src.to_owned(),
1659            changed_count: 0,
1660            sections_changed: Vec::new(),
1661        });
1662    }
1663
1664    let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1665         # [memory.forgetting]\n\
1666         # enabled = false\n\
1667         # decay_rate = 0.1                   # per-sweep importance decay\n\
1668         # forgetting_floor = 0.05            # prune below this score\n\
1669         # sweep_interval_secs = 7200         # run every 2 hours\n\
1670         # sweep_batch_size = 500\n\
1671         # protect_recent_hours = 24\n\
1672         # protect_min_access_count = 3\n";
1673    let raw = doc.to_string();
1674    let output = format!("{raw}{comment}");
1675
1676    Ok(MigrationResult {
1677        output,
1678        changed_count: 1,
1679        sections_changed: vec!["memory.forgetting".to_owned()],
1680    })
1681}
1682
1683/// Strip any existing `[memory.compression.predictor]` section from the config (#3251).
1684///
1685/// The compression predictor feature was removed. This migration cleans up both active
1686/// and commented-out sections that previous `--migrate-config` runs may have injected.
1687/// # Errors
1688///
1689/// This function is a pure string operation and always returns `Ok`. The `Result`
1690/// return type is kept for API consistency with other migration functions.
1691pub fn migrate_compression_predictor_config(
1692    toml_src: &str,
1693) -> Result<MigrationResult, MigrateError> {
1694    // Strip any [memory.compression.predictor] section (active or commented-out) that
1695    // prior migrate-config runs may have injected. The feature is removed (#3251).
1696    let has_active = toml_src.contains("[memory.compression.predictor]");
1697    let has_commented = toml_src.contains("# [memory.compression.predictor]");
1698    if !has_active && !has_commented {
1699        return Ok(MigrationResult {
1700            output: toml_src.to_owned(),
1701            changed_count: 0,
1702            sections_changed: Vec::new(),
1703        });
1704    }
1705
1706    // Remove lines that belong to the section header variants and their key lines.
1707    // A line belongs to the section when the section header has been seen and the
1708    // line is not a new `[section]` header (excluding the predictor header itself).
1709    let mut output_lines: Vec<&str> = Vec::new();
1710    let mut in_predictor = false;
1711    for line in toml_src.lines() {
1712        let trimmed = line.trim();
1713        // Detect active or commented-out section header.
1714        if trimmed == "[memory.compression.predictor]"
1715            || trimmed == "# [memory.compression.predictor]"
1716        {
1717            in_predictor = true;
1718            continue;
1719        }
1720        // Any new `[section]` header (not commented-out) ends the predictor block.
1721        if in_predictor && trimmed.starts_with('[') && !trimmed.starts_with("# [") {
1722            in_predictor = false;
1723        }
1724        if !in_predictor {
1725            output_lines.push(line);
1726        }
1727    }
1728    // Preserve trailing newline if original had one.
1729    let mut output = output_lines.join("\n");
1730    if toml_src.ends_with('\n') {
1731        output.push('\n');
1732    }
1733
1734    Ok(MigrationResult {
1735        output,
1736        changed_count: 1,
1737        sections_changed: vec!["memory.compression.predictor".to_owned()],
1738    })
1739}
1740
1741/// Add a commented-out `[memory.microcompact]` block if absent (#2699).
1742///
1743/// # Errors
1744///
1745/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1746pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1747    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1748    if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1749        return Ok(MigrationResult {
1750            output: toml_src.to_owned(),
1751            changed_count: 0,
1752            sections_changed: Vec::new(),
1753        });
1754    }
1755
1756    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1757    if !doc.contains_key("memory") {
1758        return Ok(MigrationResult {
1759            output: toml_src.to_owned(),
1760            changed_count: 0,
1761            sections_changed: Vec::new(),
1762        });
1763    }
1764
1765    let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1766         # [memory.microcompact]\n\
1767         # enabled = false\n\
1768         # gap_threshold_minutes = 60   # idle gap before clearing stale outputs\n\
1769         # keep_recent = 3              # always keep this many recent outputs intact\n";
1770    let raw = doc.to_string();
1771    let output = format!("{raw}{comment}");
1772
1773    Ok(MigrationResult {
1774        output,
1775        changed_count: 1,
1776        sections_changed: vec!["memory.microcompact".to_owned()],
1777    })
1778}
1779
1780/// Add a commented-out `[memory.autodream]` block if absent (#2697).
1781///
1782/// # Errors
1783///
1784/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1785pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1786    // Idempotency: comments are invisible to toml_edit, so check the raw source.
1787    if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1788        return Ok(MigrationResult {
1789            output: toml_src.to_owned(),
1790            changed_count: 0,
1791            sections_changed: Vec::new(),
1792        });
1793    }
1794
1795    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1796    if !doc.contains_key("memory") {
1797        return Ok(MigrationResult {
1798            output: toml_src.to_owned(),
1799            changed_count: 0,
1800            sections_changed: Vec::new(),
1801        });
1802    }
1803
1804    let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1805         # [memory.autodream]\n\
1806         # enabled = false\n\
1807         # min_sessions = 5             # sessions since last consolidation\n\
1808         # min_hours = 8                # hours since last consolidation\n\
1809         # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1810         # max_iterations = 5\n";
1811    let raw = doc.to_string();
1812    let output = format!("{raw}{comment}");
1813
1814    Ok(MigrationResult {
1815        output,
1816        changed_count: 1,
1817        sections_changed: vec!["memory.autodream".to_owned()],
1818    })
1819}
1820
1821/// Add a commented-out `[magic_docs]` block if absent (#2702).
1822///
1823/// # Errors
1824///
1825/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
1826pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1827    use toml_edit::{Item, Table};
1828
1829    let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1830
1831    if doc.contains_key("magic_docs") {
1832        return Ok(MigrationResult {
1833            output: toml_src.to_owned(),
1834            changed_count: 0,
1835            sections_changed: Vec::new(),
1836        });
1837    }
1838
1839    doc.insert("magic_docs", Item::Table(Table::new()));
1840    let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1841         # [magic_docs]\n\
1842         # enabled = false\n\
1843         # min_turns_between_updates = 10\n\
1844         # update_provider = \"\"         # provider name from [[llm.providers]]; empty = primary\n\
1845         # max_iterations = 3\n";
1846    // Remove the just-inserted empty table and replace with a comment.
1847    doc.remove("magic_docs");
1848    // Append as a trailing comment on the document root.
1849    let raw = doc.to_string();
1850    let output = format!("{raw}\n{comment}");
1851
1852    Ok(MigrationResult {
1853        output,
1854        changed_count: 1,
1855        sections_changed: vec!["magic_docs".to_owned()],
1856    })
1857}
1858
1859/// Add a commented-out `[telemetry]` block if the section is absent (#2846).
1860///
1861/// Existing configs that were written before the `telemetry` section was introduced will have
1862/// the block appended as comments so users can discover and enable it without manual hunting.
1863///
1864/// # Errors
1865///
1866/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1867pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1868    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1869
1870    if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1871        return Ok(MigrationResult {
1872            output: toml_src.to_owned(),
1873            changed_count: 0,
1874            sections_changed: Vec::new(),
1875        });
1876    }
1877
1878    let comment = "\n\
1879         # Profiling and distributed tracing (requires --features profiling). All\n\
1880         # instrumentation points are zero-overhead when the feature is absent.\n\
1881         # [telemetry]\n\
1882         # enabled = false\n\
1883         # backend = \"local\"        # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1884         # trace_dir = \".local/traces\"\n\
1885         # include_args = false\n\
1886         # service_name = \"zeph-agent\"\n\
1887         # sample_rate = 1.0\n\
1888         # otel_filter = \"info\"     # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1889
1890    let raw = doc.to_string();
1891    let output = format!("{raw}{comment}");
1892
1893    Ok(MigrationResult {
1894        output,
1895        changed_count: 1,
1896        sections_changed: vec!["telemetry".to_owned()],
1897    })
1898}
1899
1900/// Add a commented-out `[agent.supervisor]` block if the sub-table is absent (#2883).
1901///
1902/// Appended as comments under `[agent]` so users can discover and tune supervisor limits
1903/// without manual hunting. Safe to call on configs that already have the section.
1904///
1905/// # Errors
1906///
1907/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1908pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1909    // Idempotency: skip if already present (either as real section or commented-out block).
1910    if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1911        return Ok(MigrationResult {
1912            output: toml_src.to_owned(),
1913            changed_count: 0,
1914            sections_changed: Vec::new(),
1915        });
1916    }
1917
1918    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1919
1920    // Only inject the comment block when an [agent] section is already present so we don't
1921    // pollute configs that have no [agent] at all.
1922    if !doc.contains_key("agent") {
1923        return Ok(MigrationResult {
1924            output: toml_src.to_owned(),
1925            changed_count: 0,
1926            sections_changed: Vec::new(),
1927        });
1928    }
1929
1930    let comment = "\n\
1931         # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1932         # [agent.supervisor]\n\
1933         # enrichment_limit = 4\n\
1934         # telemetry_limit = 8\n\
1935         # abort_enrichment_on_turn = false\n";
1936
1937    let raw = doc.to_string();
1938    let output = format!("{raw}{comment}");
1939
1940    Ok(MigrationResult {
1941        output,
1942        changed_count: 1,
1943        sections_changed: vec!["agent.supervisor".to_owned()],
1944    })
1945}
1946
1947/// Add a commented-out `otel_filter` entry under `[telemetry]` if the key is absent (#2997).
1948///
1949/// When `[telemetry]` exists but lacks `otel_filter`, appends the key as a comment so users
1950/// can discover it without manual hunting. Safe to call when the key is already present
1951/// (real or commented-out).
1952///
1953/// # Errors
1954///
1955/// Returns `MigrateError::Parse` if `toml_src` is not valid TOML.
1956pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1957    // Idempotency: skip if key already present (real or commented-out).
1958    if toml_src.contains("otel_filter") {
1959        return Ok(MigrationResult {
1960            output: toml_src.to_owned(),
1961            changed_count: 0,
1962            sections_changed: Vec::new(),
1963        });
1964    }
1965
1966    let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1967
1968    // Only inject when [telemetry] section exists; otherwise the field will be added
1969    // by migrate_telemetry_config which already includes it in the commented block.
1970    if !doc.contains_key("telemetry") {
1971        return Ok(MigrationResult {
1972            output: toml_src.to_owned(),
1973            changed_count: 0,
1974            sections_changed: Vec::new(),
1975        });
1976    }
1977
1978    let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
1979        (tonic=warn etc.) are always appended (#2997).\n\
1980        # otel_filter = \"info\"\n";
1981    let raw = doc.to_string();
1982    // Insert within [telemetry] so the comment stays adjacent to its section.
1983    let output = insert_after_section(&raw, "telemetry", comment);
1984
1985    Ok(MigrationResult {
1986        output,
1987        changed_count: 1,
1988        sections_changed: vec!["telemetry.otel_filter".to_owned()],
1989    })
1990}
1991
1992/// Adds a commented-out `[tools.egress]` section to configs that predate egress logging (#3058).
1993///
1994/// # Errors
1995///
1996/// Returns [`MigrateError`] if the TOML source cannot be parsed.
1997pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1998    if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
1999        return Ok(MigrationResult {
2000            output: toml_src.to_owned(),
2001            changed_count: 0,
2002            sections_changed: Vec::new(),
2003        });
2004    }
2005
2006    let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
2007        # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
2008        # [tools.egress]\n\
2009        # enabled = true           # set to false to disable all egress event recording\n\
2010        # log_blocked = true       # record scheme/domain/SSRF-blocked requests\n\
2011        # log_response_bytes = true\n\
2012        # log_hosts_to_tui = true\n";
2013
2014    let mut output = toml_src.to_owned();
2015    output.push_str(comment);
2016    Ok(MigrationResult {
2017        output,
2018        changed_count: 1,
2019        sections_changed: vec!["tools.egress".to_owned()],
2020    })
2021}
2022
2023/// Adds a commented-out `[security.vigil]` section to configs that predate VIGIL (#3058).
2024///
2025/// # Errors
2026///
2027/// Returns [`MigrateError`] if the TOML source cannot be parsed.
2028pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2029    if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
2030        return Ok(MigrationResult {
2031            output: toml_src.to_owned(),
2032            changed_count: 0,
2033            sections_changed: Vec::new(),
2034        });
2035    }
2036
2037    let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2038        # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2039        # [security.vigil]\n\
2040        # enabled = true          # master switch; false bypasses VIGIL entirely\n\
2041        # strict_mode = false     # true: block (replace with sentinel); false: truncate+annotate\n\
2042        # sanitize_max_chars = 2048\n\
2043        # extra_patterns = []     # operator-supplied additional injection patterns (max 64)\n\
2044        # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2045
2046    let mut output = toml_src.to_owned();
2047    output.push_str(comment);
2048    Ok(MigrationResult {
2049        output,
2050        changed_count: 1,
2051        sections_changed: vec!["security.vigil".to_owned()],
2052    })
2053}
2054
2055/// Adds a commented-out `[tools.sandbox]` section to configs that predate the
2056/// OS subprocess sandbox wizard (#3070). Also referenced by #3077.
2057///
2058/// Idempotent: if the section (or a dotted-key form under `[tools]`) is already
2059/// present, OR if the commented-out block was already appended by a prior run,
2060/// the input is returned unchanged. Uses `toml_edit` parsing to avoid false
2061/// positives from comments that mention `tools.sandbox`.
2062///
2063/// # Errors
2064///
2065/// Returns [`MigrateError`] if the TOML source cannot be parsed.
2066pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2067    let doc: DocumentMut = toml_src.parse()?;
2068    let already_present = doc
2069        .get("tools")
2070        .and_then(|t| t.as_table())
2071        .and_then(|t| t.get("sandbox"))
2072        .is_some();
2073    // Secondary guard: commented-out block appended by a prior run of this
2074    // function is not a real TOML key, so toml_edit would not detect it above.
2075    if already_present || toml_src.contains("# [tools.sandbox]") {
2076        return Ok(MigrationResult {
2077            output: toml_src.to_owned(),
2078            changed_count: 0,
2079            sections_changed: Vec::new(),
2080        });
2081    }
2082
2083    let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2084        # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2085        # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2086        # [tools.sandbox]\n\
2087        # enabled = false                 # set to true to wrap shell commands\n\
2088        # profile = \"workspace\"          # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2089        # backend = \"auto\"               # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2090        # strict = true                   # fail startup if sandbox init fails (fail-closed)\n\
2091        # allow_read = []                 # additional read-allowed absolute paths\n\
2092        # allow_write = []                # additional write-allowed absolute paths\n";
2093
2094    let mut output = toml_src.to_owned();
2095    output.push_str(comment);
2096    Ok(MigrationResult {
2097        output,
2098        changed_count: 1,
2099        sections_changed: vec!["tools.sandbox".to_owned()],
2100    })
2101}
2102
2103/// Insert `denied_domains` and `fail_if_unavailable` into an existing `[tools.sandbox]`
2104/// section when those keys are absent (#3294).
2105///
2106/// Idempotent: if either key is already present (active or commented), the function is a no-op.
2107///
2108/// # Errors
2109///
2110/// Returns [`MigrateError`] if the TOML document cannot be parsed.
2111pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2112    // Only inject when [tools.sandbox] already exists.
2113    if !toml_src.contains("[tools.sandbox]") {
2114        return Ok(MigrationResult {
2115            output: toml_src.to_owned(),
2116            changed_count: 0,
2117            sections_changed: Vec::new(),
2118        });
2119    }
2120
2121    let already_has_denied =
2122        toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
2123    let already_has_fail =
2124        toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
2125
2126    if already_has_denied && already_has_fail {
2127        return Ok(MigrationResult {
2128            output: toml_src.to_owned(),
2129            changed_count: 0,
2130            sections_changed: Vec::new(),
2131        });
2132    }
2133
2134    let mut comment = String::new();
2135    if !already_has_denied {
2136        comment.push_str(
2137            "# denied_domains = []       \
2138             # hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
2139        );
2140    }
2141    if !already_has_fail {
2142        comment.push_str(
2143            "# fail_if_unavailable = false  \
2144             # abort startup when no effective OS sandbox is available\n",
2145        );
2146    }
2147
2148    let output = toml_src.replacen(
2149        "[tools.sandbox]\n",
2150        &format!("[tools.sandbox]\n{comment}"),
2151        1,
2152    );
2153    Ok(MigrationResult {
2154        output,
2155        changed_count: 1,
2156        sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
2157    })
2158}
2159
2160/// Add a commented-out `persistence_enabled` key under `[orchestration]` when absent (#3107).
2161///
2162/// Existing configs that omit this key pick up `true` via `#[serde(default)]`, so this
2163/// migration is informational — it surfaces the new option without changing behaviour.
2164///
2165/// # Errors
2166///
2167/// Returns [`MigrateError`] if the TOML document cannot be parsed.
2168pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2169    // Skip if the key is already present (active or commented).
2170    if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2171        return Ok(MigrationResult {
2172            output: toml_src.to_owned(),
2173            changed_count: 0,
2174            sections_changed: Vec::new(),
2175        });
2176    }
2177
2178    // Only inject under an existing [orchestration] section.
2179    if !toml_src.contains("[orchestration]") {
2180        return Ok(MigrationResult {
2181            output: toml_src.to_owned(),
2182            changed_count: 0,
2183            sections_changed: Vec::new(),
2184        });
2185    }
2186
2187    // Insert the commented key right after the `[orchestration]` header line.
2188    let comment = "# persistence_enabled = true  \
2189        # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2190    let output = toml_src.replacen(
2191        "[orchestration]\n",
2192        &format!("[orchestration]\n{comment}"),
2193        1,
2194    );
2195    Ok(MigrationResult {
2196        output,
2197        changed_count: 1,
2198        sections_changed: vec!["orchestration.persistence_enabled".to_owned()],
2199    })
2200}
2201
2202/// Add commented-out `[session.recap]` block if absent (#3064).
2203///
2204/// All recap fields have `#[serde(default)]` so existing configs parse without changes.
2205///
2206/// # Errors
2207///
2208/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
2209pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2210    // Idempotency: check both active and commented forms.
2211    if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2212        return Ok(MigrationResult {
2213            output: toml_src.to_owned(),
2214            changed_count: 0,
2215            sections_changed: Vec::new(),
2216        });
2217    }
2218
2219    let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2220         # [session.recap]\n\
2221         # on_resume = true\n\
2222         # max_tokens = 200\n\
2223         # provider = \"\"\n\
2224         # max_input_messages = 20\n";
2225    let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2226    let output = format!("{raw}{comment}");
2227
2228    Ok(MigrationResult {
2229        output,
2230        changed_count: 1,
2231        sections_changed: vec!["session.recap".to_owned()],
2232    })
2233}
2234
2235/// Add commented-out MCP elicitation keys to `[mcp]` section if absent (#3141).
2236///
2237/// All elicitation fields have `#[serde(default)]` so existing configs parse without changes.
2238///
2239/// # Errors
2240///
2241/// Returns `MigrateError::Parse` if the TOML cannot be parsed.
2242pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2243    // Idempotency: check for any elicitation key presence.
2244    if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2245        return Ok(MigrationResult {
2246            output: toml_src.to_owned(),
2247            changed_count: 0,
2248            sections_changed: Vec::new(),
2249        });
2250    }
2251
2252    // Only inject under an existing [mcp] section.
2253    if !toml_src.contains("[mcp]") {
2254        return Ok(MigrationResult {
2255            output: toml_src.to_owned(),
2256            changed_count: 0,
2257            sections_changed: Vec::new(),
2258        });
2259    }
2260
2261    // Guard against configs that have `[mcp]` but with Windows line endings or at EOF.
2262    if !toml_src.contains("[mcp]\n") {
2263        return Ok(MigrationResult {
2264            output: toml_src.to_owned(),
2265            changed_count: 0,
2266            sections_changed: Vec::new(),
2267        });
2268    }
2269
2270    let comment = "# elicitation_enabled = false          \
2271        # opt-in: servers may request user input mid-task (#3141)\n\
2272        # elicitation_timeout = 120            # seconds to wait for user response\n\
2273        # elicitation_queue_capacity = 16      # beyond this limit requests are auto-declined\n\
2274        # elicitation_warn_sensitive_fields = true  # warn before prompting for password/token/etc.\n";
2275    let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2276
2277    Ok(MigrationResult {
2278        output,
2279        changed_count: 1,
2280        sections_changed: vec!["mcp.elicitation".to_owned()],
2281    })
2282}
2283
2284/// Add a commented-out `[quality]` block if the config lacks it (#3228).
2285///
2286/// Introduced alongside the MARCH self-check pipeline (#3226). All `QualityConfig`
2287/// fields have `#[serde(default)]` so existing configs parse without changes; this
2288/// migration only surfaces the section so users can discover and enable it.
2289///
2290/// # Errors
2291///
2292/// This function is infallible in practice; the `Result` return type matches the
2293/// migration function convention for use in chained pipelines.
2294pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2295    // Idempotency: line-anchored check avoids false-positives on [quality.foo] subtables.
2296    if toml_src
2297        .lines()
2298        .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2299    {
2300        return Ok(MigrationResult {
2301            output: toml_src.to_owned(),
2302            changed_count: 0,
2303            sections_changed: Vec::new(),
2304        });
2305    }
2306
2307    let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2308         # [quality]\n\
2309         # self_check = false                    # enable post-response self-check\n\
2310         # trigger = \"has_retrieval\"             # has_retrieval | always | manual\n\
2311         # latency_budget_ms = 4000              # hard ceiling for the whole pipeline\n\
2312         # proposer_provider = \"\"                # optional: provider name from [[llm.providers]]\n\
2313         # checker_provider = \"\"                 # optional: provider name from [[llm.providers]]\n\
2314         # min_evidence = 0.6                    # 0.0..1.0; below → flag assertion\n\
2315         # async_run = false                     # true = fire-and-forget (non-blocking)\n\
2316         # per_call_timeout_ms = 2000            # per-LLM-call timeout\n\
2317         # max_assertions = 12                   # maximum assertions extracted from one response\n\
2318         # max_response_chars = 8000             # skip pipeline when response exceeds this\n\
2319         # cache_disabled_for_checker = true     # suppress prompt-cache on Checker provider\n\
2320         # flag_marker = \"[verify]\"              # marker appended when assertions are flagged\n";
2321    let output = format!("{toml_src}{comment}");
2322
2323    Ok(MigrationResult {
2324        output,
2325        changed_count: 1,
2326        sections_changed: vec!["quality".to_owned()],
2327    })
2328}
2329
2330/// Add a commented-out `[acp.subagents]` block if the config lacks it (#3304).
2331///
2332/// Introduced alongside the ACP sub-agent delegation feature (#3289). All `AcpSubagentsConfig`
2333/// fields have `#[serde(default)]` so existing configs parse without changes; this migration
2334/// only surfaces the section so users can discover and enable it.
2335///
2336/// # Errors
2337///
2338/// This function is infallible in practice; the `Result` return type matches the
2339/// migration function convention for use in chained pipelines.
2340pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2341    if toml_src
2342        .lines()
2343        .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2344    {
2345        return Ok(MigrationResult {
2346            output: toml_src.to_owned(),
2347            changed_count: 0,
2348            sections_changed: Vec::new(),
2349        });
2350    }
2351
2352    let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2353         # [acp.subagents]\n\
2354         # enabled = false\n\
2355         #\n\
2356         # [[acp.subagents.presets]]\n\
2357         # name = \"inner\"                         # identifier used in /subagent commands\n\
2358         # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2359         # # cwd = \"/path/to/agent\"              # optional working directory\n\
2360         # # handshake_timeout_secs = 30          # initialize+session/new timeout\n\
2361         # # prompt_timeout_secs = 600            # single round-trip timeout\n";
2362    let output = format!("{toml_src}{comment}");
2363
2364    Ok(MigrationResult {
2365        output,
2366        changed_count: 1,
2367        sections_changed: vec!["acp.subagents".to_owned()],
2368    })
2369}
2370
2371/// Add a commented-out `[[hooks.permission_denied]]` block if the config lacks it (#3309).
2372///
2373/// Introduced alongside the reactive env hooks and MCP tool dispatch feature (#3303).
2374/// All hook arrays have `#[serde(default)]` so existing configs parse without changes;
2375/// this migration surfaces the section for discoverability.
2376///
2377/// # Errors
2378///
2379/// This function is infallible in practice; the `Result` return type matches the
2380/// migration function convention for use in chained pipelines.
2381pub fn migrate_hooks_permission_denied_config(
2382    toml_src: &str,
2383) -> Result<MigrationResult, MigrateError> {
2384    if toml_src.lines().any(|l| {
2385        l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2386    }) {
2387        return Ok(MigrationResult {
2388            output: toml_src.to_owned(),
2389            changed_count: 0,
2390            sections_changed: Vec::new(),
2391        });
2392    }
2393
2394    let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2395         # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2396         # [[hooks.permission_denied]]\n\
2397         # [hooks.permission_denied.action]\n\
2398         # type = \"command\"\n\
2399         # command = \"echo denied: $ZEPH_TOOL\"\n";
2400    let output = format!("{toml_src}{comment}");
2401
2402    Ok(MigrationResult {
2403        output,
2404        changed_count: 1,
2405        sections_changed: vec!["hooks.permission_denied".to_owned()],
2406    })
2407}
2408
2409/// Add commented-out `[memory.graph]` retrieval strategy options if the config lacks them (#3317).
2410///
2411/// Introduced alongside the multi-strategy graph retrieval and experience memory feature (#3311).
2412/// All `MemoryGraphConfig` fields have `#[serde(default)]` so existing configs parse without
2413/// changes; this migration surfaces the new options for discoverability.
2414///
2415/// # Errors
2416///
2417/// This function is infallible in practice; the `Result` return type matches the
2418/// migration function convention for use in chained pipelines.
2419pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2420    if toml_src.contains("retrieval_strategy")
2421        || toml_src.contains("[memory.graph.beam_search]")
2422        || toml_src.contains("# [memory.graph.beam_search]")
2423    {
2424        return Ok(MigrationResult {
2425            output: toml_src.to_owned(),
2426            changed_count: 0,
2427            sections_changed: Vec::new(),
2428        });
2429    }
2430
2431    let comment = "\n# [memory.graph] retrieval strategy options (#3311).\n\
2432         # retrieval_strategy = \"synapse\"    # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2433         #\n\
2434         # [memory.graph.beam_search]        # active when retrieval_strategy = \"beam_search\"\n\
2435         # beam_width = 10                   # top-K candidates kept per hop\n\
2436         #\n\
2437         # [memory.graph.watercircles]       # active when retrieval_strategy = \"watercircles\"\n\
2438         # ring_limit = 0                    # max facts per ring; 0 = auto\n\
2439         #\n\
2440         # [memory.graph.experience]         # experience memory recording\n\
2441         # enabled = false\n\
2442         # evolution_sweep_enabled = false\n\
2443         # confidence_prune_threshold = 0.1  # prune edges below this threshold\n\
2444         # evolution_sweep_interval = 50     # turns between sweeps\n";
2445    let output = format!("{toml_src}{comment}");
2446
2447    Ok(MigrationResult {
2448        output,
2449        changed_count: 1,
2450        sections_changed: vec!["memory.graph.retrieval".to_owned()],
2451    })
2452}
2453
2454/// Add a commented-out `[scheduler.daemon]` block if the config lacks it (#3332).
2455///
2456/// Introduced alongside the `zeph serve` daemon mode (#3332). All `DaemonConfig` fields
2457/// have defaults so existing configs parse without changes; this migration surfaces the
2458/// section so users can discover and configure the daemon process.
2459///
2460/// # Errors
2461///
2462/// This function is infallible in practice; the `Result` return type matches the
2463/// migration function convention for use in chained pipelines.
2464pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2465    if toml_src
2466        .lines()
2467        .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2468    {
2469        return Ok(MigrationResult {
2470            output: toml_src.to_owned(),
2471            changed_count: 0,
2472            sections_changed: Vec::new(),
2473        });
2474    }
2475
2476    let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2477         # [scheduler.daemon]\n\
2478         # pid_file = \"/tmp/zeph-scheduler.pid\"   # PID file path (must be on a local filesystem)\n\
2479         # log_file = \"/tmp/zeph-scheduler.log\"   # daemon log file path (append-only; rotate externally)\n\
2480         # tick_secs = 60                           # scheduler tick interval in seconds (clamped 5..=3600)\n\
2481         # shutdown_grace_secs = 30                 # grace period after SIGTERM before process exits\n\
2482         # catch_up = true                          # replay missed cron tasks on daemon restart\n";
2483    let output = format!("{toml_src}{comment}");
2484
2485    Ok(MigrationResult {
2486        output,
2487        changed_count: 1,
2488        sections_changed: vec!["scheduler.daemon".to_owned()],
2489    })
2490}
2491
2492/// Add a commented-out `[memory.retrieval]` block if the config lacks it (#3340).
2493///
2494/// MemMachine-inspired retrieval-stage tuning: ANN candidate depth, search-prompt template,
2495/// and context snippet format. All fields have defaults so existing configs parse unchanged;
2496/// this migration surfaces the section for discoverability.
2497///
2498/// # Errors
2499///
2500/// This function is infallible in practice; the `Result` return type matches the migration
2501/// function convention for use in chained pipelines.
2502pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2503    if toml_src
2504        .lines()
2505        .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2506    {
2507        return Ok(MigrationResult {
2508            output: toml_src.to_owned(),
2509            changed_count: 0,
2510            sections_changed: Vec::new(),
2511        });
2512    }
2513
2514    let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2515         # [memory.retrieval]\n\
2516         # depth = 0                          # ANN candidates fetched from the vector store, directly.\n\
2517         #                                    # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2518         #                                    # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2519         # search_prompt_template = \"\"        # embedding query template; {query} = raw user query; empty = identity\n\
2520         # context_format = \"structured\"      # structured | plain — memory snippet rendering format\n\
2521         # query_bias_correction = true        # shift first-person queries towards user profile centroid (MM-F3)\n\
2522         # query_bias_profile_weight = 0.25    # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2523         # query_bias_centroid_ttl_secs = 300  # seconds before profile centroid cache is recomputed\n";
2524    let output = format!("{toml_src}{comment}");
2525
2526    Ok(MigrationResult {
2527        output,
2528        changed_count: 1,
2529        sections_changed: vec!["memory.retrieval".to_owned()],
2530    })
2531}
2532
2533/// Add a commented-out `[memory.reasoning]` block if the config lacks it (#3369).
2534///
2535/// `ReasoningBank` distilled strategy memory was added in v0.19.3 (commit b99b2d30).
2536/// All fields have defaults so existing configs parse unchanged; this migration
2537/// surfaces the section for discoverability.
2538///
2539/// # Errors
2540///
2541/// This function is infallible in practice; the `Result` return type matches the migration
2542/// function convention for use in chained pipelines.
2543pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2544    if toml_src
2545        .lines()
2546        .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2547    {
2548        return Ok(MigrationResult {
2549            output: toml_src.to_owned(),
2550            changed_count: 0,
2551            sections_changed: Vec::new(),
2552        });
2553    }
2554
2555    let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2556         # [memory.reasoning]\n\
2557         # enabled = false\n\
2558         # extract_provider = \"\"         # SLM: self-judge (JSON response) — leave blank to use primary\n\
2559         # distill_provider = \"\"         # SLM: strategy distillation — leave blank to use primary\n\
2560         # top_k = 3                      # strategies injected per turn\n\
2561         # store_limit = 1000             # max rows in reasoning_strategies table\n\
2562         # context_budget_tokens = 500\n\
2563         # extraction_timeout_secs = 30\n\
2564         # distill_timeout_secs = 30\n\
2565         # max_messages = 6\n\
2566         # min_messages = 2\n\
2567         # max_message_chars = 2000\n";
2568    let output = format!("{toml_src}{comment}");
2569
2570    Ok(MigrationResult {
2571        output,
2572        changed_count: 1,
2573        sections_changed: vec!["memory.reasoning".to_owned()],
2574    })
2575}
2576
2577/// Insert commented-out `self_judge_window` and `min_assistant_chars` keys under an existing
2578/// `[memory.reasoning]` block when they are absent (#3383).
2579///
2580/// Configs that lack a `[memory.reasoning]` section are returned unchanged (the
2581/// [`migrate_memory_reasoning_config`] step is responsible for adding the section).
2582/// Idempotent when either key is already present.
2583///
2584/// # Errors
2585///
2586/// This function is infallible in practice; the `Result` return type matches the migration
2587/// function convention for use in chained pipelines.
2588pub fn migrate_memory_reasoning_judge_config(
2589    toml_src: &str,
2590) -> Result<MigrationResult, MigrateError> {
2591    let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2592    if !has_section {
2593        return Ok(MigrationResult {
2594            output: toml_src.to_owned(),
2595            changed_count: 0,
2596            sections_changed: Vec::new(),
2597        });
2598    }
2599
2600    // Check if both keys are already present (active or commented).
2601    let has_window = toml_src.lines().any(|l| {
2602        let t = l.trim().trim_start_matches('#').trim();
2603        t.starts_with("self_judge_window")
2604    });
2605    let has_min_chars = toml_src.lines().any(|l| {
2606        let t = l.trim().trim_start_matches('#').trim();
2607        t.starts_with("min_assistant_chars")
2608    });
2609    if has_window && has_min_chars {
2610        return Ok(MigrationResult {
2611            output: toml_src.to_owned(),
2612            changed_count: 0,
2613            sections_changed: Vec::new(),
2614        });
2615    }
2616
2617    // Append the new keys after the last line belonging to [memory.reasoning].
2618    // Strategy: find the last line of the [memory.reasoning] block (before the next section
2619    // header) and insert the commented-out keys after it.
2620    let lines: Vec<&str> = toml_src.lines().collect();
2621    let mut section_start = None;
2622    let mut insert_after = None;
2623
2624    for (i, line) in lines.iter().enumerate() {
2625        if line.trim() == "[memory.reasoning]" {
2626            section_start = Some(i);
2627        }
2628        if let Some(start) = section_start {
2629            let trimmed = line.trim();
2630            // A new top-level section header ends the current section.
2631            if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2632                break;
2633            }
2634            insert_after = Some(i);
2635        }
2636    }
2637
2638    let Some(insert_idx) = insert_after else {
2639        return Ok(MigrationResult {
2640            output: toml_src.to_owned(),
2641            changed_count: 0,
2642            sections_changed: Vec::new(),
2643        });
2644    };
2645
2646    let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2647    let mut additions = Vec::new();
2648    if !has_window {
2649        additions.push(
2650            "# self_judge_window = 2   # max recent messages passed to self-judge (#3383)"
2651                .to_owned(),
2652        );
2653    }
2654    if !has_min_chars {
2655        additions.push(
2656            "# min_assistant_chars = 50  # skip self-judge for short replies (#3383)".to_owned(),
2657        );
2658    }
2659    for (offset, line) in additions.iter().enumerate() {
2660        new_lines.insert(insert_idx + 1 + offset, line.clone());
2661    }
2662
2663    let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2664    Ok(MigrationResult {
2665        output,
2666        changed_count: additions.len(),
2667        sections_changed: vec!["memory.reasoning".to_owned()],
2668    })
2669}
2670
2671/// Append a commented-out `[memory.hebbian]` block to `toml_src` when it is absent (HL-F1/F2, #3344).
2672///
2673/// Idempotent: if a `[memory.hebbian]` or `# [memory.hebbian]` line already exists,
2674/// the input is returned unchanged with `changed_count = 0`.
2675///
2676/// # Errors
2677///
2678/// This function is infallible in practice; the `Result` return type matches the migration
2679/// function convention for use in chained pipelines.
2680pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2681    if toml_src
2682        .lines()
2683        .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2684    {
2685        return Ok(MigrationResult {
2686            output: toml_src.to_owned(),
2687            changed_count: 0,
2688            sections_changed: Vec::new(),
2689        });
2690    }
2691
2692    let comment = "\n# [memory.hebbian]                       # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2693         # [memory.hebbian]\n\
2694         # enabled = false                        # opt-in master switch; no DB writes when false\n\
2695         # hebbian_lr = 0.1                       # weight increment per co-activation (0.01–0.5)\n";
2696    let output = format!("{toml_src}{comment}");
2697
2698    Ok(MigrationResult {
2699        output,
2700        changed_count: 1,
2701        sections_changed: vec!["memory.hebbian".to_owned()],
2702    })
2703}
2704
2705/// Splice missing HL-F3/F4 consolidation fields into an existing `[memory.hebbian]` section
2706/// (HL-F3/F4, #3345).
2707///
2708/// Three branches:
2709/// - Section absent → no-op (handled by `migrate_memory_hebbian_config`).
2710/// - Section present but missing consolidation fields → append commented-out defaults.
2711/// - Section present with all fields → no-op.
2712///
2713/// # Errors
2714///
2715/// Infallible in practice; `Result` matches the migration convention.
2716pub fn migrate_memory_hebbian_consolidation_config(
2717    toml_src: &str,
2718) -> Result<MigrationResult, MigrateError> {
2719    let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2720
2721    if !has_section {
2722        return Ok(MigrationResult {
2723            output: toml_src.to_owned(),
2724            changed_count: 0,
2725            sections_changed: Vec::new(),
2726        });
2727    }
2728
2729    // Check if all consolidation fields already present (active or commented).
2730    let has_interval = toml_src
2731        .lines()
2732        .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2733    let has_threshold = toml_src
2734        .lines()
2735        .any(|l| l.trim().starts_with("consolidation_threshold"));
2736    let has_provider = toml_src
2737        .lines()
2738        .any(|l| l.trim().starts_with("consolidate_provider"));
2739
2740    if has_interval && has_threshold && has_provider {
2741        return Ok(MigrationResult {
2742            output: toml_src.to_owned(),
2743            changed_count: 0,
2744            sections_changed: Vec::new(),
2745        });
2746    }
2747
2748    let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2749        # consolidation_interval_secs = 3600   # how often the sweep runs (0 = disabled)\n\
2750        # consolidation_threshold = 5.0        # degree × avg_weight score to qualify\n\
2751        # consolidate_provider = \"fast\"        # provider name for LLM distillation\n\
2752        # max_candidates_per_sweep = 10\n\
2753        # consolidation_cooldown_secs = 86400  # re-consolidation cooldown per entity\n\
2754        # consolidation_prompt_timeout_secs = 30\n\
2755        # consolidation_max_neighbors = 20\n";
2756
2757    let output = format!("{toml_src}{extra}");
2758    Ok(MigrationResult {
2759        output,
2760        changed_count: 1,
2761        sections_changed: vec!["memory.hebbian".to_owned()],
2762    })
2763}
2764
2765/// Splice missing HL-F5 spreading-activation fields into an existing `[memory.hebbian]` section
2766/// (HL-F5, #3346).
2767///
2768/// Three branches:
2769/// - Section absent → no-op (handled by `migrate_memory_hebbian_config`).
2770/// - Section present but missing HL-F5 fields → append commented-out defaults.
2771/// - Section present with all fields → no-op.
2772///
2773/// # Errors
2774///
2775/// Infallible in practice; `Result` matches the migration convention.
2776pub fn migrate_memory_hebbian_spread_config(
2777    toml_src: &str,
2778) -> Result<MigrationResult, MigrateError> {
2779    let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2780
2781    if !has_section {
2782        return Ok(MigrationResult {
2783            output: toml_src.to_owned(),
2784            changed_count: 0,
2785            sections_changed: Vec::new(),
2786        });
2787    }
2788
2789    // Check if all HL-F5 fields are already present (active or commented).
2790    let has_spreading = toml_src
2791        .lines()
2792        .any(|l| l.trim().starts_with("spreading_activation"));
2793    let has_depth = toml_src
2794        .lines()
2795        .any(|l| l.trim().starts_with("spread_depth"));
2796    let has_budget = toml_src
2797        .lines()
2798        .any(|l| l.trim().starts_with("step_budget_ms"));
2799
2800    if has_spreading && has_depth && has_budget {
2801        return Ok(MigrationResult {
2802            output: toml_src.to_owned(),
2803            changed_count: 0,
2804            sections_changed: Vec::new(),
2805        });
2806    }
2807
2808    let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2809        # spreading_activation = false   # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2810        # spread_depth = 2               # BFS hops, clamped [1,6]\n\
2811        # spread_edge_types = []         # MAGMA edge types to traverse; empty = all\n\
2812        # step_budget_ms = 8             # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n";
2813
2814    let output = format!("{toml_src}{extra}");
2815    Ok(MigrationResult {
2816        output,
2817        changed_count: 1,
2818        sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2819    })
2820}
2821
2822/// Append a commented-out `[[hooks.turn_complete]]` block to `toml_src` when it is absent (#3308).
2823///
2824/// Idempotent: if a `[[hooks.turn_complete]]` or `# [[hooks.turn_complete]]` line already exists,
2825/// the input is returned unchanged with `changed_count = 0`.
2826///
2827/// The template uses a single `command` string (not `args`) to match the `HookAction::Command`
2828/// schema, and avoids embedding `$ZEPH_TURN_PREVIEW` directly in the command string to prevent
2829/// shell injection.
2830///
2831/// # Errors
2832///
2833/// This function is infallible in practice; the `Result` return type matches the migration
2834/// function convention for use in chained pipelines.
2835pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2836    if toml_src
2837        .lines()
2838        .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2839    {
2840        return Ok(MigrationResult {
2841            output: toml_src.to_owned(),
2842            changed_count: 0,
2843            sections_changed: Vec::new(),
2844        });
2845    }
2846
2847    let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2848         # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2849         # ZEPH_TURN_LLM_REQUESTS.\n\
2850         # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2851         # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2852         # [[hooks.turn_complete]]\n\
2853         # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2854         # timeout_secs = 3\n\
2855         # fail_closed = false\n";
2856    let output = format!("{toml_src}{comment}");
2857
2858    Ok(MigrationResult {
2859        output,
2860        changed_count: 1,
2861        sections_changed: vec!["hooks.turn_complete".to_owned()],
2862    })
2863}
2864
2865/// Inject a commented-out `auto_consolidate_min_window` key into `[agent.focus]` if absent (#3313).
2866///
2867/// All `FocusConfig` fields have `#[serde(default)]`, so existing configs deserialize without
2868/// changes. This step surfaces the new field for users upgrading from older configs.
2869///
2870/// The comment is inserted *inside* the `[agent.focus]` section using [`insert_after_section`],
2871/// so it ends up in the correct table regardless of where that section appears in the file.
2872///
2873/// Idempotent: if `auto_consolidate_min_window` already appears anywhere in the source,
2874/// the input is returned unchanged with `changed_count = 0`.
2875/// No-op when `[agent.focus]` is absent or only exists as a comment line.
2876///
2877/// # Errors
2878///
2879/// This function is infallible in practice; the `Result` return type matches the migration
2880/// function convention for use in chained pipelines.
2881pub fn migrate_focus_auto_consolidate_min_window(
2882    toml_src: &str,
2883) -> Result<MigrationResult, MigrateError> {
2884    if toml_src.contains("auto_consolidate_min_window") {
2885        return Ok(MigrationResult {
2886            output: toml_src.to_owned(),
2887            changed_count: 0,
2888            sections_changed: Vec::new(),
2889        });
2890    }
2891
2892    // Only inject when [agent.focus] exists as a live section (not a comment).
2893    if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
2894        return Ok(MigrationResult {
2895            output: toml_src.to_owned(),
2896            changed_count: 0,
2897            sections_changed: Vec::new(),
2898        });
2899    }
2900
2901    let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
2902         runs (#3313).\n\
2903         # auto_consolidate_min_window = 6\n";
2904    let output = insert_after_section(toml_src, "agent.focus", comment);
2905
2906    Ok(MigrationResult {
2907        output,
2908        changed_count: 1,
2909        sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
2910    })
2911}
2912
2913// Helper to create a formatted value (used in tests).
2914#[cfg(test)]
2915fn make_formatted_str(s: &str) -> Value {
2916    use toml_edit::Formatted;
2917    Value::String(Formatted::new(s.to_owned()))
2918}
2919
2920#[cfg(test)]
2921mod tests {
2922    use super::*;
2923
2924    #[test]
2925    fn empty_config_gets_sections_as_comments() {
2926        let migrator = ConfigMigrator::new();
2927        let result = migrator.migrate("").expect("migrate empty");
2928        // Should have added sections since reference is non-empty.
2929        assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
2930        // Output should mention at least agent section.
2931        assert!(
2932            result.output.contains("[agent]") || result.output.contains("# [agent]"),
2933            "expected agent section in output, got:\n{}",
2934            result.output
2935        );
2936    }
2937
2938    #[test]
2939    fn existing_values_not_overwritten() {
2940        let user = r#"
2941[agent]
2942name = "MyAgent"
2943max_tool_iterations = 5
2944"#;
2945        let migrator = ConfigMigrator::new();
2946        let result = migrator.migrate(user).expect("migrate");
2947        // Original name preserved.
2948        assert!(
2949            result.output.contains("name = \"MyAgent\""),
2950            "user value should be preserved"
2951        );
2952        assert!(
2953            result.output.contains("max_tool_iterations = 5"),
2954            "user value should be preserved"
2955        );
2956        // Should not appear as commented default.
2957        assert!(
2958            !result.output.contains("# max_tool_iterations = 10"),
2959            "already-set key should not appear as comment"
2960        );
2961    }
2962
2963    #[test]
2964    fn missing_nested_key_added_as_comment() {
2965        // User has [memory] but is missing some keys.
2966        let user = r#"
2967[memory]
2968sqlite_path = ".zeph/data/zeph.db"
2969"#;
2970        let migrator = ConfigMigrator::new();
2971        let result = migrator.migrate(user).expect("migrate");
2972        // history_limit should be added as comment since it's in reference.
2973        assert!(
2974            result.output.contains("# history_limit"),
2975            "missing key should be added as comment, got:\n{}",
2976            result.output
2977        );
2978    }
2979
2980    #[test]
2981    fn unknown_user_keys_preserved() {
2982        let user = r#"
2983[agent]
2984name = "Test"
2985my_custom_key = "preserved"
2986"#;
2987        let migrator = ConfigMigrator::new();
2988        let result = migrator.migrate(user).expect("migrate");
2989        assert!(
2990            result.output.contains("my_custom_key = \"preserved\""),
2991            "custom user keys must not be removed"
2992        );
2993    }
2994
2995    #[test]
2996    fn idempotent() {
2997        let migrator = ConfigMigrator::new();
2998        let first = migrator
2999            .migrate("[agent]\nname = \"Zeph\"\n")
3000            .expect("first migrate");
3001        let second = migrator.migrate(&first.output).expect("second migrate");
3002        assert_eq!(
3003            first.output, second.output,
3004            "idempotent: full output must be identical on second run"
3005        );
3006    }
3007
3008    #[test]
3009    fn malformed_input_returns_error() {
3010        let migrator = ConfigMigrator::new();
3011        let err = migrator
3012            .migrate("[[invalid toml [[[")
3013            .expect_err("should error");
3014        assert!(
3015            matches!(err, MigrateError::Parse(_)),
3016            "expected Parse error"
3017        );
3018    }
3019
3020    #[test]
3021    fn array_of_tables_preserved() {
3022        let user = r#"
3023[mcp]
3024allowed_commands = ["npx"]
3025
3026[[mcp.servers]]
3027id = "my-server"
3028command = "npx"
3029args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3030"#;
3031        let migrator = ConfigMigrator::new();
3032        let result = migrator.migrate(user).expect("migrate");
3033        // User's [[mcp.servers]] entry must survive.
3034        assert!(
3035            result.output.contains("[[mcp.servers]]"),
3036            "array-of-tables entries must be preserved"
3037        );
3038        assert!(result.output.contains("id = \"my-server\""));
3039    }
3040
3041    #[test]
3042    fn canonical_ordering_applied() {
3043        // Put memory before agent intentionally.
3044        let user = r#"
3045[memory]
3046sqlite_path = ".zeph/data/zeph.db"
3047
3048[agent]
3049name = "Test"
3050"#;
3051        let migrator = ConfigMigrator::new();
3052        let result = migrator.migrate(user).expect("migrate");
3053        // agent should appear before memory in canonical order.
3054        let agent_pos = result.output.find("[agent]");
3055        let memory_pos = result.output.find("[memory]");
3056        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3057            assert!(a < m, "agent section should precede memory section");
3058        }
3059    }
3060
3061    #[test]
3062    fn value_to_toml_string_formats_correctly() {
3063        use toml_edit::Formatted;
3064
3065        let s = make_formatted_str("hello");
3066        assert_eq!(value_to_toml_string(&s), "\"hello\"");
3067
3068        let i = Value::Integer(Formatted::new(42_i64));
3069        assert_eq!(value_to_toml_string(&i), "42");
3070
3071        let b = Value::Boolean(Formatted::new(true));
3072        assert_eq!(value_to_toml_string(&b), "true");
3073
3074        let f = Value::Float(Formatted::new(1.0_f64));
3075        assert_eq!(value_to_toml_string(&f), "1.0");
3076
3077        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3078        assert_eq!(value_to_toml_string(&f2), "3.14");
3079
3080        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3081        let arr_val = Value::Array(arr);
3082        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3083
3084        let empty_arr = Value::Array(Array::new());
3085        assert_eq!(value_to_toml_string(&empty_arr), "[]");
3086    }
3087
3088    #[test]
3089    fn idempotent_full_output_unchanged() {
3090        // Stronger idempotency: the entire output string must not change on a second pass.
3091        let migrator = ConfigMigrator::new();
3092        let first = migrator
3093            .migrate("[agent]\nname = \"Zeph\"\n")
3094            .expect("first migrate");
3095        let second = migrator.migrate(&first.output).expect("second migrate");
3096        assert_eq!(
3097            first.output, second.output,
3098            "full output string must be identical after second migration pass"
3099        );
3100    }
3101
3102    #[test]
3103    fn full_config_produces_zero_additions() {
3104        // Migrating the reference config itself should add nothing new.
3105        let reference = include_str!("../config/default.toml");
3106        let migrator = ConfigMigrator::new();
3107        let result = migrator.migrate(reference).expect("migrate reference");
3108        assert_eq!(
3109            result.changed_count, 0,
3110            "migrating the canonical reference should add nothing (changed_count = {})",
3111            result.changed_count
3112        );
3113        assert!(
3114            result.sections_changed.is_empty(),
3115            "migrating the canonical reference should report no sections_changed: {:?}",
3116            result.sections_changed
3117        );
3118    }
3119
3120    #[test]
3121    fn empty_config_changed_count_is_positive() {
3122        // Stricter variant of empty_config_gets_sections_as_comments.
3123        let migrator = ConfigMigrator::new();
3124        let result = migrator.migrate("").expect("migrate empty");
3125        assert!(
3126            result.changed_count > 0,
3127            "empty config must report changed_count > 0"
3128        );
3129    }
3130
3131    // IMPL-04: verify that [security.guardrail] is injected as commented defaults
3132    // for a pre-guardrail config that has [security] but no [security.guardrail].
3133    #[test]
3134    fn security_without_guardrail_gets_guardrail_commented() {
3135        let user = "[security]\nredact_secrets = true\n";
3136        let migrator = ConfigMigrator::new();
3137        let result = migrator.migrate(user).expect("migrate");
3138        // The generic diff mechanism must add guardrail keys as commented defaults.
3139        assert!(
3140            result.output.contains("guardrail"),
3141            "migration must add guardrail keys for configs without [security.guardrail]: \
3142             got:\n{}",
3143            result.output
3144        );
3145    }
3146
3147    #[test]
3148    fn migrate_reference_contains_tools_policy() {
3149        // IMP-NO-MIGRATE-CONFIG: verify that the embedded default.toml (the canonical reference
3150        // used by ConfigMigrator) contains a [tools.policy] section. This ensures that
3151        // `zeph --migrate-config` will surface the section to users as a discoverable commented
3152        // block, even if it cannot be injected as a live sub-table via toml_edit's round-trip.
3153        let reference = include_str!("../config/default.toml");
3154        assert!(
3155            reference.contains("[tools.policy]"),
3156            "default.toml must contain [tools.policy] section so migrate-config can surface it"
3157        );
3158        assert!(
3159            reference.contains("enabled = false"),
3160            "tools.policy section must include enabled = false default"
3161        );
3162    }
3163
3164    #[test]
3165    fn migrate_reference_contains_probe_section() {
3166        // default.toml must contain the probe section comment block so users can discover it
3167        // when reading the file directly or after running --migrate-config.
3168        let reference = include_str!("../config/default.toml");
3169        assert!(
3170            reference.contains("[memory.compression.probe]"),
3171            "default.toml must contain [memory.compression.probe] section comment"
3172        );
3173        assert!(
3174            reference.contains("hard_fail_threshold"),
3175            "probe section must include hard_fail_threshold default"
3176        );
3177    }
3178
3179    // ─── migrate_llm_to_providers ─────────────────────────────────────────────
3180
3181    #[test]
3182    fn migrate_llm_no_llm_section_is_noop() {
3183        let src = "[agent]\nname = \"Zeph\"\n";
3184        let result = migrate_llm_to_providers(src).expect("migrate");
3185        assert_eq!(result.changed_count, 0);
3186        assert_eq!(result.output, src);
3187    }
3188
3189    #[test]
3190    fn migrate_llm_already_new_format_is_noop() {
3191        let src = r#"
3192[llm]
3193[[llm.providers]]
3194type = "ollama"
3195model = "qwen3:8b"
3196"#;
3197        let result = migrate_llm_to_providers(src).expect("migrate");
3198        assert_eq!(result.changed_count, 0);
3199    }
3200
3201    #[test]
3202    fn migrate_llm_ollama_produces_providers_block() {
3203        let src = r#"
3204[llm]
3205provider = "ollama"
3206model = "qwen3:8b"
3207base_url = "http://localhost:11434"
3208embedding_model = "nomic-embed-text"
3209"#;
3210        let result = migrate_llm_to_providers(src).expect("migrate");
3211        assert!(
3212            result.output.contains("[[llm.providers]]"),
3213            "should contain [[llm.providers]]:\n{}",
3214            result.output
3215        );
3216        assert!(
3217            result.output.contains("type = \"ollama\""),
3218            "{}",
3219            result.output
3220        );
3221        assert!(
3222            result.output.contains("model = \"qwen3:8b\""),
3223            "{}",
3224            result.output
3225        );
3226    }
3227
3228    #[test]
3229    fn migrate_llm_claude_produces_providers_block() {
3230        let src = r#"
3231[llm]
3232provider = "claude"
3233
3234[llm.cloud]
3235model = "claude-sonnet-4-6"
3236max_tokens = 8192
3237server_compaction = true
3238"#;
3239        let result = migrate_llm_to_providers(src).expect("migrate");
3240        assert!(
3241            result.output.contains("[[llm.providers]]"),
3242            "{}",
3243            result.output
3244        );
3245        assert!(
3246            result.output.contains("type = \"claude\""),
3247            "{}",
3248            result.output
3249        );
3250        assert!(
3251            result.output.contains("model = \"claude-sonnet-4-6\""),
3252            "{}",
3253            result.output
3254        );
3255        assert!(
3256            result.output.contains("server_compaction = true"),
3257            "{}",
3258            result.output
3259        );
3260    }
3261
3262    #[test]
3263    fn migrate_llm_openai_copies_fields() {
3264        let src = r#"
3265[llm]
3266provider = "openai"
3267
3268[llm.openai]
3269base_url = "https://api.openai.com/v1"
3270model = "gpt-4o"
3271max_tokens = 4096
3272"#;
3273        let result = migrate_llm_to_providers(src).expect("migrate");
3274        assert!(
3275            result.output.contains("type = \"openai\""),
3276            "{}",
3277            result.output
3278        );
3279        assert!(
3280            result
3281                .output
3282                .contains("base_url = \"https://api.openai.com/v1\""),
3283            "{}",
3284            result.output
3285        );
3286    }
3287
3288    #[test]
3289    fn migrate_llm_gemini_copies_fields() {
3290        let src = r#"
3291[llm]
3292provider = "gemini"
3293
3294[llm.gemini]
3295model = "gemini-2.0-flash"
3296max_tokens = 8192
3297base_url = "https://generativelanguage.googleapis.com"
3298"#;
3299        let result = migrate_llm_to_providers(src).expect("migrate");
3300        assert!(
3301            result.output.contains("type = \"gemini\""),
3302            "{}",
3303            result.output
3304        );
3305        assert!(
3306            result.output.contains("model = \"gemini-2.0-flash\""),
3307            "{}",
3308            result.output
3309        );
3310    }
3311
3312    #[test]
3313    fn migrate_llm_compatible_copies_multiple_entries() {
3314        let src = r#"
3315[llm]
3316provider = "compatible"
3317
3318[[llm.compatible]]
3319name = "proxy-a"
3320base_url = "http://proxy-a:8080/v1"
3321model = "llama3"
3322max_tokens = 4096
3323
3324[[llm.compatible]]
3325name = "proxy-b"
3326base_url = "http://proxy-b:8080/v1"
3327model = "mistral"
3328max_tokens = 2048
3329"#;
3330        let result = migrate_llm_to_providers(src).expect("migrate");
3331        // Both compatible entries should be emitted.
3332        let count = result.output.matches("[[llm.providers]]").count();
3333        assert_eq!(
3334            count, 2,
3335            "expected 2 [[llm.providers]] blocks:\n{}",
3336            result.output
3337        );
3338        assert!(
3339            result.output.contains("name = \"proxy-a\""),
3340            "{}",
3341            result.output
3342        );
3343        assert!(
3344            result.output.contains("name = \"proxy-b\""),
3345            "{}",
3346            result.output
3347        );
3348    }
3349
3350    #[test]
3351    fn migrate_llm_mixed_format_errors() {
3352        // Legacy + new format together should produce an error.
3353        let src = r#"
3354[llm]
3355provider = "ollama"
3356
3357[[llm.providers]]
3358type = "ollama"
3359"#;
3360        assert!(
3361            migrate_llm_to_providers(src).is_err(),
3362            "mixed format must return error"
3363        );
3364    }
3365
3366    // ─── migrate_stt_to_provider ──────────────────────────────────────────────
3367
3368    #[test]
3369    fn stt_migration_no_stt_section_returns_unchanged() {
3370        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
3371        let result = migrate_stt_to_provider(src).unwrap();
3372        assert_eq!(result.changed_count, 0);
3373        assert_eq!(result.output, src);
3374    }
3375
3376    #[test]
3377    fn stt_migration_no_model_or_base_url_returns_unchanged() {
3378        let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
3379        let result = migrate_stt_to_provider(src).unwrap();
3380        assert_eq!(result.changed_count, 0);
3381    }
3382
3383    #[test]
3384    fn stt_migration_moves_model_to_provider_entry() {
3385        let src = r#"
3386[llm]
3387
3388[[llm.providers]]
3389type = "openai"
3390name = "quality"
3391model = "gpt-5.4"
3392
3393[llm.stt]
3394provider = "quality"
3395model = "gpt-4o-mini-transcribe"
3396language = "en"
3397"#;
3398        let result = migrate_stt_to_provider(src).unwrap();
3399        assert_eq!(result.changed_count, 1);
3400        // stt_model should appear in providers entry.
3401        assert!(
3402            result.output.contains("stt_model"),
3403            "stt_model must be in output"
3404        );
3405        // model should be removed from [llm.stt].
3406        // The output should parse cleanly.
3407        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3408        let stt = doc
3409            .get("llm")
3410            .and_then(toml_edit::Item::as_table)
3411            .and_then(|l| l.get("stt"))
3412            .and_then(toml_edit::Item::as_table)
3413            .unwrap();
3414        assert!(
3415            stt.get("model").is_none(),
3416            "model must be removed from [llm.stt]"
3417        );
3418        assert_eq!(
3419            stt.get("provider").and_then(toml_edit::Item::as_str),
3420            Some("quality")
3421        );
3422    }
3423
3424    #[test]
3425    fn stt_migration_creates_new_provider_when_no_match() {
3426        let src = r#"
3427[llm]
3428
3429[[llm.providers]]
3430type = "ollama"
3431name = "local"
3432model = "qwen3:8b"
3433
3434[llm.stt]
3435provider = "whisper"
3436model = "whisper-1"
3437base_url = "https://api.openai.com/v1"
3438language = "en"
3439"#;
3440        let result = migrate_stt_to_provider(src).unwrap();
3441        assert!(
3442            result.output.contains("openai-stt"),
3443            "new entry name must be openai-stt"
3444        );
3445        assert!(
3446            result.output.contains("stt_model"),
3447            "stt_model must be in output"
3448        );
3449    }
3450
3451    #[test]
3452    fn stt_migration_candle_whisper_creates_candle_entry() {
3453        let src = r#"
3454[llm]
3455
3456[llm.stt]
3457provider = "candle-whisper"
3458model = "openai/whisper-tiny"
3459language = "auto"
3460"#;
3461        let result = migrate_stt_to_provider(src).unwrap();
3462        assert!(
3463            result.output.contains("local-whisper"),
3464            "candle entry name must be local-whisper"
3465        );
3466        assert!(result.output.contains("candle"), "type must be candle");
3467    }
3468
3469    #[test]
3470    fn stt_migration_w2_assigns_explicit_name() {
3471        // Provider has no explicit name (type = "openai") — migration must assign one.
3472        let src = r#"
3473[llm]
3474
3475[[llm.providers]]
3476type = "openai"
3477model = "gpt-5.4"
3478
3479[llm.stt]
3480provider = "openai"
3481model = "whisper-1"
3482language = "auto"
3483"#;
3484        let result = migrate_stt_to_provider(src).unwrap();
3485        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3486        let providers = doc
3487            .get("llm")
3488            .and_then(toml_edit::Item::as_table)
3489            .and_then(|l| l.get("providers"))
3490            .and_then(toml_edit::Item::as_array_of_tables)
3491            .unwrap();
3492        let entry = providers
3493            .iter()
3494            .find(|t| t.get("stt_model").is_some())
3495            .unwrap();
3496        // Must have an explicit `name` field (W2).
3497        assert!(
3498            entry.get("name").is_some(),
3499            "migrated entry must have explicit name"
3500        );
3501    }
3502
3503    #[test]
3504    fn stt_migration_removes_base_url_from_stt_table() {
3505        // MEDIUM: verify that base_url is stripped from [llm.stt] after migration.
3506        let src = r#"
3507[llm]
3508
3509[[llm.providers]]
3510type = "openai"
3511name = "quality"
3512model = "gpt-5.4"
3513
3514[llm.stt]
3515provider = "quality"
3516model = "whisper-1"
3517base_url = "https://api.openai.com/v1"
3518language = "en"
3519"#;
3520        let result = migrate_stt_to_provider(src).unwrap();
3521        let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3522        let stt = doc
3523            .get("llm")
3524            .and_then(toml_edit::Item::as_table)
3525            .and_then(|l| l.get("stt"))
3526            .and_then(toml_edit::Item::as_table)
3527            .unwrap();
3528        assert!(
3529            stt.get("model").is_none(),
3530            "model must be removed from [llm.stt]"
3531        );
3532        assert!(
3533            stt.get("base_url").is_none(),
3534            "base_url must be removed from [llm.stt]"
3535        );
3536    }
3537
3538    #[test]
3539    fn migrate_planner_model_to_provider_with_field() {
3540        let input = r#"
3541[orchestration]
3542enabled = true
3543planner_model = "gpt-4o"
3544max_tasks = 20
3545"#;
3546        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
3547        assert_eq!(result.changed_count, 1, "changed_count must be 1");
3548        assert!(
3549            !result.output.contains("planner_model = "),
3550            "planner_model key must be removed from output"
3551        );
3552        assert!(
3553            result.output.contains("# planner_provider"),
3554            "commented-out planner_provider entry must be present"
3555        );
3556        assert!(
3557            result.output.contains("gpt-4o"),
3558            "old value must appear in the comment"
3559        );
3560        assert!(
3561            result.output.contains("MIGRATED"),
3562            "comment must include MIGRATED marker"
3563        );
3564    }
3565
3566    #[test]
3567    fn migrate_planner_model_to_provider_no_op() {
3568        let input = r"
3569[orchestration]
3570enabled = true
3571max_tasks = 20
3572";
3573        let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
3574        assert_eq!(
3575            result.changed_count, 0,
3576            "changed_count must be 0 when field is absent"
3577        );
3578        assert_eq!(
3579            result.output, input,
3580            "output must equal input when nothing to migrate"
3581        );
3582    }
3583
3584    #[test]
3585    fn migrate_error_invalid_structure_formats_correctly() {
3586        // HIGH: verify that MigrateError::InvalidStructure exists, matches correctly, and
3587        // produces a human-readable message. The error path is triggered when the [llm] item
3588        // is present but cannot be obtained as a mutable table (defensive guard replacing the
3589        // previous .expect() calls that would have panicked).
3590        let err = MigrateError::InvalidStructure("test sentinel");
3591        assert!(
3592            matches!(err, MigrateError::InvalidStructure(_)),
3593            "variant must match"
3594        );
3595        let msg = err.to_string();
3596        assert!(
3597            msg.contains("invalid TOML structure"),
3598            "error message must mention 'invalid TOML structure', got: {msg}"
3599        );
3600        assert!(
3601            msg.contains("test sentinel"),
3602            "message must include reason: {msg}"
3603        );
3604    }
3605
3606    // ─── migrate_mcp_trust_levels ─────────────────────────────────────────────
3607
3608    #[test]
3609    fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
3610        let src = r#"
3611[mcp]
3612allowed_commands = ["npx"]
3613
3614[[mcp.servers]]
3615id = "srv-a"
3616command = "npx"
3617args = ["-y", "some-mcp"]
3618
3619[[mcp.servers]]
3620id = "srv-b"
3621command = "npx"
3622args = ["-y", "other-mcp"]
3623"#;
3624        let result = migrate_mcp_trust_levels(src).expect("migrate");
3625        assert_eq!(
3626            result.changed_count, 2,
3627            "both entries must get trust_level added"
3628        );
3629        assert!(
3630            result
3631                .sections_changed
3632                .contains(&"mcp.servers.trust_level".to_owned()),
3633            "sections_changed must report mcp.servers.trust_level"
3634        );
3635        // Both entries must now contain trust_level = "trusted"
3636        let occurrences = result.output.matches("trust_level = \"trusted\"").count();
3637        assert_eq!(
3638            occurrences, 2,
3639            "each entry must have trust_level = \"trusted\""
3640        );
3641    }
3642
3643    #[test]
3644    fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
3645        let src = r#"
3646[[mcp.servers]]
3647id = "srv-a"
3648command = "npx"
3649trust_level = "sandboxed"
3650tool_allowlist = ["read_file"]
3651
3652[[mcp.servers]]
3653id = "srv-b"
3654command = "npx"
3655"#;
3656        let result = migrate_mcp_trust_levels(src).expect("migrate");
3657        // Only srv-b has no trust_level, so only 1 entry should be updated
3658        assert_eq!(
3659            result.changed_count, 1,
3660            "only entry without trust_level gets updated"
3661        );
3662        // srv-a's sandboxed value must not be overwritten
3663        assert!(
3664            result.output.contains("trust_level = \"sandboxed\""),
3665            "existing trust_level must not be overwritten"
3666        );
3667        // srv-b gets trusted
3668        assert!(
3669            result.output.contains("trust_level = \"trusted\""),
3670            "entry without trust_level must get trusted"
3671        );
3672    }
3673
3674    #[test]
3675    fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
3676        let src = "[agent]\nname = \"Zeph\"\n";
3677        let result = migrate_mcp_trust_levels(src).expect("migrate");
3678        assert_eq!(result.changed_count, 0);
3679        assert!(result.sections_changed.is_empty());
3680        assert_eq!(result.output, src);
3681    }
3682
3683    #[test]
3684    fn migrate_mcp_trust_levels_no_servers_is_noop() {
3685        let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
3686        let result = migrate_mcp_trust_levels(src).expect("migrate");
3687        assert_eq!(result.changed_count, 0);
3688        assert!(result.sections_changed.is_empty());
3689        assert_eq!(result.output, src);
3690    }
3691
3692    #[test]
3693    fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
3694        let src = r#"
3695[[mcp.servers]]
3696id = "srv-a"
3697trust_level = "trusted"
3698
3699[[mcp.servers]]
3700id = "srv-b"
3701trust_level = "untrusted"
3702"#;
3703        let result = migrate_mcp_trust_levels(src).expect("migrate");
3704        assert_eq!(result.changed_count, 0);
3705        assert!(result.sections_changed.is_empty());
3706    }
3707
3708    #[test]
3709    fn migrate_database_url_adds_comment_when_absent() {
3710        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
3711        let result = migrate_database_url(src).expect("migrate");
3712        assert_eq!(result.changed_count, 1);
3713        assert!(
3714            result
3715                .sections_changed
3716                .contains(&"memory.database_url".to_owned())
3717        );
3718        assert!(result.output.contains("# database_url = \"\""));
3719    }
3720
3721    #[test]
3722    fn migrate_database_url_is_noop_when_present() {
3723        let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
3724        let result = migrate_database_url(src).expect("migrate");
3725        assert_eq!(result.changed_count, 0);
3726        assert!(result.sections_changed.is_empty());
3727        assert_eq!(result.output, src);
3728    }
3729
3730    #[test]
3731    fn migrate_database_url_creates_memory_section_when_absent() {
3732        let src = "[agent]\nname = \"Zeph\"\n";
3733        let result = migrate_database_url(src).expect("migrate");
3734        assert_eq!(result.changed_count, 1);
3735        assert!(result.output.contains("# database_url = \"\""));
3736    }
3737
3738    // ── migrate_agent_budget_hint tests (#2267) ───────────────────────────────
3739
3740    #[test]
3741    fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
3742        let src = "[agent]\nname = \"Zeph\"\n";
3743        let result = migrate_agent_budget_hint(src).expect("migrate");
3744        assert_eq!(result.changed_count, 1);
3745        assert!(result.output.contains("budget_hint_enabled"));
3746        assert!(
3747            result
3748                .sections_changed
3749                .contains(&"agent.budget_hint_enabled".to_owned())
3750        );
3751    }
3752
3753    #[test]
3754    fn migrate_agent_budget_hint_no_agent_section_is_noop() {
3755        let src = "[llm]\nmodel = \"gpt-4o\"\n";
3756        let result = migrate_agent_budget_hint(src).expect("migrate");
3757        assert_eq!(result.changed_count, 0);
3758        assert_eq!(result.output, src);
3759    }
3760
3761    #[test]
3762    fn migrate_agent_budget_hint_already_present_is_noop() {
3763        let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
3764        let result = migrate_agent_budget_hint(src).expect("migrate");
3765        assert_eq!(result.changed_count, 0);
3766        assert_eq!(result.output, src);
3767    }
3768
3769    #[test]
3770    fn migrate_telemetry_config_empty_config_appends_comment_block() {
3771        let src = "[agent]\nname = \"Zeph\"\n";
3772        let result = migrate_telemetry_config(src).expect("migrate");
3773        assert_eq!(result.changed_count, 1);
3774        assert_eq!(result.sections_changed, vec!["telemetry"]);
3775        assert!(
3776            result.output.contains("# [telemetry]"),
3777            "expected commented-out [telemetry] block in output"
3778        );
3779        assert!(
3780            result.output.contains("enabled = false"),
3781            "expected enabled = false in telemetry comment block"
3782        );
3783    }
3784
3785    #[test]
3786    fn migrate_telemetry_config_existing_section_is_noop() {
3787        let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
3788        let result = migrate_telemetry_config(src).expect("migrate");
3789        assert_eq!(result.changed_count, 0);
3790        assert_eq!(result.output, src);
3791    }
3792
3793    #[test]
3794    fn migrate_telemetry_config_existing_comment_is_noop() {
3795        // Idempotency: if the comment block was already added, don't append again.
3796        let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
3797        let result = migrate_telemetry_config(src).expect("migrate");
3798        assert_eq!(result.changed_count, 0);
3799        assert_eq!(result.output, src);
3800    }
3801
3802    // ── migrate_otel_filter tests (#2997) ─────────────────────────────────────
3803
3804    #[test]
3805    fn migrate_otel_filter_already_present_is_noop() {
3806        // Real key present — must not modify.
3807        let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
3808        let result = migrate_otel_filter(src).expect("migrate");
3809        assert_eq!(result.changed_count, 0);
3810        assert_eq!(result.output, src);
3811    }
3812
3813    #[test]
3814    fn migrate_otel_filter_commented_key_is_noop() {
3815        // Commented-out key already present — idempotent.
3816        let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
3817        let result = migrate_otel_filter(src).expect("migrate");
3818        assert_eq!(result.changed_count, 0);
3819        assert_eq!(result.output, src);
3820    }
3821
3822    #[test]
3823    fn migrate_otel_filter_no_telemetry_section_is_noop() {
3824        // [telemetry] absent — must not inject into wrong location.
3825        let src = "[agent]\nname = \"Zeph\"\n";
3826        let result = migrate_otel_filter(src).expect("migrate");
3827        assert_eq!(result.changed_count, 0);
3828        assert_eq!(result.output, src);
3829        assert!(!result.output.contains("otel_filter"));
3830    }
3831
3832    #[test]
3833    fn migrate_otel_filter_injects_within_telemetry_section() {
3834        let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
3835        let result = migrate_otel_filter(src).expect("migrate");
3836        assert_eq!(result.changed_count, 1);
3837        assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
3838        assert!(
3839            result.output.contains("otel_filter"),
3840            "otel_filter comment must appear"
3841        );
3842        // Comment must appear before [agent] — i.e., within the telemetry section.
3843        let otel_pos = result
3844            .output
3845            .find("otel_filter")
3846            .expect("otel_filter present");
3847        let agent_pos = result.output.find("[agent]").expect("[agent] present");
3848        assert!(
3849            otel_pos < agent_pos,
3850            "otel_filter comment should appear before [agent] section"
3851        );
3852    }
3853
3854    #[test]
3855    fn sandbox_migration_adds_commented_section_when_absent() {
3856        let src = "[agent]\nname = \"Z\"\n";
3857        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3858        assert_eq!(result.changed_count, 1);
3859        assert!(result.output.contains("# [tools.sandbox]"));
3860        assert!(result.output.contains("# profile = \"workspace\""));
3861    }
3862
3863    #[test]
3864    fn sandbox_migration_noop_when_section_present() {
3865        let src = "[tools.sandbox]\nenabled = true\n";
3866        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3867        assert_eq!(result.changed_count, 0);
3868    }
3869
3870    #[test]
3871    fn sandbox_migration_noop_when_dotted_key_present() {
3872        let src = "[tools]\nsandbox = { enabled = true }\n";
3873        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3874        assert_eq!(result.changed_count, 0);
3875    }
3876
3877    #[test]
3878    fn sandbox_migration_false_positive_comment_does_not_block() {
3879        // Comments mentioning tools.sandbox must NOT suppress insertion.
3880        let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
3881        let result = migrate_sandbox_config(src).expect("migrate sandbox");
3882        assert_eq!(result.changed_count, 1);
3883    }
3884
3885    #[test]
3886    fn embedded_default_mentions_tools_sandbox() {
3887        let default_src = include_str!("../config/default.toml");
3888        assert!(
3889            default_src.contains("tools.sandbox"),
3890            "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
3891        );
3892    }
3893
3894    #[test]
3895    fn sandbox_migration_idempotent_on_own_output() {
3896        let base = "[agent]\nmodel = \"test\"\n";
3897        let first = migrate_sandbox_config(base).unwrap();
3898        assert_eq!(first.changed_count, 1);
3899        let second = migrate_sandbox_config(&first.output).unwrap();
3900        assert_eq!(second.changed_count, 0, "second run must not double-append");
3901        assert_eq!(second.output, first.output);
3902    }
3903
3904    #[test]
3905    fn migrate_agent_budget_hint_idempotent_on_commented_output() {
3906        let base = "[agent]\nname = \"Zeph\"\n";
3907        let first = migrate_agent_budget_hint(base).unwrap();
3908        assert_eq!(first.changed_count, 1);
3909        let second = migrate_agent_budget_hint(&first.output).unwrap();
3910        assert_eq!(second.changed_count, 0, "second run must not double-append");
3911        assert_eq!(second.output, first.output);
3912    }
3913
3914    #[test]
3915    fn migrate_forgetting_config_idempotent_on_commented_output() {
3916        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3917        let first = migrate_forgetting_config(base).unwrap();
3918        assert_eq!(first.changed_count, 1);
3919        let second = migrate_forgetting_config(&first.output).unwrap();
3920        assert_eq!(second.changed_count, 0, "second run must not double-append");
3921        assert_eq!(second.output, first.output);
3922    }
3923
3924    #[test]
3925    fn migrate_microcompact_config_idempotent_on_commented_output() {
3926        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3927        let first = migrate_microcompact_config(base).unwrap();
3928        assert_eq!(first.changed_count, 1);
3929        let second = migrate_microcompact_config(&first.output).unwrap();
3930        assert_eq!(second.changed_count, 0, "second run must not double-append");
3931        assert_eq!(second.output, first.output);
3932    }
3933
3934    #[test]
3935    fn migrate_autodream_config_idempotent_on_commented_output() {
3936        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3937        let first = migrate_autodream_config(base).unwrap();
3938        assert_eq!(first.changed_count, 1);
3939        let second = migrate_autodream_config(&first.output).unwrap();
3940        assert_eq!(second.changed_count, 0, "second run must not double-append");
3941        assert_eq!(second.output, first.output);
3942    }
3943
3944    #[test]
3945    fn migrate_compression_predictor_strips_active_section() {
3946        let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
3947        let result = migrate_compression_predictor_config(base).unwrap();
3948        assert!(!result.output.contains("[memory.compression.predictor]"));
3949        assert!(!result.output.contains("min_samples"));
3950        assert!(result.output.contains("[memory.other]"));
3951        assert_eq!(result.changed_count, 1);
3952    }
3953
3954    #[test]
3955    fn migrate_compression_predictor_strips_commented_section() {
3956        let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
3957        let result = migrate_compression_predictor_config(base).unwrap();
3958        assert!(!result.output.contains("compression.predictor"));
3959        assert!(result.output.contains("[memory.other]"));
3960    }
3961
3962    #[test]
3963    fn migrate_compression_predictor_idempotent() {
3964        let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
3965        let first = migrate_compression_predictor_config(base).unwrap();
3966        let second = migrate_compression_predictor_config(&first.output).unwrap();
3967        assert_eq!(second.output, first.output);
3968        assert_eq!(second.changed_count, 0);
3969    }
3970
3971    #[test]
3972    fn migrate_compression_predictor_noop_when_absent() {
3973        let base = "[memory]\ndb_path = \"test\"\n";
3974        let result = migrate_compression_predictor_config(base).unwrap();
3975        assert_eq!(result.output, base);
3976        assert_eq!(result.changed_count, 0);
3977    }
3978
3979    #[test]
3980    fn migrate_database_url_idempotent_on_commented_output() {
3981        let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3982        let first = migrate_database_url(base).unwrap();
3983        assert_eq!(first.changed_count, 1);
3984        let second = migrate_database_url(&first.output).unwrap();
3985        assert_eq!(second.changed_count, 0, "second run must not double-append");
3986        assert_eq!(second.output, first.output);
3987    }
3988
3989    #[test]
3990    fn migrate_shell_transactional_idempotent_on_commented_output() {
3991        let base = "[tools]\n[tools.shell]\nallow_list = []\n";
3992        let first = migrate_shell_transactional(base).unwrap();
3993        assert_eq!(first.changed_count, 1);
3994        let second = migrate_shell_transactional(&first.output).unwrap();
3995        assert_eq!(second.changed_count, 0, "second run must not double-append");
3996        assert_eq!(second.output, first.output);
3997    }
3998
3999    #[test]
4000    fn migrate_otel_filter_idempotent_on_commented_output() {
4001        let base = "[telemetry]\nenabled = true\n";
4002        let first = migrate_otel_filter(base).unwrap();
4003        assert_eq!(first.changed_count, 1);
4004        let second = migrate_otel_filter(&first.output).unwrap();
4005        assert_eq!(second.changed_count, 0, "second run must not double-append");
4006        assert_eq!(second.output, first.output);
4007    }
4008
4009    #[test]
4010    fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4011        let migrator = ConfigMigrator::new();
4012        let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4013        let result = migrator.migrate(src).expect("migrate");
4014        let sec_body_start = result
4015            .output
4016            .find("[security.content_isolation]")
4017            .unwrap_or(0);
4018        let sec_body = &result.output[sec_body_start..];
4019        let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4020        let sec_slice = &sec_body[..next_header];
4021        assert!(
4022            sec_slice.contains("# enabled"),
4023            "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4024        );
4025    }
4026
4027    #[test]
4028    fn config_migrator_idempotent_on_realistic_config() {
4029        let base = r#"
4030[agent]
4031name = "Zeph"
4032
4033[memory]
4034db_path = "~/.zeph/memory.db"
4035soft_compaction_threshold = 0.6
4036
4037[index]
4038max_chunks = 12
4039
4040[tools]
4041[tools.shell]
4042allow_list = []
4043
4044[telemetry]
4045enabled = false
4046
4047[security]
4048[security.content_isolation]
4049enabled = true
4050"#;
4051        let migrator = ConfigMigrator::new();
4052        let first = migrator.migrate(base).expect("first migrate");
4053        let second = migrator.migrate(&first.output).expect("second migrate");
4054        assert_eq!(
4055            second.changed_count, 0,
4056            "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4057            second.changed_count
4058        );
4059        assert_eq!(
4060            first.output, second.output,
4061            "output must be identical on second run"
4062        );
4063        for line in first.output.lines() {
4064            if line.starts_with('[') && !line.starts_with("[[") {
4065                assert!(
4066                    !line.contains('#'),
4067                    "section header must not have inline comment: {line:?}"
4068                );
4069            }
4070        }
4071    }
4072
4073    #[test]
4074    fn migrate_claude_prompt_cache_ttl_1h_survives() {
4075        let src = r#"
4076[llm]
4077provider = "claude"
4078
4079[llm.cloud]
4080model = "claude-sonnet-4-6"
4081prompt_cache_ttl = "1h"
4082"#;
4083        let result = migrate_llm_to_providers(src).expect("migrate");
4084        assert!(
4085            result.output.contains("prompt_cache_ttl = \"1h\""),
4086            "1h TTL must be preserved in migrated output:\n{}",
4087            result.output
4088        );
4089    }
4090
4091    #[test]
4092    fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4093        let src = r#"
4094[llm]
4095provider = "claude"
4096
4097[llm.cloud]
4098model = "claude-sonnet-4-6"
4099prompt_cache_ttl = "ephemeral"
4100"#;
4101        let result = migrate_llm_to_providers(src).expect("migrate");
4102        assert!(
4103            !result.output.contains("prompt_cache_ttl"),
4104            "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4105            result.output
4106        );
4107    }
4108
4109    #[test]
4110    fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4111        let src = r#"
4112[[llm.providers]]
4113type = "claude"
4114model = "claude-sonnet-4-6"
4115prompt_cache_ttl = "1h"
4116"#;
4117        let migrator = ConfigMigrator::new();
4118        let first = migrator.migrate(src).expect("first migrate");
4119        let second = migrator.migrate(&first.output).expect("second migrate");
4120        assert_eq!(
4121            first.output, second.output,
4122            "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4123        );
4124    }
4125
4126    // ── migrate_session_recap_config ──────────────────────────────────────────
4127
4128    #[test]
4129    fn migrate_session_recap_adds_block_when_absent() {
4130        let src = "[agent]\nname = \"Zeph\"\n";
4131        let result = migrate_session_recap_config(src).expect("migrate");
4132        assert_eq!(result.changed_count, 1);
4133        assert!(
4134            result
4135                .sections_changed
4136                .contains(&"session.recap".to_owned())
4137        );
4138        assert!(result.output.contains("# [session.recap]"));
4139        assert!(result.output.contains("on_resume = true"));
4140    }
4141
4142    #[test]
4143    fn migrate_session_recap_idempotent_on_commented_block() {
4144        let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4145        let result = migrate_session_recap_config(src).expect("migrate");
4146        assert_eq!(result.changed_count, 0);
4147        assert_eq!(result.output, src);
4148    }
4149
4150    #[test]
4151    fn migrate_session_recap_idempotent_on_active_section() {
4152        let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4153        let result = migrate_session_recap_config(src).expect("migrate");
4154        assert_eq!(result.changed_count, 0);
4155        assert_eq!(result.output, src);
4156    }
4157
4158    // ── migrate_mcp_elicitation_config ────────────────────────────────────────
4159
4160    #[test]
4161    fn migrate_mcp_elicitation_adds_keys_when_absent() {
4162        let src = "[mcp]\nallowed_commands = []\n";
4163        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4164        assert_eq!(result.changed_count, 1);
4165        assert!(
4166            result
4167                .sections_changed
4168                .contains(&"mcp.elicitation".to_owned())
4169        );
4170        assert!(result.output.contains("# elicitation_enabled = false"));
4171        assert!(result.output.contains("# elicitation_timeout = 120"));
4172    }
4173
4174    #[test]
4175    fn migrate_mcp_elicitation_idempotent_when_key_present() {
4176        let src = "[mcp]\nelicitation_enabled = true\n";
4177        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4178        assert_eq!(result.changed_count, 0);
4179        assert_eq!(result.output, src);
4180    }
4181
4182    #[test]
4183    fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4184        let src = "[agent]\nname = \"Zeph\"\n";
4185        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4186        assert_eq!(result.changed_count, 0);
4187        assert_eq!(result.output, src);
4188    }
4189
4190    #[test]
4191    fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4192        // Edge case: `[mcp]` at EOF with no `\n` — replacen would be a no-op.
4193        let src = "[mcp]";
4194        let result = migrate_mcp_elicitation_config(src).expect("migrate");
4195        assert_eq!(result.changed_count, 0);
4196        assert_eq!(result.output, src);
4197    }
4198
4199    // ── migrate_quality_config ────────────────────────────────────────────────
4200
4201    #[test]
4202    fn migrate_quality_adds_block_when_absent() {
4203        let src = "[agent]\nname = \"Zeph\"\n";
4204        let result = migrate_quality_config(src).expect("migrate");
4205        assert_eq!(result.changed_count, 1);
4206        assert!(result.sections_changed.contains(&"quality".to_owned()));
4207        assert!(result.output.contains("# [quality]"));
4208        assert!(result.output.contains("self_check = false"));
4209        assert!(result.output.contains("trigger = \"has_retrieval\""));
4210    }
4211
4212    #[test]
4213    fn migrate_quality_idempotent_on_commented_block() {
4214        let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4215        let result = migrate_quality_config(src).expect("migrate");
4216        assert_eq!(result.changed_count, 0);
4217        assert_eq!(result.output, src);
4218    }
4219
4220    #[test]
4221    fn migrate_quality_idempotent_on_active_section() {
4222        let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4223        let result = migrate_quality_config(src).expect("migrate");
4224        assert_eq!(result.changed_count, 0);
4225        assert_eq!(result.output, src);
4226    }
4227
4228    // ── migrate_acp_subagents_config ─────────────────────────────────────────
4229
4230    #[test]
4231    fn migrate_acp_subagents_adds_block_when_absent() {
4232        let src = "[agent]\nname = \"Zeph\"\n";
4233        let result = migrate_acp_subagents_config(src).expect("migrate");
4234        assert_eq!(result.changed_count, 1);
4235        assert!(
4236            result
4237                .sections_changed
4238                .contains(&"acp.subagents".to_owned())
4239        );
4240        assert!(result.output.contains("# [acp.subagents]"));
4241        assert!(result.output.contains("enabled = false"));
4242    }
4243
4244    #[test]
4245    fn migrate_acp_subagents_idempotent_on_existing_block() {
4246        let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4247        let result = migrate_acp_subagents_config(src).expect("migrate");
4248        assert_eq!(result.changed_count, 0);
4249        assert_eq!(result.output, src);
4250    }
4251
4252    // ── migrate_hooks_permission_denied_config ────────────────────────────────
4253
4254    #[test]
4255    fn migrate_hooks_permission_denied_adds_block_when_absent() {
4256        let src = "[agent]\nname = \"Zeph\"\n";
4257        let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4258        assert_eq!(result.changed_count, 1);
4259        assert!(
4260            result
4261                .sections_changed
4262                .contains(&"hooks.permission_denied".to_owned())
4263        );
4264        assert!(result.output.contains("# [[hooks.permission_denied]]"));
4265        assert!(result.output.contains("ZEPH_TOOL"));
4266    }
4267
4268    #[test]
4269    fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4270        let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4271        let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4272        assert_eq!(result.changed_count, 0);
4273        assert_eq!(result.output, src);
4274    }
4275
4276    // ── migrate_memory_graph_config ───────────────────────────────────────────
4277
4278    #[test]
4279    fn migrate_memory_graph_adds_block_when_absent() {
4280        let src = "[agent]\nname = \"Zeph\"\n";
4281        let result = migrate_memory_graph_config(src).expect("migrate");
4282        assert_eq!(result.changed_count, 1);
4283        assert!(
4284            result
4285                .sections_changed
4286                .contains(&"memory.graph.retrieval".to_owned())
4287        );
4288        assert!(result.output.contains("retrieval_strategy"));
4289        assert!(result.output.contains("# [memory.graph.beam_search]"));
4290    }
4291
4292    #[test]
4293    fn migrate_memory_graph_idempotent_on_existing_block() {
4294        let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4295        let result = migrate_memory_graph_config(src).expect("migrate");
4296        assert_eq!(result.changed_count, 0);
4297        assert_eq!(result.output, src);
4298    }
4299
4300    // ── migrate_scheduler_daemon_config ──────────────────────────────────────
4301
4302    #[test]
4303    fn migrate_scheduler_daemon_adds_block_when_absent() {
4304        let src = "[agent]\nname = \"Zeph\"\n";
4305        let result = migrate_scheduler_daemon_config(src).expect("migrate");
4306        assert_eq!(result.changed_count, 1);
4307        assert!(
4308            result
4309                .sections_changed
4310                .contains(&"scheduler.daemon".to_owned())
4311        );
4312        assert!(result.output.contains("# [scheduler.daemon]"));
4313        assert!(result.output.contains("pid_file"));
4314        assert!(result.output.contains("tick_secs = 60"));
4315        assert!(result.output.contains("shutdown_grace_secs = 30"));
4316        assert!(result.output.contains("catch_up = true"));
4317    }
4318
4319    #[test]
4320    fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4321        let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4322        let result = migrate_scheduler_daemon_config(src).expect("migrate");
4323        assert_eq!(result.changed_count, 0);
4324        assert_eq!(result.output, src);
4325    }
4326
4327    // ── migrate_memory_retrieval_config ──────────────────────────────────────
4328
4329    #[test]
4330    fn migrate_memory_retrieval_adds_block_when_absent() {
4331        let src = "[agent]\nname = \"Zeph\"\n";
4332        let result = migrate_memory_retrieval_config(src).expect("migrate");
4333        assert_eq!(result.changed_count, 1);
4334        assert!(
4335            result
4336                .sections_changed
4337                .contains(&"memory.retrieval".to_owned())
4338        );
4339        assert!(result.output.contains("# [memory.retrieval]"));
4340        assert!(result.output.contains("depth = 0"));
4341        assert!(result.output.contains("context_format"));
4342    }
4343
4344    #[test]
4345    fn migrate_memory_retrieval_idempotent_on_active_section() {
4346        let src = "[memory.retrieval]\ndepth = 40\n";
4347        let result = migrate_memory_retrieval_config(src).expect("migrate");
4348        assert_eq!(result.changed_count, 0);
4349        assert_eq!(result.output, src);
4350    }
4351
4352    #[test]
4353    fn migrate_memory_retrieval_idempotent_on_commented_section() {
4354        let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
4355        let result = migrate_memory_retrieval_config(src).expect("migrate");
4356        assert_eq!(result.changed_count, 0);
4357        assert_eq!(result.output, src);
4358    }
4359
4360    // ── acp PR4 migration ─────────────────────────────────────────────────────
4361
4362    #[test]
4363    fn migrate_adds_pr4_acp_keys_commented() {
4364        let migrator = ConfigMigrator::new();
4365        let input = include_str!("../tests/fixtures/acp_pr4_v0_19.toml");
4366        let out = migrator.migrate(input).expect("migrate");
4367        assert!(
4368            out.output.contains("# additional_directories = []"),
4369            "expected commented additional_directories; got:\n{}",
4370            out.output
4371        );
4372        assert!(
4373            out.output.contains("# auth_methods = [\"agent\"]"),
4374            "expected commented auth_methods; got:\n{}",
4375            out.output
4376        );
4377        assert!(
4378            out.output.contains("# message_ids_enabled = true"),
4379            "expected commented message_ids_enabled; got:\n{}",
4380            out.output
4381        );
4382    }
4383
4384    // ── migrate_memory_reasoning_config ──────────────────────────────────────
4385
4386    #[test]
4387    fn migrate_memory_reasoning_adds_block_when_absent() {
4388        let input = "[agent]\nmodel = \"gpt-4o\"\n";
4389        let result = migrate_memory_reasoning_config(input).unwrap();
4390        assert_eq!(result.changed_count, 1);
4391        assert!(
4392            result
4393                .sections_changed
4394                .contains(&"memory.reasoning".to_owned())
4395        );
4396        assert!(result.output.contains("# [memory.reasoning]"));
4397        assert!(result.output.contains("extraction_timeout_secs = 30"));
4398        assert!(result.output.contains("max_message_chars = 2000"));
4399    }
4400
4401    #[test]
4402    fn migrate_memory_reasoning_idempotent_on_existing_block() {
4403        let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
4404        let result = migrate_memory_reasoning_config(input).unwrap();
4405        assert_eq!(result.changed_count, 0);
4406        assert!(result.sections_changed.is_empty());
4407        assert_eq!(result.output, input);
4408    }
4409
4410    // ── migrate_hooks_turn_complete_config ────────────────────────────────────
4411
4412    #[test]
4413    fn migrate_hooks_turn_complete_adds_block_when_absent() {
4414        let input = "[agent]\nmodel = \"gpt-4o\"\n";
4415        let result = migrate_hooks_turn_complete_config(input).unwrap();
4416        assert_eq!(result.changed_count, 1);
4417        assert!(
4418            result
4419                .sections_changed
4420                .contains(&"hooks.turn_complete".to_owned())
4421        );
4422        assert!(result.output.contains("# [[hooks.turn_complete]]"));
4423        assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
4424        assert!(result.output.contains("timeout_secs = 3"));
4425    }
4426
4427    #[test]
4428    fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
4429        let input =
4430            "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
4431        let result = migrate_hooks_turn_complete_config(input).unwrap();
4432        assert_eq!(result.changed_count, 0);
4433        assert!(result.sections_changed.is_empty());
4434        assert_eq!(result.output, input);
4435    }
4436
4437    // ── migrate_focus_auto_consolidate_min_window ──────────────────────────────
4438
4439    /// S5: the comment must land inside [agent.focus], not after a subsequent section.
4440    #[test]
4441    fn migrate_focus_auto_consolidate_injects_inside_section() {
4442        let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
4443        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4444        assert_eq!(result.changed_count, 1);
4445        let comment_pos = result
4446            .output
4447            .find("auto_consolidate_min_window")
4448            .expect("comment must be present");
4449        let other_pos = result
4450            .output
4451            .find("[other]")
4452            .expect("[other] must be present");
4453        assert!(
4454            comment_pos < other_pos,
4455            "auto_consolidate_min_window comment must appear before [other] section"
4456        );
4457    }
4458
4459    #[test]
4460    fn migrate_focus_auto_consolidate_idempotent() {
4461        let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
4462        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4463        assert_eq!(result.changed_count, 0);
4464        assert_eq!(result.output, input);
4465    }
4466
4467    #[test]
4468    fn migrate_focus_auto_consolidate_noop_when_section_absent() {
4469        let input = "[agent]\nname = \"zeph\"\n";
4470        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4471        assert_eq!(result.changed_count, 0);
4472        assert_eq!(result.output, input);
4473    }
4474
4475    #[test]
4476    fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
4477        let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
4478        let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4479        assert_eq!(result.changed_count, 0);
4480        assert_eq!(result.output, input);
4481    }
4482}