1use toml_edit::{Array, DocumentMut, Item, RawString, Table, Value};
11
12static 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#[derive(Debug, thiserror::Error)]
45pub enum MigrateError {
46 #[error("failed to parse input config: {0}")]
48 Parse(#[from] toml_edit::TomlError),
49 #[error("failed to parse reference config: {0}")]
51 Reference(toml_edit::TomlError),
52}
53
54#[derive(Debug)]
56pub struct MigrationResult {
57 pub output: String,
59 pub added_count: usize,
61 pub sections_added: Vec<String>,
63}
64
65pub 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 #[must_use]
82 pub fn new() -> Self {
83 Self {
84 reference_src: include_str!("../../config/default.toml"),
85 }
86 }
87
88 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 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 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 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 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 let user_str = user_doc.to_string();
144
145 let mut output = user_str;
147 for key in §ions_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 output = reorder_sections(&output, CANONICAL_ORDER);
169
170 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
184fn 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 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 } else {
221 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
238fn 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 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
253fn 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
264fn 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 } 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 if lines.trim() == format!("[{section_name}]") {
295 return String::new();
296 }
297 lines
298}
299
300fn 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 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
338fn 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 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 for (idx, &(_, content)) in section_map.iter().enumerate() {
386 if !emitted[idx] {
387 out.push_str(content);
388 }
389 }
390
391 out
392}
393
394fn extract_section_name(header: &str) -> &str {
396 let trimmed = header.trim().trim_start_matches("# ");
398 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
408fn 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 if !current_header.is_empty() || !current_content.is_empty() {
431 sections.push((current_header, current_content));
432 }
433
434 sections
435}
436
437fn 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#[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 assert!(result.added_count > 0 || !result.sections_added.is_empty());
468 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 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 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 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 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 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 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 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 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 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 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}