Skip to main content

zeph_config/migrate/
mod.rs

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