Skip to main content

zeph_config/migrate/
mod.rs

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