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 "classifiers",
31 "security",
32 "vault",
33 "timeouts",
34 "cost",
35 "observability",
36 "debug",
37 "logging",
38 "tui",
39 "agents",
40 "experiments",
41 "lsp",
42];
43
44#[derive(Debug, thiserror::Error)]
46pub enum MigrateError {
47 #[error("failed to parse input config: {0}")]
49 Parse(#[from] toml_edit::TomlError),
50 #[error("failed to parse reference config: {0}")]
52 Reference(toml_edit::TomlError),
53 #[error("migration failed: invalid TOML structure — {0}")]
56 InvalidStructure(&'static str),
57}
58
59#[derive(Debug)]
61pub struct MigrationResult {
62 pub output: String,
64 pub added_count: usize,
66 pub sections_added: Vec<String>,
68}
69
70pub struct ConfigMigrator {
75 reference_src: &'static str,
76}
77
78impl Default for ConfigMigrator {
79 fn default() -> Self {
80 Self::new()
81 }
82}
83
84impl ConfigMigrator {
85 #[must_use]
87 pub fn new() -> Self {
88 Self {
89 reference_src: include_str!("../config/default.toml"),
90 }
91 }
92
93 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
105 let reference_doc = self
106 .reference_src
107 .parse::<DocumentMut>()
108 .map_err(MigrateError::Reference)?;
109 let mut user_doc = user_toml.parse::<DocumentMut>()?;
110
111 let mut added_count = 0usize;
112 let mut sections_added: Vec<String> = Vec::new();
113
114 for (key, ref_item) in reference_doc.as_table() {
116 if ref_item.is_table() {
117 let ref_table = ref_item.as_table().expect("is_table checked above");
118 if user_doc.contains_key(key) {
119 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
121 added_count += merge_table_commented(user_table, ref_table, key);
122 }
123 } else {
124 if user_toml.contains(&format!("# [{key}]")) {
127 continue;
128 }
129 let commented = commented_table_block(key, ref_table);
130 if !commented.is_empty() {
131 sections_added.push(key.to_owned());
132 }
133 added_count += 1;
134 }
135 } else {
136 if !user_doc.contains_key(key) {
138 let raw = format_commented_item(key, ref_item);
139 if !raw.is_empty() {
140 sections_added.push(format!("__scalar__{key}"));
141 added_count += 1;
142 }
143 }
144 }
145 }
146
147 let user_str = user_doc.to_string();
149
150 let mut output = user_str;
152 for key in §ions_added {
153 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
154 if let Some(ref_item) = reference_doc.get(scalar_key) {
155 let raw = format_commented_item(scalar_key, ref_item);
156 if !raw.is_empty() {
157 output.push('\n');
158 output.push_str(&raw);
159 output.push('\n');
160 }
161 }
162 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
163 {
164 let block = commented_table_block(key, ref_table);
165 if !block.is_empty() {
166 output.push('\n');
167 output.push_str(&block);
168 }
169 }
170 }
171
172 output = reorder_sections(&output, CANONICAL_ORDER);
174
175 let sections_added_clean: Vec<String> = sections_added
177 .into_iter()
178 .filter(|k| !k.starts_with("__scalar__"))
179 .collect();
180
181 Ok(MigrationResult {
182 output,
183 added_count,
184 sections_added: sections_added_clean,
185 })
186 }
187}
188
189fn merge_table_commented(user_table: &mut Table, ref_table: &Table, section_key: &str) -> usize {
193 let mut count = 0usize;
194 for (key, ref_item) in ref_table {
195 if ref_item.is_table() {
196 if user_table.contains_key(key) {
197 let pair = (
198 user_table.get_mut(key).and_then(Item::as_table_mut),
199 ref_item.as_table(),
200 );
201 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
202 let sub_key = format!("{section_key}.{key}");
203 count += merge_table_commented(user_sub_table, ref_sub_table, &sub_key);
204 }
205 } else if let Some(ref_sub_table) = ref_item.as_table() {
206 let dotted = format!("{section_key}.{key}");
208 let marker = format!("# [{dotted}]");
209 let existing = user_table
210 .decor()
211 .suffix()
212 .and_then(RawString::as_str)
213 .unwrap_or("");
214 if !existing.contains(&marker) {
215 let block = commented_table_block(&dotted, ref_sub_table);
216 if !block.is_empty() {
217 let new_suffix = format!("{existing}\n{block}");
218 user_table.decor_mut().set_suffix(new_suffix);
219 count += 1;
220 }
221 }
222 }
223 } else if ref_item.is_array_of_tables() {
224 } else {
226 if !user_table.contains_key(key) {
228 let raw_value = ref_item
229 .as_value()
230 .map(value_to_toml_string)
231 .unwrap_or_default();
232 if !raw_value.is_empty() {
233 let comment_line = format!("# {key} = {raw_value}\n");
234 append_comment_to_table_suffix(user_table, &comment_line);
235 count += 1;
236 }
237 }
238 }
239 }
240 count
241}
242
243fn append_comment_to_table_suffix(table: &mut Table, comment_line: &str) {
245 let existing: String = table
246 .decor()
247 .suffix()
248 .and_then(RawString::as_str)
249 .unwrap_or("")
250 .to_owned();
251 if !existing.contains(comment_line.trim()) {
253 let new_suffix = format!("{existing}{comment_line}");
254 table.decor_mut().set_suffix(new_suffix);
255 }
256}
257
258fn format_commented_item(key: &str, item: &Item) -> String {
260 if let Some(val) = item.as_value() {
261 let raw = value_to_toml_string(val);
262 if !raw.is_empty() {
263 return format!("# {key} = {raw}\n");
264 }
265 }
266 String::new()
267}
268
269fn commented_table_block(section_name: &str, table: &Table) -> String {
274 use std::fmt::Write as _;
275
276 let mut lines = format!("# [{section_name}]\n");
277
278 for (key, item) in table {
279 if item.is_table() {
280 if let Some(sub_table) = item.as_table() {
281 let sub_name = format!("{section_name}.{key}");
282 let sub_block = commented_table_block(&sub_name, sub_table);
283 if !sub_block.is_empty() {
284 lines.push('\n');
285 lines.push_str(&sub_block);
286 }
287 }
288 } else if item.is_array_of_tables() {
289 } else if let Some(val) = item.as_value() {
291 let raw = value_to_toml_string(val);
292 if !raw.is_empty() {
293 let _ = writeln!(lines, "# {key} = {raw}");
294 }
295 }
296 }
297
298 if lines.trim() == format!("[{section_name}]") {
300 return String::new();
301 }
302 lines
303}
304
305fn value_to_toml_string(val: &Value) -> String {
307 match val {
308 Value::String(s) => {
309 let inner = s.value();
310 format!("\"{inner}\"")
311 }
312 Value::Integer(i) => i.value().to_string(),
313 Value::Float(f) => {
314 let v = f.value();
315 if v.fract() == 0.0 {
317 format!("{v:.1}")
318 } else {
319 format!("{v}")
320 }
321 }
322 Value::Boolean(b) => b.value().to_string(),
323 Value::Array(arr) => format_array(arr),
324 Value::InlineTable(t) => {
325 let pairs: Vec<String> = t
326 .iter()
327 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
328 .collect();
329 format!("{{ {} }}", pairs.join(", "))
330 }
331 Value::Datetime(dt) => dt.value().to_string(),
332 }
333}
334
335fn format_array(arr: &Array) -> String {
336 if arr.is_empty() {
337 return "[]".to_owned();
338 }
339 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
340 format!("[{}]", items.join(", "))
341}
342
343fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
349 let sections = split_into_sections(toml_str);
350 if sections.is_empty() {
351 return toml_str.to_owned();
352 }
353
354 let preamble_block = sections
356 .iter()
357 .find(|(h, _)| h.is_empty())
358 .map_or("", |(_, c)| c.as_str());
359
360 let section_map: Vec<(&str, &str)> = sections
361 .iter()
362 .filter(|(h, _)| !h.is_empty())
363 .map(|(h, c)| (h.as_str(), c.as_str()))
364 .collect();
365
366 let mut out = String::new();
367 if !preamble_block.is_empty() {
368 out.push_str(preamble_block);
369 }
370
371 let mut emitted: Vec<bool> = vec![false; section_map.len()];
372
373 for &canon in canonical_order {
374 for (idx, &(header, content)) in section_map.iter().enumerate() {
375 let section_name = extract_section_name(header);
376 let top_level = section_name
377 .split('.')
378 .next()
379 .unwrap_or("")
380 .trim_start_matches('#')
381 .trim();
382 if top_level == canon && !emitted[idx] {
383 out.push_str(content);
384 emitted[idx] = true;
385 }
386 }
387 }
388
389 for (idx, &(_, content)) in section_map.iter().enumerate() {
391 if !emitted[idx] {
392 out.push_str(content);
393 }
394 }
395
396 out
397}
398
399fn extract_section_name(header: &str) -> &str {
401 let trimmed = header.trim().trim_start_matches("# ");
403 if trimmed.starts_with('[') && trimmed.contains(']') {
405 let inner = &trimmed[1..];
406 if let Some(end) = inner.find(']') {
407 return &inner[..end];
408 }
409 }
410 trimmed
411}
412
413fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
417 let mut sections: Vec<(String, String)> = Vec::new();
418 let mut current_header = String::new();
419 let mut current_content = String::new();
420
421 for line in toml_str.lines() {
422 let trimmed = line.trim();
423 if is_top_level_section_header(trimmed) {
424 sections.push((current_header.clone(), current_content.clone()));
425 trimmed.clone_into(&mut current_header);
426 line.clone_into(&mut current_content);
427 current_content.push('\n');
428 } else {
429 current_content.push_str(line);
430 current_content.push('\n');
431 }
432 }
433
434 if !current_header.is_empty() || !current_content.is_empty() {
436 sections.push((current_header, current_content));
437 }
438
439 sections
440}
441
442fn is_top_level_section_header(line: &str) -> bool {
447 if line.starts_with('[')
448 && !line.starts_with("[[")
449 && let Some(end) = line.find(']')
450 {
451 return !line[1..end].contains('.');
452 }
453 false
454}
455
456#[allow(
467 clippy::too_many_lines,
468 clippy::format_push_string,
469 clippy::manual_let_else,
470 clippy::op_ref,
471 clippy::collapsible_if
472)]
473pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
474 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
475
476 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
478 Some(t) => t,
479 None => {
480 return Ok(MigrationResult {
482 output: toml_src.to_owned(),
483 added_count: 0,
484 sections_added: Vec::new(),
485 });
486 }
487 };
488
489 let has_provider_field = llm.contains_key("provider");
490 let has_cloud = llm.contains_key("cloud");
491 let has_openai = llm.contains_key("openai");
492 let has_gemini = llm.contains_key("gemini");
493 let has_orchestrator = llm.contains_key("orchestrator");
494 let has_router = llm.contains_key("router");
495 let has_providers = llm.contains_key("providers");
496
497 if !has_provider_field
498 && !has_cloud
499 && !has_openai
500 && !has_orchestrator
501 && !has_router
502 && !has_gemini
503 {
504 return Ok(MigrationResult {
506 output: toml_src.to_owned(),
507 added_count: 0,
508 sections_added: Vec::new(),
509 });
510 }
511
512 if has_providers {
513 return Err(MigrateError::Parse(
515 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
516 .parse::<toml_edit::DocumentMut>()
517 .unwrap_err(),
518 ));
519 }
520
521 let provider_str = llm
523 .get("provider")
524 .and_then(toml_edit::Item::as_str)
525 .unwrap_or("ollama");
526 let base_url = llm
527 .get("base_url")
528 .and_then(toml_edit::Item::as_str)
529 .map(str::to_owned);
530 let model = llm
531 .get("model")
532 .and_then(toml_edit::Item::as_str)
533 .map(str::to_owned);
534 let embedding_model = llm
535 .get("embedding_model")
536 .and_then(toml_edit::Item::as_str)
537 .map(str::to_owned);
538
539 let mut provider_blocks: Vec<String> = Vec::new();
541 let mut routing: Option<String> = None;
542 let mut routes_block: Option<String> = None;
543
544 match provider_str {
545 "ollama" => {
546 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
547 if let Some(ref m) = model {
548 block.push_str(&format!("model = \"{m}\"\n"));
549 }
550 if let Some(ref em) = embedding_model {
551 block.push_str(&format!("embedding_model = \"{em}\"\n"));
552 }
553 if let Some(ref u) = base_url {
554 block.push_str(&format!("base_url = \"{u}\"\n"));
555 }
556 provider_blocks.push(block);
557 }
558 "claude" => {
559 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
560 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
561 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
562 block.push_str(&format!("model = \"{m}\"\n"));
563 }
564 if let Some(t) = cloud
565 .get("max_tokens")
566 .and_then(toml_edit::Item::as_integer)
567 {
568 block.push_str(&format!("max_tokens = {t}\n"));
569 }
570 if cloud
571 .get("server_compaction")
572 .and_then(toml_edit::Item::as_bool)
573 == Some(true)
574 {
575 block.push_str("server_compaction = true\n");
576 }
577 if cloud
578 .get("enable_extended_context")
579 .and_then(toml_edit::Item::as_bool)
580 == Some(true)
581 {
582 block.push_str("enable_extended_context = true\n");
583 }
584 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
586 let pairs: Vec<String> =
587 thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
588 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
589 }
590 } else if let Some(ref m) = model {
591 block.push_str(&format!("model = \"{m}\"\n"));
592 }
593 provider_blocks.push(block);
594 }
595 "openai" => {
596 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
597 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
598 copy_str_field(openai, "model", &mut block);
599 copy_str_field(openai, "base_url", &mut block);
600 copy_int_field(openai, "max_tokens", &mut block);
601 copy_str_field(openai, "embedding_model", &mut block);
602 copy_str_field(openai, "reasoning_effort", &mut block);
603 } else if let Some(ref m) = model {
604 block.push_str(&format!("model = \"{m}\"\n"));
605 }
606 provider_blocks.push(block);
607 }
608 "gemini" => {
609 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
610 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
611 copy_str_field(gemini, "model", &mut block);
612 copy_int_field(gemini, "max_tokens", &mut block);
613 copy_str_field(gemini, "base_url", &mut block);
614 copy_str_field(gemini, "embedding_model", &mut block);
615 copy_str_field(gemini, "thinking_level", &mut block);
617 copy_int_field(gemini, "thinking_budget", &mut block);
618 if let Some(v) = gemini
619 .get("include_thoughts")
620 .and_then(toml_edit::Item::as_bool)
621 {
622 block.push_str(&format!("include_thoughts = {v}\n"));
623 }
624 } else if let Some(ref m) = model {
625 block.push_str(&format!("model = \"{m}\"\n"));
626 }
627 provider_blocks.push(block);
628 }
629 "compatible" => {
630 if let Some(compat_arr) = llm
632 .get("compatible")
633 .and_then(toml_edit::Item::as_array_of_tables)
634 {
635 for entry in compat_arr {
636 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
637 copy_str_field(entry, "name", &mut block);
638 copy_str_field(entry, "base_url", &mut block);
639 copy_str_field(entry, "model", &mut block);
640 copy_int_field(entry, "max_tokens", &mut block);
641 copy_str_field(entry, "embedding_model", &mut block);
642 provider_blocks.push(block);
643 }
644 }
645 }
646 "orchestrator" => {
647 routing = Some("task".to_owned());
649 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
650 let default_name = orch
651 .get("default")
652 .and_then(toml_edit::Item::as_str)
653 .unwrap_or("")
654 .to_owned();
655 let embed_name = orch
656 .get("embed")
657 .and_then(toml_edit::Item::as_str)
658 .unwrap_or("")
659 .to_owned();
660
661 if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
663 let mut rb = "[llm.routes]\n".to_owned();
664 for (key, val) in routes {
665 if let Some(arr) = val.as_array() {
666 let items: Vec<String> = arr
667 .iter()
668 .filter_map(toml_edit::Value::as_str)
669 .map(|s| format!("\"{s}\""))
670 .collect();
671 rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
672 }
673 }
674 routes_block = Some(rb);
675 }
676
677 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
679 for (name, pcfg_item) in providers {
680 let Some(pcfg) = pcfg_item.as_table() else {
681 continue;
682 };
683 let ptype = pcfg
684 .get("type")
685 .and_then(toml_edit::Item::as_str)
686 .unwrap_or("ollama");
687 let mut block =
688 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
689 if name == &default_name {
690 block.push_str("default = true\n");
691 }
692 if name == &embed_name {
693 block.push_str("embed = true\n");
694 }
695 copy_str_field(pcfg, "model", &mut block);
697 copy_str_field(pcfg, "base_url", &mut block);
698 copy_str_field(pcfg, "embedding_model", &mut block);
699 if ptype == "claude" && !pcfg.contains_key("model") {
701 if let Some(cloud) =
702 llm.get("cloud").and_then(toml_edit::Item::as_table)
703 {
704 copy_str_field(cloud, "model", &mut block);
705 copy_int_field(cloud, "max_tokens", &mut block);
706 }
707 }
708 if ptype == "openai" && !pcfg.contains_key("model") {
710 if let Some(openai) =
711 llm.get("openai").and_then(toml_edit::Item::as_table)
712 {
713 copy_str_field(openai, "model", &mut block);
714 copy_str_field(openai, "base_url", &mut block);
715 copy_int_field(openai, "max_tokens", &mut block);
716 copy_str_field(openai, "embedding_model", &mut block);
717 }
718 }
719 if ptype == "ollama" && !pcfg.contains_key("base_url") {
721 if let Some(ref u) = base_url {
722 block.push_str(&format!("base_url = \"{u}\"\n"));
723 }
724 }
725 if ptype == "ollama" && !pcfg.contains_key("model") {
726 if let Some(ref m) = model {
727 block.push_str(&format!("model = \"{m}\"\n"));
728 }
729 }
730 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
731 if let Some(ref em) = embedding_model {
732 block.push_str(&format!("embedding_model = \"{em}\"\n"));
733 }
734 }
735 provider_blocks.push(block);
736 }
737 }
738 }
739 }
740 "router" => {
741 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
743 let strategy = router
744 .get("strategy")
745 .and_then(toml_edit::Item::as_str)
746 .unwrap_or("ema");
747 routing = Some(strategy.to_owned());
748
749 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
750 for item in chain {
751 let name = item.as_str().unwrap_or_default();
752 let ptype = infer_provider_type(name, llm);
754 let mut block =
755 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
756 match ptype {
757 "claude" => {
758 if let Some(cloud) =
759 llm.get("cloud").and_then(toml_edit::Item::as_table)
760 {
761 copy_str_field(cloud, "model", &mut block);
762 copy_int_field(cloud, "max_tokens", &mut block);
763 }
764 }
765 "openai" => {
766 if let Some(openai) =
767 llm.get("openai").and_then(toml_edit::Item::as_table)
768 {
769 copy_str_field(openai, "model", &mut block);
770 copy_str_field(openai, "base_url", &mut block);
771 copy_int_field(openai, "max_tokens", &mut block);
772 copy_str_field(openai, "embedding_model", &mut block);
773 } else {
774 if let Some(ref m) = model {
775 block.push_str(&format!("model = \"{m}\"\n"));
776 }
777 if let Some(ref u) = base_url {
778 block.push_str(&format!("base_url = \"{u}\"\n"));
779 }
780 }
781 }
782 "ollama" => {
783 if let Some(ref m) = model {
784 block.push_str(&format!("model = \"{m}\"\n"));
785 }
786 if let Some(ref em) = embedding_model {
787 block.push_str(&format!("embedding_model = \"{em}\"\n"));
788 }
789 if let Some(ref u) = base_url {
790 block.push_str(&format!("base_url = \"{u}\"\n"));
791 }
792 }
793 _ => {
794 if let Some(ref m) = model {
795 block.push_str(&format!("model = \"{m}\"\n"));
796 }
797 }
798 }
799 provider_blocks.push(block);
800 }
801 }
802 }
803 }
804 other => {
805 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
807 if let Some(ref m) = model {
808 block.push_str(&format!("model = \"{m}\"\n"));
809 }
810 provider_blocks.push(block);
811 }
812 }
813
814 if provider_blocks.is_empty() {
815 return Ok(MigrationResult {
817 output: toml_src.to_owned(),
818 added_count: 0,
819 sections_added: Vec::new(),
820 });
821 }
822
823 let mut new_llm = "[llm]\n".to_owned();
825 if let Some(ref r) = routing {
826 new_llm.push_str(&format!("routing = \"{r}\"\n"));
827 }
828 for key in &[
830 "response_cache_enabled",
831 "response_cache_ttl_secs",
832 "semantic_cache_enabled",
833 "semantic_cache_threshold",
834 "semantic_cache_max_candidates",
835 "summary_model",
836 "instruction_file",
837 ] {
838 if let Some(val) = llm.get(key) {
839 if let Some(v) = val.as_value() {
840 let raw = value_to_toml_string(v);
841 if !raw.is_empty() {
842 new_llm.push_str(&format!("{key} = {raw}\n"));
843 }
844 }
845 }
846 }
847 new_llm.push('\n');
848
849 if let Some(rb) = routes_block {
850 new_llm.push_str(&rb);
851 new_llm.push('\n');
852 }
853
854 for block in &provider_blocks {
855 new_llm.push_str(block);
856 new_llm.push('\n');
857 }
858
859 let output = replace_llm_section(toml_src, &new_llm);
862
863 Ok(MigrationResult {
864 output,
865 added_count: provider_blocks.len(),
866 sections_added: vec!["llm.providers".to_owned()],
867 })
868}
869
870fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
872 match name {
873 "claude" => "claude",
874 "openai" => "openai",
875 "gemini" => "gemini",
876 "ollama" => "ollama",
877 "candle" => "candle",
878 _ => {
879 if llm.contains_key("compatible") {
881 "compatible"
882 } else if llm.contains_key("openai") {
883 "openai"
884 } else {
885 "ollama"
886 }
887 }
888 }
889}
890
891fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
892 use std::fmt::Write as _;
893 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
894 let _ = writeln!(out, "{key} = \"{v}\"");
895 }
896}
897
898fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
899 use std::fmt::Write as _;
900 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
901 let _ = writeln!(out, "{key} = {v}");
902 }
903}
904
905fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
908 let mut out = String::new();
909 let mut in_llm = false;
910 let mut skip_until_next_top = false;
911
912 for line in toml_str.lines() {
913 let trimmed = line.trim();
914
915 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
917 && trimmed.ends_with(']')
918 && !trimmed[1..trimmed.len() - 1].contains('.');
919 let is_top_aot = trimmed.starts_with("[[")
920 && trimmed.ends_with("]]")
921 && !trimmed[2..trimmed.len() - 2].contains('.');
922 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
923 && (trimmed.contains(']'));
924
925 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
926 in_llm = true;
927 skip_until_next_top = true;
928 continue;
929 }
930
931 if is_top_section || is_top_aot {
932 if skip_until_next_top {
933 out.push_str(new_llm_section);
935 skip_until_next_top = false;
936 }
937 in_llm = false;
938 }
939
940 if !skip_until_next_top {
941 out.push_str(line);
942 out.push('\n');
943 }
944 }
945
946 if skip_until_next_top {
948 out.push_str(new_llm_section);
949 }
950
951 out
952}
953
954#[allow(clippy::too_many_lines)]
973pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
974 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
975
976 let stt_model = doc
978 .get("llm")
979 .and_then(toml_edit::Item::as_table)
980 .and_then(|llm| llm.get("stt"))
981 .and_then(toml_edit::Item::as_table)
982 .and_then(|stt| stt.get("model"))
983 .and_then(toml_edit::Item::as_str)
984 .map(ToOwned::to_owned);
985
986 let stt_base_url = doc
987 .get("llm")
988 .and_then(toml_edit::Item::as_table)
989 .and_then(|llm| llm.get("stt"))
990 .and_then(toml_edit::Item::as_table)
991 .and_then(|stt| stt.get("base_url"))
992 .and_then(toml_edit::Item::as_str)
993 .map(ToOwned::to_owned);
994
995 let stt_provider_hint = doc
996 .get("llm")
997 .and_then(toml_edit::Item::as_table)
998 .and_then(|llm| llm.get("stt"))
999 .and_then(toml_edit::Item::as_table)
1000 .and_then(|stt| stt.get("provider"))
1001 .and_then(toml_edit::Item::as_str)
1002 .map(ToOwned::to_owned)
1003 .unwrap_or_default();
1004
1005 if stt_model.is_none() && stt_base_url.is_none() {
1007 return Ok(MigrationResult {
1008 output: toml_src.to_owned(),
1009 added_count: 0,
1010 sections_added: Vec::new(),
1011 });
1012 }
1013
1014 let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1015
1016 let target_type = match stt_provider_hint.as_str() {
1018 "candle-whisper" | "candle" => "candle",
1019 _ => "openai",
1020 };
1021
1022 let providers = doc
1025 .get("llm")
1026 .and_then(toml_edit::Item::as_table)
1027 .and_then(|llm| llm.get("providers"))
1028 .and_then(toml_edit::Item::as_array_of_tables);
1029
1030 let matching_idx = providers.and_then(|arr| {
1031 arr.iter().enumerate().find_map(|(i, t)| {
1032 let name = t
1033 .get("name")
1034 .and_then(toml_edit::Item::as_str)
1035 .unwrap_or("");
1036 let ptype = t
1037 .get("type")
1038 .and_then(toml_edit::Item::as_str)
1039 .unwrap_or("");
1040 let name_match = !stt_provider_hint.is_empty()
1042 && (name == stt_provider_hint || ptype == stt_provider_hint);
1043 let type_match = ptype == target_type;
1044 if name_match || type_match {
1045 Some(i)
1046 } else {
1047 None
1048 }
1049 })
1050 });
1051
1052 let resolved_provider_name: String;
1054
1055 if let Some(idx) = matching_idx {
1056 let llm_mut = doc
1058 .get_mut("llm")
1059 .and_then(toml_edit::Item::as_table_mut)
1060 .ok_or(MigrateError::InvalidStructure(
1061 "[llm] table not accessible for mutation",
1062 ))?;
1063 let providers_mut = llm_mut
1064 .get_mut("providers")
1065 .and_then(toml_edit::Item::as_array_of_tables_mut)
1066 .ok_or(MigrateError::InvalidStructure(
1067 "[[llm.providers]] array not accessible for mutation",
1068 ))?;
1069 let entry = providers_mut
1070 .iter_mut()
1071 .nth(idx)
1072 .ok_or(MigrateError::InvalidStructure(
1073 "[[llm.providers]] entry index out of range during mutation",
1074 ))?;
1075
1076 let existing_name = entry
1078 .get("name")
1079 .and_then(toml_edit::Item::as_str)
1080 .map(ToOwned::to_owned);
1081 let entry_name = existing_name.unwrap_or_else(|| {
1082 let t = entry
1083 .get("type")
1084 .and_then(toml_edit::Item::as_str)
1085 .unwrap_or("openai");
1086 format!("{t}-stt")
1087 });
1088 entry.insert("name", toml_edit::value(entry_name.clone()));
1089 entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1090 if stt_base_url.is_some() && entry.get("base_url").is_none() {
1091 entry.insert(
1092 "base_url",
1093 toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1094 );
1095 }
1096 resolved_provider_name = entry_name;
1097 } else {
1098 let new_name = if target_type == "candle" {
1100 "local-whisper".to_owned()
1101 } else {
1102 "openai-stt".to_owned()
1103 };
1104 let mut new_entry = toml_edit::Table::new();
1105 new_entry.insert("name", toml_edit::value(new_name.clone()));
1106 new_entry.insert("type", toml_edit::value(target_type));
1107 new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1108 if let Some(ref url) = stt_base_url {
1109 new_entry.insert("base_url", toml_edit::value(url.clone()));
1110 }
1111 let llm_mut = doc
1113 .get_mut("llm")
1114 .and_then(toml_edit::Item::as_table_mut)
1115 .ok_or(MigrateError::InvalidStructure(
1116 "[llm] table not accessible for mutation",
1117 ))?;
1118 if let Some(item) = llm_mut.get_mut("providers") {
1119 if let Some(arr) = item.as_array_of_tables_mut() {
1120 arr.push(new_entry);
1121 }
1122 } else {
1123 let mut arr = toml_edit::ArrayOfTables::new();
1124 arr.push(new_entry);
1125 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1126 }
1127 resolved_provider_name = new_name;
1128 }
1129
1130 if let Some(stt_table) = doc
1132 .get_mut("llm")
1133 .and_then(toml_edit::Item::as_table_mut)
1134 .and_then(|llm| llm.get_mut("stt"))
1135 .and_then(toml_edit::Item::as_table_mut)
1136 {
1137 stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1138 stt_table.remove("model");
1139 stt_table.remove("base_url");
1140 }
1141
1142 Ok(MigrationResult {
1143 output: doc.to_string(),
1144 added_count: 1,
1145 sections_added: vec!["llm.providers.stt_model".to_owned()],
1146 })
1147}
1148
1149pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1162 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1163
1164 let old_value = doc
1165 .get("orchestration")
1166 .and_then(toml_edit::Item::as_table)
1167 .and_then(|t| t.get("planner_model"))
1168 .and_then(toml_edit::Item::as_value)
1169 .and_then(toml_edit::Value::as_str)
1170 .map(ToOwned::to_owned);
1171
1172 let Some(old_model) = old_value else {
1173 return Ok(MigrationResult {
1174 output: toml_src.to_owned(),
1175 added_count: 0,
1176 sections_added: Vec::new(),
1177 });
1178 };
1179
1180 let commented_out = format!(
1184 "# planner_provider = \"{old_model}\" \
1185 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1186 );
1187
1188 let orch_table = doc
1189 .get_mut("orchestration")
1190 .and_then(toml_edit::Item::as_table_mut)
1191 .ok_or(MigrateError::InvalidStructure(
1192 "[orchestration] is not a table",
1193 ))?;
1194 orch_table.remove("planner_model");
1195 let decor = orch_table.decor_mut();
1196 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1197 let new_suffix = if existing_suffix.trim().is_empty() {
1199 format!("\n{commented_out}\n")
1200 } else {
1201 format!("{existing_suffix}\n{commented_out}\n")
1202 };
1203 decor.set_suffix(new_suffix);
1204
1205 eprintln!(
1206 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1207 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1208 `name` field, not a raw model name. Update or remove the commented line."
1209 );
1210
1211 Ok(MigrationResult {
1212 output: doc.to_string(),
1213 added_count: 1,
1214 sections_added: vec!["orchestration.planner_provider".to_owned()],
1215 })
1216}
1217
1218pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1232 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1233 let mut added = 0usize;
1234
1235 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1236 return Ok(MigrationResult {
1237 output: toml_src.to_owned(),
1238 added_count: 0,
1239 sections_added: Vec::new(),
1240 });
1241 };
1242
1243 let Some(servers) = mcp
1244 .get_mut("servers")
1245 .and_then(toml_edit::Item::as_array_of_tables_mut)
1246 else {
1247 return Ok(MigrationResult {
1248 output: toml_src.to_owned(),
1249 added_count: 0,
1250 sections_added: Vec::new(),
1251 });
1252 };
1253
1254 for entry in servers.iter_mut() {
1255 if !entry.contains_key("trust_level") {
1256 entry.insert(
1257 "trust_level",
1258 toml_edit::value(toml_edit::Value::from("trusted")),
1259 );
1260 added += 1;
1261 }
1262 }
1263
1264 if added > 0 {
1265 eprintln!(
1266 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1267 entr{} (preserving previous SSRF-skip behavior). \
1268 Review and adjust trust levels as needed.",
1269 if added == 1 { "y" } else { "ies" }
1270 );
1271 }
1272
1273 Ok(MigrationResult {
1274 output: doc.to_string(),
1275 added_count: added,
1276 sections_added: if added > 0 {
1277 vec!["mcp.servers.trust_level".to_owned()]
1278 } else {
1279 Vec::new()
1280 },
1281 })
1282}
1283
1284pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1295 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1296
1297 let max_retries = doc
1298 .get("agent")
1299 .and_then(toml_edit::Item::as_table)
1300 .and_then(|t| t.get("max_tool_retries"))
1301 .and_then(toml_edit::Item::as_value)
1302 .and_then(toml_edit::Value::as_integer)
1303 .map(i64::cast_unsigned);
1304
1305 let budget_secs = doc
1306 .get("agent")
1307 .and_then(toml_edit::Item::as_table)
1308 .and_then(|t| t.get("max_retry_duration_secs"))
1309 .and_then(toml_edit::Item::as_value)
1310 .and_then(toml_edit::Value::as_integer)
1311 .map(i64::cast_unsigned);
1312
1313 if max_retries.is_none() && budget_secs.is_none() {
1314 return Ok(MigrationResult {
1315 output: toml_src.to_owned(),
1316 added_count: 0,
1317 sections_added: Vec::new(),
1318 });
1319 }
1320
1321 if !doc.contains_key("tools") {
1323 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1324 }
1325 let tools_table = doc
1326 .get_mut("tools")
1327 .and_then(toml_edit::Item::as_table_mut)
1328 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1329
1330 if !tools_table.contains_key("retry") {
1331 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1332 }
1333 let retry_table = tools_table
1334 .get_mut("retry")
1335 .and_then(toml_edit::Item::as_table_mut)
1336 .ok_or(MigrateError::InvalidStructure(
1337 "[tools.retry] is not a table",
1338 ))?;
1339
1340 let mut added_count = 0usize;
1341
1342 if let Some(retries) = max_retries
1343 && !retry_table.contains_key("max_attempts")
1344 {
1345 retry_table.insert(
1346 "max_attempts",
1347 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1348 );
1349 added_count += 1;
1350 }
1351
1352 if let Some(secs) = budget_secs
1353 && !retry_table.contains_key("budget_secs")
1354 {
1355 retry_table.insert(
1356 "budget_secs",
1357 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1358 );
1359 added_count += 1;
1360 }
1361
1362 if added_count > 0 {
1363 eprintln!(
1364 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1365 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1366 );
1367 }
1368
1369 Ok(MigrationResult {
1370 output: doc.to_string(),
1371 added_count,
1372 sections_added: if added_count > 0 {
1373 vec!["tools.retry".to_owned()]
1374 } else {
1375 Vec::new()
1376 },
1377 })
1378}
1379
1380pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1389 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1390
1391 if !doc.contains_key("memory") {
1393 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1394 }
1395
1396 let memory = doc
1397 .get_mut("memory")
1398 .and_then(toml_edit::Item::as_table_mut)
1399 .ok_or(MigrateError::InvalidStructure(
1400 "[memory] key exists but is not a table",
1401 ))?;
1402
1403 if memory.contains_key("database_url") {
1404 return Ok(MigrationResult {
1405 output: toml_src.to_owned(),
1406 added_count: 0,
1407 sections_added: Vec::new(),
1408 });
1409 }
1410
1411 let comment = "# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1413 # Leave empty and store the actual URL in the vault:\n\
1414 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1415 # database_url = \"\"\n";
1416 append_comment_to_table_suffix(memory, comment);
1417
1418 Ok(MigrationResult {
1419 output: doc.to_string(),
1420 added_count: 1,
1421 sections_added: vec!["memory.database_url".to_owned()],
1422 })
1423}
1424
1425pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1434 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1435
1436 let tools_shell_exists = doc
1437 .get("tools")
1438 .and_then(toml_edit::Item::as_table)
1439 .is_some_and(|t| t.contains_key("shell"));
1440 if !tools_shell_exists {
1441 return Ok(MigrationResult {
1443 output: toml_src.to_owned(),
1444 added_count: 0,
1445 sections_added: Vec::new(),
1446 });
1447 }
1448
1449 let shell = doc
1450 .get_mut("tools")
1451 .and_then(toml_edit::Item::as_table_mut)
1452 .and_then(|t| t.get_mut("shell"))
1453 .and_then(toml_edit::Item::as_table_mut)
1454 .ok_or(MigrateError::InvalidStructure(
1455 "[tools.shell] is not a table",
1456 ))?;
1457
1458 if shell.contains_key("transactional") {
1459 return Ok(MigrationResult {
1460 output: toml_src.to_owned(),
1461 added_count: 0,
1462 sections_added: Vec::new(),
1463 });
1464 }
1465
1466 let comment = "# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1467 # transactional = false\n\
1468 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1469 # auto_rollback = false # rollback when exit code >= 2\n\
1470 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1471 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1472 append_comment_to_table_suffix(shell, comment);
1473
1474 Ok(MigrationResult {
1475 output: doc.to_string(),
1476 added_count: 1,
1477 sections_added: vec!["tools.shell.transactional".to_owned()],
1478 })
1479}
1480
1481#[cfg(test)]
1483fn make_formatted_str(s: &str) -> Value {
1484 use toml_edit::Formatted;
1485 Value::String(Formatted::new(s.to_owned()))
1486}
1487
1488#[cfg(test)]
1489mod tests {
1490 use super::*;
1491
1492 #[test]
1493 fn empty_config_gets_sections_as_comments() {
1494 let migrator = ConfigMigrator::new();
1495 let result = migrator.migrate("").expect("migrate empty");
1496 assert!(result.added_count > 0 || !result.sections_added.is_empty());
1498 assert!(
1500 result.output.contains("[agent]") || result.output.contains("# [agent]"),
1501 "expected agent section in output, got:\n{}",
1502 result.output
1503 );
1504 }
1505
1506 #[test]
1507 fn existing_values_not_overwritten() {
1508 let user = r#"
1509[agent]
1510name = "MyAgent"
1511max_tool_iterations = 5
1512"#;
1513 let migrator = ConfigMigrator::new();
1514 let result = migrator.migrate(user).expect("migrate");
1515 assert!(
1517 result.output.contains("name = \"MyAgent\""),
1518 "user value should be preserved"
1519 );
1520 assert!(
1521 result.output.contains("max_tool_iterations = 5"),
1522 "user value should be preserved"
1523 );
1524 assert!(
1526 !result.output.contains("# max_tool_iterations = 10"),
1527 "already-set key should not appear as comment"
1528 );
1529 }
1530
1531 #[test]
1532 fn missing_nested_key_added_as_comment() {
1533 let user = r#"
1535[memory]
1536sqlite_path = ".zeph/data/zeph.db"
1537"#;
1538 let migrator = ConfigMigrator::new();
1539 let result = migrator.migrate(user).expect("migrate");
1540 assert!(
1542 result.output.contains("# history_limit"),
1543 "missing key should be added as comment, got:\n{}",
1544 result.output
1545 );
1546 }
1547
1548 #[test]
1549 fn unknown_user_keys_preserved() {
1550 let user = r#"
1551[agent]
1552name = "Test"
1553my_custom_key = "preserved"
1554"#;
1555 let migrator = ConfigMigrator::new();
1556 let result = migrator.migrate(user).expect("migrate");
1557 assert!(
1558 result.output.contains("my_custom_key = \"preserved\""),
1559 "custom user keys must not be removed"
1560 );
1561 }
1562
1563 #[test]
1564 fn idempotent() {
1565 let migrator = ConfigMigrator::new();
1566 let first = migrator
1567 .migrate("[agent]\nname = \"Zeph\"\n")
1568 .expect("first migrate");
1569 let second = migrator.migrate(&first.output).expect("second migrate");
1570 assert_eq!(
1571 first.output, second.output,
1572 "idempotent: full output must be identical on second run"
1573 );
1574 }
1575
1576 #[test]
1577 fn malformed_input_returns_error() {
1578 let migrator = ConfigMigrator::new();
1579 let err = migrator
1580 .migrate("[[invalid toml [[[")
1581 .expect_err("should error");
1582 assert!(
1583 matches!(err, MigrateError::Parse(_)),
1584 "expected Parse error"
1585 );
1586 }
1587
1588 #[test]
1589 fn array_of_tables_preserved() {
1590 let user = r#"
1591[mcp]
1592allowed_commands = ["npx"]
1593
1594[[mcp.servers]]
1595id = "my-server"
1596command = "npx"
1597args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1598"#;
1599 let migrator = ConfigMigrator::new();
1600 let result = migrator.migrate(user).expect("migrate");
1601 assert!(
1603 result.output.contains("[[mcp.servers]]"),
1604 "array-of-tables entries must be preserved"
1605 );
1606 assert!(result.output.contains("id = \"my-server\""));
1607 }
1608
1609 #[test]
1610 fn canonical_ordering_applied() {
1611 let user = r#"
1613[memory]
1614sqlite_path = ".zeph/data/zeph.db"
1615
1616[agent]
1617name = "Test"
1618"#;
1619 let migrator = ConfigMigrator::new();
1620 let result = migrator.migrate(user).expect("migrate");
1621 let agent_pos = result.output.find("[agent]");
1623 let memory_pos = result.output.find("[memory]");
1624 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1625 assert!(a < m, "agent section should precede memory section");
1626 }
1627 }
1628
1629 #[test]
1630 fn value_to_toml_string_formats_correctly() {
1631 use toml_edit::Formatted;
1632
1633 let s = make_formatted_str("hello");
1634 assert_eq!(value_to_toml_string(&s), "\"hello\"");
1635
1636 let i = Value::Integer(Formatted::new(42_i64));
1637 assert_eq!(value_to_toml_string(&i), "42");
1638
1639 let b = Value::Boolean(Formatted::new(true));
1640 assert_eq!(value_to_toml_string(&b), "true");
1641
1642 let f = Value::Float(Formatted::new(1.0_f64));
1643 assert_eq!(value_to_toml_string(&f), "1.0");
1644
1645 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1646 assert_eq!(value_to_toml_string(&f2), "3.14");
1647
1648 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1649 let arr_val = Value::Array(arr);
1650 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1651
1652 let empty_arr = Value::Array(Array::new());
1653 assert_eq!(value_to_toml_string(&empty_arr), "[]");
1654 }
1655
1656 #[test]
1657 fn idempotent_full_output_unchanged() {
1658 let migrator = ConfigMigrator::new();
1660 let first = migrator
1661 .migrate("[agent]\nname = \"Zeph\"\n")
1662 .expect("first migrate");
1663 let second = migrator.migrate(&first.output).expect("second migrate");
1664 assert_eq!(
1665 first.output, second.output,
1666 "full output string must be identical after second migration pass"
1667 );
1668 }
1669
1670 #[test]
1671 fn full_config_produces_zero_additions() {
1672 let reference = include_str!("../config/default.toml");
1674 let migrator = ConfigMigrator::new();
1675 let result = migrator.migrate(reference).expect("migrate reference");
1676 assert_eq!(
1677 result.added_count, 0,
1678 "migrating the canonical reference should add nothing (added_count = {})",
1679 result.added_count
1680 );
1681 assert!(
1682 result.sections_added.is_empty(),
1683 "migrating the canonical reference should report no sections_added: {:?}",
1684 result.sections_added
1685 );
1686 }
1687
1688 #[test]
1689 fn empty_config_added_count_is_positive() {
1690 let migrator = ConfigMigrator::new();
1692 let result = migrator.migrate("").expect("migrate empty");
1693 assert!(
1694 result.added_count > 0,
1695 "empty config must report added_count > 0"
1696 );
1697 }
1698
1699 #[test]
1702 fn security_without_guardrail_gets_guardrail_commented() {
1703 let user = "[security]\nredact_secrets = true\n";
1704 let migrator = ConfigMigrator::new();
1705 let result = migrator.migrate(user).expect("migrate");
1706 assert!(
1708 result.output.contains("guardrail"),
1709 "migration must add guardrail keys for configs without [security.guardrail]: \
1710 got:\n{}",
1711 result.output
1712 );
1713 }
1714
1715 #[test]
1716 fn migrate_reference_contains_tools_policy() {
1717 let reference = include_str!("../config/default.toml");
1722 assert!(
1723 reference.contains("[tools.policy]"),
1724 "default.toml must contain [tools.policy] section so migrate-config can surface it"
1725 );
1726 assert!(
1727 reference.contains("enabled = false"),
1728 "tools.policy section must include enabled = false default"
1729 );
1730 }
1731
1732 #[test]
1733 fn migrate_reference_contains_probe_section() {
1734 let reference = include_str!("../config/default.toml");
1737 assert!(
1738 reference.contains("[memory.compression.probe]"),
1739 "default.toml must contain [memory.compression.probe] section comment"
1740 );
1741 assert!(
1742 reference.contains("hard_fail_threshold"),
1743 "probe section must include hard_fail_threshold default"
1744 );
1745 }
1746
1747 #[test]
1750 fn migrate_llm_no_llm_section_is_noop() {
1751 let src = "[agent]\nname = \"Zeph\"\n";
1752 let result = migrate_llm_to_providers(src).expect("migrate");
1753 assert_eq!(result.added_count, 0);
1754 assert_eq!(result.output, src);
1755 }
1756
1757 #[test]
1758 fn migrate_llm_already_new_format_is_noop() {
1759 let src = r#"
1760[llm]
1761[[llm.providers]]
1762type = "ollama"
1763model = "qwen3:8b"
1764"#;
1765 let result = migrate_llm_to_providers(src).expect("migrate");
1766 assert_eq!(result.added_count, 0);
1767 }
1768
1769 #[test]
1770 fn migrate_llm_ollama_produces_providers_block() {
1771 let src = r#"
1772[llm]
1773provider = "ollama"
1774model = "qwen3:8b"
1775base_url = "http://localhost:11434"
1776embedding_model = "nomic-embed-text"
1777"#;
1778 let result = migrate_llm_to_providers(src).expect("migrate");
1779 assert!(
1780 result.output.contains("[[llm.providers]]"),
1781 "should contain [[llm.providers]]:\n{}",
1782 result.output
1783 );
1784 assert!(
1785 result.output.contains("type = \"ollama\""),
1786 "{}",
1787 result.output
1788 );
1789 assert!(
1790 result.output.contains("model = \"qwen3:8b\""),
1791 "{}",
1792 result.output
1793 );
1794 }
1795
1796 #[test]
1797 fn migrate_llm_claude_produces_providers_block() {
1798 let src = r#"
1799[llm]
1800provider = "claude"
1801
1802[llm.cloud]
1803model = "claude-sonnet-4-6"
1804max_tokens = 8192
1805server_compaction = true
1806"#;
1807 let result = migrate_llm_to_providers(src).expect("migrate");
1808 assert!(
1809 result.output.contains("[[llm.providers]]"),
1810 "{}",
1811 result.output
1812 );
1813 assert!(
1814 result.output.contains("type = \"claude\""),
1815 "{}",
1816 result.output
1817 );
1818 assert!(
1819 result.output.contains("model = \"claude-sonnet-4-6\""),
1820 "{}",
1821 result.output
1822 );
1823 assert!(
1824 result.output.contains("server_compaction = true"),
1825 "{}",
1826 result.output
1827 );
1828 }
1829
1830 #[test]
1831 fn migrate_llm_openai_copies_fields() {
1832 let src = r#"
1833[llm]
1834provider = "openai"
1835
1836[llm.openai]
1837base_url = "https://api.openai.com/v1"
1838model = "gpt-4o"
1839max_tokens = 4096
1840"#;
1841 let result = migrate_llm_to_providers(src).expect("migrate");
1842 assert!(
1843 result.output.contains("type = \"openai\""),
1844 "{}",
1845 result.output
1846 );
1847 assert!(
1848 result
1849 .output
1850 .contains("base_url = \"https://api.openai.com/v1\""),
1851 "{}",
1852 result.output
1853 );
1854 }
1855
1856 #[test]
1857 fn migrate_llm_gemini_copies_fields() {
1858 let src = r#"
1859[llm]
1860provider = "gemini"
1861
1862[llm.gemini]
1863model = "gemini-2.0-flash"
1864max_tokens = 8192
1865base_url = "https://generativelanguage.googleapis.com"
1866"#;
1867 let result = migrate_llm_to_providers(src).expect("migrate");
1868 assert!(
1869 result.output.contains("type = \"gemini\""),
1870 "{}",
1871 result.output
1872 );
1873 assert!(
1874 result.output.contains("model = \"gemini-2.0-flash\""),
1875 "{}",
1876 result.output
1877 );
1878 }
1879
1880 #[test]
1881 fn migrate_llm_compatible_copies_multiple_entries() {
1882 let src = r#"
1883[llm]
1884provider = "compatible"
1885
1886[[llm.compatible]]
1887name = "proxy-a"
1888base_url = "http://proxy-a:8080/v1"
1889model = "llama3"
1890max_tokens = 4096
1891
1892[[llm.compatible]]
1893name = "proxy-b"
1894base_url = "http://proxy-b:8080/v1"
1895model = "mistral"
1896max_tokens = 2048
1897"#;
1898 let result = migrate_llm_to_providers(src).expect("migrate");
1899 let count = result.output.matches("[[llm.providers]]").count();
1901 assert_eq!(
1902 count, 2,
1903 "expected 2 [[llm.providers]] blocks:\n{}",
1904 result.output
1905 );
1906 assert!(
1907 result.output.contains("name = \"proxy-a\""),
1908 "{}",
1909 result.output
1910 );
1911 assert!(
1912 result.output.contains("name = \"proxy-b\""),
1913 "{}",
1914 result.output
1915 );
1916 }
1917
1918 #[test]
1919 fn migrate_llm_mixed_format_errors() {
1920 let src = r#"
1922[llm]
1923provider = "ollama"
1924
1925[[llm.providers]]
1926type = "ollama"
1927"#;
1928 assert!(
1929 migrate_llm_to_providers(src).is_err(),
1930 "mixed format must return error"
1931 );
1932 }
1933
1934 #[test]
1937 fn stt_migration_no_stt_section_returns_unchanged() {
1938 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
1939 let result = migrate_stt_to_provider(src).unwrap();
1940 assert_eq!(result.added_count, 0);
1941 assert_eq!(result.output, src);
1942 }
1943
1944 #[test]
1945 fn stt_migration_no_model_or_base_url_returns_unchanged() {
1946 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
1947 let result = migrate_stt_to_provider(src).unwrap();
1948 assert_eq!(result.added_count, 0);
1949 }
1950
1951 #[test]
1952 fn stt_migration_moves_model_to_provider_entry() {
1953 let src = r#"
1954[llm]
1955
1956[[llm.providers]]
1957type = "openai"
1958name = "quality"
1959model = "gpt-5.4"
1960
1961[llm.stt]
1962provider = "quality"
1963model = "gpt-4o-mini-transcribe"
1964language = "en"
1965"#;
1966 let result = migrate_stt_to_provider(src).unwrap();
1967 assert_eq!(result.added_count, 1);
1968 assert!(
1970 result.output.contains("stt_model"),
1971 "stt_model must be in output"
1972 );
1973 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1976 let stt = doc
1977 .get("llm")
1978 .and_then(toml_edit::Item::as_table)
1979 .and_then(|l| l.get("stt"))
1980 .and_then(toml_edit::Item::as_table)
1981 .unwrap();
1982 assert!(
1983 stt.get("model").is_none(),
1984 "model must be removed from [llm.stt]"
1985 );
1986 assert_eq!(
1987 stt.get("provider").and_then(toml_edit::Item::as_str),
1988 Some("quality")
1989 );
1990 }
1991
1992 #[test]
1993 fn stt_migration_creates_new_provider_when_no_match() {
1994 let src = r#"
1995[llm]
1996
1997[[llm.providers]]
1998type = "ollama"
1999name = "local"
2000model = "qwen3:8b"
2001
2002[llm.stt]
2003provider = "whisper"
2004model = "whisper-1"
2005base_url = "https://api.openai.com/v1"
2006language = "en"
2007"#;
2008 let result = migrate_stt_to_provider(src).unwrap();
2009 assert!(
2010 result.output.contains("openai-stt"),
2011 "new entry name must be openai-stt"
2012 );
2013 assert!(
2014 result.output.contains("stt_model"),
2015 "stt_model must be in output"
2016 );
2017 }
2018
2019 #[test]
2020 fn stt_migration_candle_whisper_creates_candle_entry() {
2021 let src = r#"
2022[llm]
2023
2024[llm.stt]
2025provider = "candle-whisper"
2026model = "openai/whisper-tiny"
2027language = "auto"
2028"#;
2029 let result = migrate_stt_to_provider(src).unwrap();
2030 assert!(
2031 result.output.contains("local-whisper"),
2032 "candle entry name must be local-whisper"
2033 );
2034 assert!(result.output.contains("candle"), "type must be candle");
2035 }
2036
2037 #[test]
2038 fn stt_migration_w2_assigns_explicit_name() {
2039 let src = r#"
2041[llm]
2042
2043[[llm.providers]]
2044type = "openai"
2045model = "gpt-5.4"
2046
2047[llm.stt]
2048provider = "openai"
2049model = "whisper-1"
2050language = "auto"
2051"#;
2052 let result = migrate_stt_to_provider(src).unwrap();
2053 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2054 let providers = doc
2055 .get("llm")
2056 .and_then(toml_edit::Item::as_table)
2057 .and_then(|l| l.get("providers"))
2058 .and_then(toml_edit::Item::as_array_of_tables)
2059 .unwrap();
2060 let entry = providers
2061 .iter()
2062 .find(|t| t.get("stt_model").is_some())
2063 .unwrap();
2064 assert!(
2066 entry.get("name").is_some(),
2067 "migrated entry must have explicit name"
2068 );
2069 }
2070
2071 #[test]
2072 fn stt_migration_removes_base_url_from_stt_table() {
2073 let src = r#"
2075[llm]
2076
2077[[llm.providers]]
2078type = "openai"
2079name = "quality"
2080model = "gpt-5.4"
2081
2082[llm.stt]
2083provider = "quality"
2084model = "whisper-1"
2085base_url = "https://api.openai.com/v1"
2086language = "en"
2087"#;
2088 let result = migrate_stt_to_provider(src).unwrap();
2089 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2090 let stt = doc
2091 .get("llm")
2092 .and_then(toml_edit::Item::as_table)
2093 .and_then(|l| l.get("stt"))
2094 .and_then(toml_edit::Item::as_table)
2095 .unwrap();
2096 assert!(
2097 stt.get("model").is_none(),
2098 "model must be removed from [llm.stt]"
2099 );
2100 assert!(
2101 stt.get("base_url").is_none(),
2102 "base_url must be removed from [llm.stt]"
2103 );
2104 }
2105
2106 #[test]
2107 fn migrate_planner_model_to_provider_with_field() {
2108 let input = r#"
2109[orchestration]
2110enabled = true
2111planner_model = "gpt-4o"
2112max_tasks = 20
2113"#;
2114 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2115 assert_eq!(result.added_count, 1, "added_count must be 1");
2116 assert!(
2117 !result.output.contains("planner_model = "),
2118 "planner_model key must be removed from output"
2119 );
2120 assert!(
2121 result.output.contains("# planner_provider"),
2122 "commented-out planner_provider entry must be present"
2123 );
2124 assert!(
2125 result.output.contains("gpt-4o"),
2126 "old value must appear in the comment"
2127 );
2128 assert!(
2129 result.output.contains("MIGRATED"),
2130 "comment must include MIGRATED marker"
2131 );
2132 }
2133
2134 #[test]
2135 fn migrate_planner_model_to_provider_no_op() {
2136 let input = r"
2137[orchestration]
2138enabled = true
2139max_tasks = 20
2140";
2141 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2142 assert_eq!(
2143 result.added_count, 0,
2144 "added_count must be 0 when field is absent"
2145 );
2146 assert_eq!(
2147 result.output, input,
2148 "output must equal input when nothing to migrate"
2149 );
2150 }
2151
2152 #[test]
2153 fn migrate_error_invalid_structure_formats_correctly() {
2154 let err = MigrateError::InvalidStructure("test sentinel");
2159 assert!(
2160 matches!(err, MigrateError::InvalidStructure(_)),
2161 "variant must match"
2162 );
2163 let msg = err.to_string();
2164 assert!(
2165 msg.contains("invalid TOML structure"),
2166 "error message must mention 'invalid TOML structure', got: {msg}"
2167 );
2168 assert!(
2169 msg.contains("test sentinel"),
2170 "message must include reason: {msg}"
2171 );
2172 }
2173
2174 #[test]
2177 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2178 let src = r#"
2179[mcp]
2180allowed_commands = ["npx"]
2181
2182[[mcp.servers]]
2183id = "srv-a"
2184command = "npx"
2185args = ["-y", "some-mcp"]
2186
2187[[mcp.servers]]
2188id = "srv-b"
2189command = "npx"
2190args = ["-y", "other-mcp"]
2191"#;
2192 let result = migrate_mcp_trust_levels(src).expect("migrate");
2193 assert_eq!(
2194 result.added_count, 2,
2195 "both entries must get trust_level added"
2196 );
2197 assert!(
2198 result
2199 .sections_added
2200 .contains(&"mcp.servers.trust_level".to_owned()),
2201 "sections_added must report mcp.servers.trust_level"
2202 );
2203 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2205 assert_eq!(
2206 occurrences, 2,
2207 "each entry must have trust_level = \"trusted\""
2208 );
2209 }
2210
2211 #[test]
2212 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2213 let src = r#"
2214[[mcp.servers]]
2215id = "srv-a"
2216command = "npx"
2217trust_level = "sandboxed"
2218tool_allowlist = ["read_file"]
2219
2220[[mcp.servers]]
2221id = "srv-b"
2222command = "npx"
2223"#;
2224 let result = migrate_mcp_trust_levels(src).expect("migrate");
2225 assert_eq!(
2227 result.added_count, 1,
2228 "only entry without trust_level gets updated"
2229 );
2230 assert!(
2232 result.output.contains("trust_level = \"sandboxed\""),
2233 "existing trust_level must not be overwritten"
2234 );
2235 assert!(
2237 result.output.contains("trust_level = \"trusted\""),
2238 "entry without trust_level must get trusted"
2239 );
2240 }
2241
2242 #[test]
2243 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2244 let src = "[agent]\nname = \"Zeph\"\n";
2245 let result = migrate_mcp_trust_levels(src).expect("migrate");
2246 assert_eq!(result.added_count, 0);
2247 assert!(result.sections_added.is_empty());
2248 assert_eq!(result.output, src);
2249 }
2250
2251 #[test]
2252 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2253 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2254 let result = migrate_mcp_trust_levels(src).expect("migrate");
2255 assert_eq!(result.added_count, 0);
2256 assert!(result.sections_added.is_empty());
2257 assert_eq!(result.output, src);
2258 }
2259
2260 #[test]
2261 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2262 let src = r#"
2263[[mcp.servers]]
2264id = "srv-a"
2265trust_level = "trusted"
2266
2267[[mcp.servers]]
2268id = "srv-b"
2269trust_level = "untrusted"
2270"#;
2271 let result = migrate_mcp_trust_levels(src).expect("migrate");
2272 assert_eq!(result.added_count, 0);
2273 assert!(result.sections_added.is_empty());
2274 }
2275
2276 #[test]
2277 fn migrate_database_url_adds_comment_when_absent() {
2278 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2279 let result = migrate_database_url(src).expect("migrate");
2280 assert_eq!(result.added_count, 1);
2281 assert!(
2282 result
2283 .sections_added
2284 .contains(&"memory.database_url".to_owned())
2285 );
2286 assert!(result.output.contains("# database_url = \"\""));
2287 }
2288
2289 #[test]
2290 fn migrate_database_url_is_noop_when_present() {
2291 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2292 let result = migrate_database_url(src).expect("migrate");
2293 assert_eq!(result.added_count, 0);
2294 assert!(result.sections_added.is_empty());
2295 assert_eq!(result.output, src);
2296 }
2297
2298 #[test]
2299 fn migrate_database_url_creates_memory_section_when_absent() {
2300 let src = "[agent]\nname = \"Zeph\"\n";
2301 let result = migrate_database_url(src).expect("migrate");
2302 assert_eq!(result.added_count, 1);
2303 assert!(result.output.contains("# database_url = \"\""));
2304 }
2305}