Skip to main content

zeph_core/config/
migrate.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Config migration: add missing parameters from the canonical reference as commented-out entries.
5//!
6//! The canonical reference is the checked-in `config/default.toml` file embedded at compile time.
7//! Missing sections and keys are added as `# key = default_value` comments so users can discover
8//! and enable them without hunting through documentation.
9
10use toml_edit::{Array, DocumentMut, Item, RawString, 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    "daemon",
28    "scheduler",
29    "orchestration",
30    "security",
31    "vault",
32    "timeouts",
33    "cost",
34    "observability",
35    "debug",
36    "logging",
37    "tui",
38    "agents",
39    "experiments",
40    "lsp",
41];
42
43/// Error type for migration failures.
44#[derive(Debug, thiserror::Error)]
45pub enum MigrateError {
46    /// Failed to parse the user's config.
47    #[error("failed to parse input config: {0}")]
48    Parse(#[from] toml_edit::TomlError),
49    /// Failed to parse the embedded reference config (should never happen in practice).
50    #[error("failed to parse reference config: {0}")]
51    Reference(toml_edit::TomlError),
52}
53
54/// Result of a migration operation.
55#[derive(Debug)]
56pub struct MigrationResult {
57    /// The migrated TOML document as a string.
58    pub output: String,
59    /// Number of top-level keys or sub-keys added as comments.
60    pub added_count: usize,
61    /// Names of top-level sections that were added.
62    pub sections_added: Vec<String>,
63}
64
65/// Migrates a user config by adding missing parameters as commented-out entries.
66///
67/// The canonical reference is embedded from `config/default.toml` at compile time.
68/// User values are never modified; only missing keys are appended as comments.
69pub struct ConfigMigrator {
70    reference_src: &'static str,
71}
72
73impl Default for ConfigMigrator {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl ConfigMigrator {
80    /// Create a new migrator using the embedded canonical reference config.
81    #[must_use]
82    pub fn new() -> Self {
83        Self {
84            reference_src: include_str!("../../config/default.toml"),
85        }
86    }
87
88    /// Migrate `user_toml`: add missing parameters from the reference as commented-out entries.
89    ///
90    /// # Errors
91    ///
92    /// Returns `MigrateError::Parse` if the user's TOML is invalid.
93    /// Returns `MigrateError::Reference` if the embedded reference TOML cannot be parsed.
94    ///
95    /// # Panics
96    ///
97    /// Never panics in practice; `.expect("checked")` is unreachable because `is_table()` is
98    /// verified on the same `ref_item` immediately before calling `as_table()`.
99    pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
100        let reference_doc = self
101            .reference_src
102            .parse::<DocumentMut>()
103            .map_err(MigrateError::Reference)?;
104        let mut user_doc = user_toml.parse::<DocumentMut>()?;
105
106        let mut added_count = 0usize;
107        let mut sections_added: Vec<String> = Vec::new();
108
109        // Walk the reference top-level keys.
110        for (key, ref_item) in reference_doc.as_table() {
111            if ref_item.is_table() {
112                let ref_table = ref_item.as_table().expect("is_table checked above");
113                if user_doc.contains_key(key) {
114                    // Section exists — merge missing sub-keys.
115                    if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
116                        added_count += merge_table_commented(user_table, ref_table, key);
117                    }
118                } else {
119                    // Entire section is missing — record for textual append after rendering.
120                    // Idempotency: skip if a commented block for this section was already appended.
121                    if user_toml.contains(&format!("# [{key}]")) {
122                        continue;
123                    }
124                    let commented = commented_table_block(key, ref_table);
125                    if !commented.is_empty() {
126                        sections_added.push(key.to_owned());
127                    }
128                    added_count += 1;
129                }
130            } else {
131                // Top-level scalar/array key.
132                if !user_doc.contains_key(key) {
133                    let raw = format_commented_item(key, ref_item);
134                    if !raw.is_empty() {
135                        sections_added.push(format!("__scalar__{key}"));
136                        added_count += 1;
137                    }
138                }
139            }
140        }
141
142        // Render the user doc as-is first.
143        let user_str = user_doc.to_string();
144
145        // Append missing sections as raw commented text at the end.
146        let mut output = user_str;
147        for key in &sections_added {
148            if let Some(scalar_key) = key.strip_prefix("__scalar__") {
149                if let Some(ref_item) = reference_doc.get(scalar_key) {
150                    let raw = format_commented_item(scalar_key, ref_item);
151                    if !raw.is_empty() {
152                        output.push('\n');
153                        output.push_str(&raw);
154                        output.push('\n');
155                    }
156                }
157            } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
158            {
159                let block = commented_table_block(key, ref_table);
160                if !block.is_empty() {
161                    output.push('\n');
162                    output.push_str(&block);
163                }
164            }
165        }
166
167        // Reorder top-level sections by canonical order.
168        output = reorder_sections(&output, CANONICAL_ORDER);
169
170        // Resolve sections_added to only real section names (not scalars).
171        let sections_added_clean: Vec<String> = sections_added
172            .into_iter()
173            .filter(|k| !k.starts_with("__scalar__"))
174            .collect();
175
176        Ok(MigrationResult {
177            output,
178            added_count,
179            sections_added: sections_added_clean,
180        })
181    }
182}
183
184/// Merge missing keys from `ref_table` into `user_table` as commented-out entries.
185///
186/// Returns the number of keys added.
187fn merge_table_commented(user_table: &mut Table, ref_table: &Table, section_key: &str) -> usize {
188    let mut count = 0usize;
189    for (key, ref_item) in ref_table {
190        if ref_item.is_table() {
191            if user_table.contains_key(key) {
192                let pair = (
193                    user_table.get_mut(key).and_then(Item::as_table_mut),
194                    ref_item.as_table(),
195                );
196                if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
197                    let sub_key = format!("{section_key}.{key}");
198                    count += merge_table_commented(user_sub_table, ref_sub_table, &sub_key);
199                }
200            } else if let Some(ref_sub_table) = ref_item.as_table() {
201                // Sub-table missing from user config — append as commented block.
202                let dotted = format!("{section_key}.{key}");
203                let marker = format!("# [{dotted}]");
204                let existing = user_table
205                    .decor()
206                    .suffix()
207                    .and_then(RawString::as_str)
208                    .unwrap_or("");
209                if !existing.contains(&marker) {
210                    let block = commented_table_block(&dotted, ref_sub_table);
211                    if !block.is_empty() {
212                        let new_suffix = format!("{existing}\n{block}");
213                        user_table.decor_mut().set_suffix(new_suffix);
214                        count += 1;
215                    }
216                }
217            }
218        } else if ref_item.is_array_of_tables() {
219            // Never inject array-of-tables entries — they are user-defined.
220        } else {
221            // Scalar/array value — check if already present (as value or as comment).
222            if !user_table.contains_key(key) {
223                let raw_value = ref_item
224                    .as_value()
225                    .map(value_to_toml_string)
226                    .unwrap_or_default();
227                if !raw_value.is_empty() {
228                    let comment_line = format!("# {key} = {raw_value}\n");
229                    append_comment_to_table_suffix(user_table, &comment_line);
230                    count += 1;
231                }
232            }
233        }
234    }
235    count
236}
237
238/// Append a comment line to a table's trailing whitespace/decor.
239fn append_comment_to_table_suffix(table: &mut Table, comment_line: &str) {
240    let existing: String = table
241        .decor()
242        .suffix()
243        .and_then(RawString::as_str)
244        .unwrap_or("")
245        .to_owned();
246    // Only append if this exact comment_line is not already present (idempotency).
247    if !existing.contains(comment_line.trim()) {
248        let new_suffix = format!("{existing}{comment_line}");
249        table.decor_mut().set_suffix(new_suffix);
250    }
251}
252
253/// Format a reference item as a commented TOML line: `# key = value`.
254fn format_commented_item(key: &str, item: &Item) -> String {
255    if let Some(val) = item.as_value() {
256        let raw = value_to_toml_string(val);
257        if !raw.is_empty() {
258            return format!("# {key} = {raw}\n");
259        }
260    }
261    String::new()
262}
263
264/// Render a table as a commented-out TOML block with arbitrary nesting depth.
265///
266/// `section_name` is the full dotted path (e.g. `security.content_isolation`).
267/// Returns an empty string if the table has no renderable content.
268fn commented_table_block(section_name: &str, table: &Table) -> String {
269    use std::fmt::Write as _;
270
271    let mut lines = format!("# [{section_name}]\n");
272
273    for (key, item) in table {
274        if item.is_table() {
275            if let Some(sub_table) = item.as_table() {
276                let sub_name = format!("{section_name}.{key}");
277                let sub_block = commented_table_block(&sub_name, sub_table);
278                if !sub_block.is_empty() {
279                    lines.push('\n');
280                    lines.push_str(&sub_block);
281                }
282            }
283        } else if item.is_array_of_tables() {
284            // Skip — user configures these manually (e.g. `[[mcp.servers]]`).
285        } else if let Some(val) = item.as_value() {
286            let raw = value_to_toml_string(val);
287            if !raw.is_empty() {
288                let _ = writeln!(lines, "# {key} = {raw}");
289            }
290        }
291    }
292
293    // Return empty if we only wrote the section header with no content.
294    if lines.trim() == format!("[{section_name}]") {
295        return String::new();
296    }
297    lines
298}
299
300/// Convert a `toml_edit::Value` to its TOML string representation.
301fn value_to_toml_string(val: &Value) -> String {
302    match val {
303        Value::String(s) => {
304            let inner = s.value();
305            format!("\"{inner}\"")
306        }
307        Value::Integer(i) => i.value().to_string(),
308        Value::Float(f) => {
309            let v = f.value();
310            // Use representation that round-trips exactly.
311            if v.fract() == 0.0 {
312                format!("{v:.1}")
313            } else {
314                format!("{v}")
315            }
316        }
317        Value::Boolean(b) => b.value().to_string(),
318        Value::Array(arr) => format_array(arr),
319        Value::InlineTable(t) => {
320            let pairs: Vec<String> = t
321                .iter()
322                .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
323                .collect();
324            format!("{{ {} }}", pairs.join(", "))
325        }
326        Value::Datetime(dt) => dt.value().to_string(),
327    }
328}
329
330fn format_array(arr: &Array) -> String {
331    if arr.is_empty() {
332        return "[]".to_owned();
333    }
334    let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
335    format!("[{}]", items.join(", "))
336}
337
338/// Reorder top-level sections of a TOML document string by the canonical order.
339///
340/// Sections not in the canonical list are placed at the end, preserving their relative order.
341/// This operates on the raw string rather than the parsed document to preserve comments that
342/// would otherwise be dropped by `toml_edit`'s round-trip.
343fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
344    let sections = split_into_sections(toml_str);
345    if sections.is_empty() {
346        return toml_str.to_owned();
347    }
348
349    // Each entry is (header, content). Empty header = preamble block.
350    let preamble_block = sections
351        .iter()
352        .find(|(h, _)| h.is_empty())
353        .map_or("", |(_, c)| c.as_str());
354
355    let section_map: Vec<(&str, &str)> = sections
356        .iter()
357        .filter(|(h, _)| !h.is_empty())
358        .map(|(h, c)| (h.as_str(), c.as_str()))
359        .collect();
360
361    let mut out = String::new();
362    if !preamble_block.is_empty() {
363        out.push_str(preamble_block);
364    }
365
366    let mut emitted: Vec<bool> = vec![false; section_map.len()];
367
368    for &canon in canonical_order {
369        for (idx, &(header, content)) in section_map.iter().enumerate() {
370            let section_name = extract_section_name(header);
371            let top_level = section_name
372                .split('.')
373                .next()
374                .unwrap_or("")
375                .trim_start_matches('#')
376                .trim();
377            if top_level == canon && !emitted[idx] {
378                out.push_str(content);
379                emitted[idx] = true;
380            }
381        }
382    }
383
384    // Append sections not in canonical order.
385    for (idx, &(_, content)) in section_map.iter().enumerate() {
386        if !emitted[idx] {
387            out.push_str(content);
388        }
389    }
390
391    out
392}
393
394/// Extract the section name from a section header line (e.g. `[agent]` → `agent`).
395fn extract_section_name(header: &str) -> &str {
396    // Strip leading `# ` for commented headers.
397    let trimmed = header.trim().trim_start_matches("# ");
398    // Strip `[` and `]`.
399    if trimmed.starts_with('[') && trimmed.contains(']') {
400        let inner = &trimmed[1..];
401        if let Some(end) = inner.find(']') {
402            return &inner[..end];
403        }
404    }
405    trimmed
406}
407
408/// Split a TOML string into `(header_line, full_block)` pairs.
409///
410/// The first element may have an empty header representing the preamble.
411fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
412    let mut sections: Vec<(String, String)> = Vec::new();
413    let mut current_header = String::new();
414    let mut current_content = String::new();
415
416    for line in toml_str.lines() {
417        let trimmed = line.trim();
418        if is_top_level_section_header(trimmed) {
419            sections.push((current_header.clone(), current_content.clone()));
420            trimmed.clone_into(&mut current_header);
421            line.clone_into(&mut current_content);
422            current_content.push('\n');
423        } else {
424            current_content.push_str(line);
425            current_content.push('\n');
426        }
427    }
428
429    // Push the last section.
430    if !current_header.is_empty() || !current_content.is_empty() {
431        sections.push((current_header, current_content));
432    }
433
434    sections
435}
436
437/// Determine if a line is a real (non-commented) top-level section header.
438///
439/// Top-level means `[name]` with no dots. Commented headers like `# [name]`
440/// are NOT treated as section boundaries — they are migrator-generated hints.
441fn is_top_level_section_header(line: &str) -> bool {
442    if line.starts_with('[')
443        && !line.starts_with("[[")
444        && let Some(end) = line.find(']')
445    {
446        return !line[1..end].contains('.');
447    }
448    false
449}
450
451// Helper to create a formatted value (used in tests).
452#[cfg(test)]
453fn make_formatted_str(s: &str) -> Value {
454    use toml_edit::Formatted;
455    Value::String(Formatted::new(s.to_owned()))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn empty_config_gets_sections_as_comments() {
464        let migrator = ConfigMigrator::new();
465        let result = migrator.migrate("").expect("migrate empty");
466        // Should have added sections since reference is non-empty.
467        assert!(result.added_count > 0 || !result.sections_added.is_empty());
468        // Output should mention at least agent section.
469        assert!(
470            result.output.contains("[agent]") || result.output.contains("# [agent]"),
471            "expected agent section in output, got:\n{}",
472            result.output
473        );
474    }
475
476    #[test]
477    fn existing_values_not_overwritten() {
478        let user = r#"
479[agent]
480name = "MyAgent"
481max_tool_iterations = 5
482"#;
483        let migrator = ConfigMigrator::new();
484        let result = migrator.migrate(user).expect("migrate");
485        // Original name preserved.
486        assert!(
487            result.output.contains("name = \"MyAgent\""),
488            "user value should be preserved"
489        );
490        assert!(
491            result.output.contains("max_tool_iterations = 5"),
492            "user value should be preserved"
493        );
494        // Should not appear as commented default.
495        assert!(
496            !result.output.contains("# max_tool_iterations = 10"),
497            "already-set key should not appear as comment"
498        );
499    }
500
501    #[test]
502    fn missing_nested_key_added_as_comment() {
503        // User has [memory] but is missing some keys.
504        let user = r#"
505[memory]
506sqlite_path = ".zeph/data/zeph.db"
507"#;
508        let migrator = ConfigMigrator::new();
509        let result = migrator.migrate(user).expect("migrate");
510        // history_limit should be added as comment since it's in reference.
511        assert!(
512            result.output.contains("# history_limit"),
513            "missing key should be added as comment, got:\n{}",
514            result.output
515        );
516    }
517
518    #[test]
519    fn unknown_user_keys_preserved() {
520        let user = r#"
521[agent]
522name = "Test"
523my_custom_key = "preserved"
524"#;
525        let migrator = ConfigMigrator::new();
526        let result = migrator.migrate(user).expect("migrate");
527        assert!(
528            result.output.contains("my_custom_key = \"preserved\""),
529            "custom user keys must not be removed"
530        );
531    }
532
533    #[test]
534    fn idempotent() {
535        let migrator = ConfigMigrator::new();
536        let first = migrator
537            .migrate("[agent]\nname = \"Zeph\"\n")
538            .expect("first migrate");
539        let second = migrator.migrate(&first.output).expect("second migrate");
540        assert_eq!(
541            first.output, second.output,
542            "idempotent: full output must be identical on second run"
543        );
544    }
545
546    #[test]
547    fn malformed_input_returns_error() {
548        let migrator = ConfigMigrator::new();
549        let err = migrator
550            .migrate("[[invalid toml [[[")
551            .expect_err("should error");
552        assert!(
553            matches!(err, MigrateError::Parse(_)),
554            "expected Parse error"
555        );
556    }
557
558    #[test]
559    fn array_of_tables_preserved() {
560        let user = r#"
561[mcp]
562allowed_commands = ["npx"]
563
564[[mcp.servers]]
565id = "my-server"
566command = "npx"
567args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
568"#;
569        let migrator = ConfigMigrator::new();
570        let result = migrator.migrate(user).expect("migrate");
571        // User's [[mcp.servers]] entry must survive.
572        assert!(
573            result.output.contains("[[mcp.servers]]"),
574            "array-of-tables entries must be preserved"
575        );
576        assert!(result.output.contains("id = \"my-server\""));
577    }
578
579    #[test]
580    fn canonical_ordering_applied() {
581        // Put memory before agent intentionally.
582        let user = r#"
583[memory]
584sqlite_path = ".zeph/data/zeph.db"
585
586[agent]
587name = "Test"
588"#;
589        let migrator = ConfigMigrator::new();
590        let result = migrator.migrate(user).expect("migrate");
591        // agent should appear before memory in canonical order.
592        let agent_pos = result.output.find("[agent]");
593        let memory_pos = result.output.find("[memory]");
594        if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
595            assert!(a < m, "agent section should precede memory section");
596        }
597    }
598
599    #[test]
600    fn value_to_toml_string_formats_correctly() {
601        use toml_edit::Formatted;
602
603        let s = make_formatted_str("hello");
604        assert_eq!(value_to_toml_string(&s), "\"hello\"");
605
606        let i = Value::Integer(Formatted::new(42_i64));
607        assert_eq!(value_to_toml_string(&i), "42");
608
609        let b = Value::Boolean(Formatted::new(true));
610        assert_eq!(value_to_toml_string(&b), "true");
611
612        let f = Value::Float(Formatted::new(1.0_f64));
613        assert_eq!(value_to_toml_string(&f), "1.0");
614
615        let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
616        assert_eq!(value_to_toml_string(&f2), "3.14");
617
618        let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
619        let arr_val = Value::Array(arr);
620        assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
621
622        let empty_arr = Value::Array(Array::new());
623        assert_eq!(value_to_toml_string(&empty_arr), "[]");
624    }
625
626    #[test]
627    fn idempotent_full_output_unchanged() {
628        // Stronger idempotency: the entire output string must not change on a second pass.
629        let migrator = ConfigMigrator::new();
630        let first = migrator
631            .migrate("[agent]\nname = \"Zeph\"\n")
632            .expect("first migrate");
633        let second = migrator.migrate(&first.output).expect("second migrate");
634        assert_eq!(
635            first.output, second.output,
636            "full output string must be identical after second migration pass"
637        );
638    }
639
640    #[test]
641    fn full_config_produces_zero_additions() {
642        // Migrating the reference config itself should add nothing new.
643        let reference = include_str!("../../config/default.toml");
644        let migrator = ConfigMigrator::new();
645        let result = migrator.migrate(reference).expect("migrate reference");
646        assert_eq!(
647            result.added_count, 0,
648            "migrating the canonical reference should add nothing (added_count = {})",
649            result.added_count
650        );
651        assert!(
652            result.sections_added.is_empty(),
653            "migrating the canonical reference should report no sections_added: {:?}",
654            result.sections_added
655        );
656    }
657
658    #[test]
659    fn empty_config_added_count_is_positive() {
660        // Stricter variant of empty_config_gets_sections_as_comments.
661        let migrator = ConfigMigrator::new();
662        let result = migrator.migrate("").expect("migrate empty");
663        assert!(
664            result.added_count > 0,
665            "empty config must report added_count > 0"
666        );
667    }
668}