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
1380#[cfg(test)]
1382fn make_formatted_str(s: &str) -> Value {
1383 use toml_edit::Formatted;
1384 Value::String(Formatted::new(s.to_owned()))
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390
1391 #[test]
1392 fn empty_config_gets_sections_as_comments() {
1393 let migrator = ConfigMigrator::new();
1394 let result = migrator.migrate("").expect("migrate empty");
1395 assert!(result.added_count > 0 || !result.sections_added.is_empty());
1397 assert!(
1399 result.output.contains("[agent]") || result.output.contains("# [agent]"),
1400 "expected agent section in output, got:\n{}",
1401 result.output
1402 );
1403 }
1404
1405 #[test]
1406 fn existing_values_not_overwritten() {
1407 let user = r#"
1408[agent]
1409name = "MyAgent"
1410max_tool_iterations = 5
1411"#;
1412 let migrator = ConfigMigrator::new();
1413 let result = migrator.migrate(user).expect("migrate");
1414 assert!(
1416 result.output.contains("name = \"MyAgent\""),
1417 "user value should be preserved"
1418 );
1419 assert!(
1420 result.output.contains("max_tool_iterations = 5"),
1421 "user value should be preserved"
1422 );
1423 assert!(
1425 !result.output.contains("# max_tool_iterations = 10"),
1426 "already-set key should not appear as comment"
1427 );
1428 }
1429
1430 #[test]
1431 fn missing_nested_key_added_as_comment() {
1432 let user = r#"
1434[memory]
1435sqlite_path = ".zeph/data/zeph.db"
1436"#;
1437 let migrator = ConfigMigrator::new();
1438 let result = migrator.migrate(user).expect("migrate");
1439 assert!(
1441 result.output.contains("# history_limit"),
1442 "missing key should be added as comment, got:\n{}",
1443 result.output
1444 );
1445 }
1446
1447 #[test]
1448 fn unknown_user_keys_preserved() {
1449 let user = r#"
1450[agent]
1451name = "Test"
1452my_custom_key = "preserved"
1453"#;
1454 let migrator = ConfigMigrator::new();
1455 let result = migrator.migrate(user).expect("migrate");
1456 assert!(
1457 result.output.contains("my_custom_key = \"preserved\""),
1458 "custom user keys must not be removed"
1459 );
1460 }
1461
1462 #[test]
1463 fn idempotent() {
1464 let migrator = ConfigMigrator::new();
1465 let first = migrator
1466 .migrate("[agent]\nname = \"Zeph\"\n")
1467 .expect("first migrate");
1468 let second = migrator.migrate(&first.output).expect("second migrate");
1469 assert_eq!(
1470 first.output, second.output,
1471 "idempotent: full output must be identical on second run"
1472 );
1473 }
1474
1475 #[test]
1476 fn malformed_input_returns_error() {
1477 let migrator = ConfigMigrator::new();
1478 let err = migrator
1479 .migrate("[[invalid toml [[[")
1480 .expect_err("should error");
1481 assert!(
1482 matches!(err, MigrateError::Parse(_)),
1483 "expected Parse error"
1484 );
1485 }
1486
1487 #[test]
1488 fn array_of_tables_preserved() {
1489 let user = r#"
1490[mcp]
1491allowed_commands = ["npx"]
1492
1493[[mcp.servers]]
1494id = "my-server"
1495command = "npx"
1496args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1497"#;
1498 let migrator = ConfigMigrator::new();
1499 let result = migrator.migrate(user).expect("migrate");
1500 assert!(
1502 result.output.contains("[[mcp.servers]]"),
1503 "array-of-tables entries must be preserved"
1504 );
1505 assert!(result.output.contains("id = \"my-server\""));
1506 }
1507
1508 #[test]
1509 fn canonical_ordering_applied() {
1510 let user = r#"
1512[memory]
1513sqlite_path = ".zeph/data/zeph.db"
1514
1515[agent]
1516name = "Test"
1517"#;
1518 let migrator = ConfigMigrator::new();
1519 let result = migrator.migrate(user).expect("migrate");
1520 let agent_pos = result.output.find("[agent]");
1522 let memory_pos = result.output.find("[memory]");
1523 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1524 assert!(a < m, "agent section should precede memory section");
1525 }
1526 }
1527
1528 #[test]
1529 fn value_to_toml_string_formats_correctly() {
1530 use toml_edit::Formatted;
1531
1532 let s = make_formatted_str("hello");
1533 assert_eq!(value_to_toml_string(&s), "\"hello\"");
1534
1535 let i = Value::Integer(Formatted::new(42_i64));
1536 assert_eq!(value_to_toml_string(&i), "42");
1537
1538 let b = Value::Boolean(Formatted::new(true));
1539 assert_eq!(value_to_toml_string(&b), "true");
1540
1541 let f = Value::Float(Formatted::new(1.0_f64));
1542 assert_eq!(value_to_toml_string(&f), "1.0");
1543
1544 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1545 assert_eq!(value_to_toml_string(&f2), "3.14");
1546
1547 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1548 let arr_val = Value::Array(arr);
1549 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1550
1551 let empty_arr = Value::Array(Array::new());
1552 assert_eq!(value_to_toml_string(&empty_arr), "[]");
1553 }
1554
1555 #[test]
1556 fn idempotent_full_output_unchanged() {
1557 let migrator = ConfigMigrator::new();
1559 let first = migrator
1560 .migrate("[agent]\nname = \"Zeph\"\n")
1561 .expect("first migrate");
1562 let second = migrator.migrate(&first.output).expect("second migrate");
1563 assert_eq!(
1564 first.output, second.output,
1565 "full output string must be identical after second migration pass"
1566 );
1567 }
1568
1569 #[test]
1570 fn full_config_produces_zero_additions() {
1571 let reference = include_str!("../config/default.toml");
1573 let migrator = ConfigMigrator::new();
1574 let result = migrator.migrate(reference).expect("migrate reference");
1575 assert_eq!(
1576 result.added_count, 0,
1577 "migrating the canonical reference should add nothing (added_count = {})",
1578 result.added_count
1579 );
1580 assert!(
1581 result.sections_added.is_empty(),
1582 "migrating the canonical reference should report no sections_added: {:?}",
1583 result.sections_added
1584 );
1585 }
1586
1587 #[test]
1588 fn empty_config_added_count_is_positive() {
1589 let migrator = ConfigMigrator::new();
1591 let result = migrator.migrate("").expect("migrate empty");
1592 assert!(
1593 result.added_count > 0,
1594 "empty config must report added_count > 0"
1595 );
1596 }
1597
1598 #[test]
1601 fn security_without_guardrail_gets_guardrail_commented() {
1602 let user = "[security]\nredact_secrets = true\n";
1603 let migrator = ConfigMigrator::new();
1604 let result = migrator.migrate(user).expect("migrate");
1605 assert!(
1607 result.output.contains("guardrail"),
1608 "migration must add guardrail keys for configs without [security.guardrail]: \
1609 got:\n{}",
1610 result.output
1611 );
1612 }
1613
1614 #[test]
1615 fn migrate_reference_contains_tools_policy() {
1616 let reference = include_str!("../config/default.toml");
1621 assert!(
1622 reference.contains("[tools.policy]"),
1623 "default.toml must contain [tools.policy] section so migrate-config can surface it"
1624 );
1625 assert!(
1626 reference.contains("enabled = false"),
1627 "tools.policy section must include enabled = false default"
1628 );
1629 }
1630
1631 #[test]
1632 fn migrate_reference_contains_probe_section() {
1633 let reference = include_str!("../config/default.toml");
1636 assert!(
1637 reference.contains("[memory.compression.probe]"),
1638 "default.toml must contain [memory.compression.probe] section comment"
1639 );
1640 assert!(
1641 reference.contains("hard_fail_threshold"),
1642 "probe section must include hard_fail_threshold default"
1643 );
1644 }
1645
1646 #[test]
1649 fn migrate_llm_no_llm_section_is_noop() {
1650 let src = "[agent]\nname = \"Zeph\"\n";
1651 let result = migrate_llm_to_providers(src).expect("migrate");
1652 assert_eq!(result.added_count, 0);
1653 assert_eq!(result.output, src);
1654 }
1655
1656 #[test]
1657 fn migrate_llm_already_new_format_is_noop() {
1658 let src = r#"
1659[llm]
1660[[llm.providers]]
1661type = "ollama"
1662model = "qwen3:8b"
1663"#;
1664 let result = migrate_llm_to_providers(src).expect("migrate");
1665 assert_eq!(result.added_count, 0);
1666 }
1667
1668 #[test]
1669 fn migrate_llm_ollama_produces_providers_block() {
1670 let src = r#"
1671[llm]
1672provider = "ollama"
1673model = "qwen3:8b"
1674base_url = "http://localhost:11434"
1675embedding_model = "nomic-embed-text"
1676"#;
1677 let result = migrate_llm_to_providers(src).expect("migrate");
1678 assert!(
1679 result.output.contains("[[llm.providers]]"),
1680 "should contain [[llm.providers]]:\n{}",
1681 result.output
1682 );
1683 assert!(
1684 result.output.contains("type = \"ollama\""),
1685 "{}",
1686 result.output
1687 );
1688 assert!(
1689 result.output.contains("model = \"qwen3:8b\""),
1690 "{}",
1691 result.output
1692 );
1693 }
1694
1695 #[test]
1696 fn migrate_llm_claude_produces_providers_block() {
1697 let src = r#"
1698[llm]
1699provider = "claude"
1700
1701[llm.cloud]
1702model = "claude-sonnet-4-6"
1703max_tokens = 8192
1704server_compaction = true
1705"#;
1706 let result = migrate_llm_to_providers(src).expect("migrate");
1707 assert!(
1708 result.output.contains("[[llm.providers]]"),
1709 "{}",
1710 result.output
1711 );
1712 assert!(
1713 result.output.contains("type = \"claude\""),
1714 "{}",
1715 result.output
1716 );
1717 assert!(
1718 result.output.contains("model = \"claude-sonnet-4-6\""),
1719 "{}",
1720 result.output
1721 );
1722 assert!(
1723 result.output.contains("server_compaction = true"),
1724 "{}",
1725 result.output
1726 );
1727 }
1728
1729 #[test]
1730 fn migrate_llm_openai_copies_fields() {
1731 let src = r#"
1732[llm]
1733provider = "openai"
1734
1735[llm.openai]
1736base_url = "https://api.openai.com/v1"
1737model = "gpt-4o"
1738max_tokens = 4096
1739"#;
1740 let result = migrate_llm_to_providers(src).expect("migrate");
1741 assert!(
1742 result.output.contains("type = \"openai\""),
1743 "{}",
1744 result.output
1745 );
1746 assert!(
1747 result
1748 .output
1749 .contains("base_url = \"https://api.openai.com/v1\""),
1750 "{}",
1751 result.output
1752 );
1753 }
1754
1755 #[test]
1756 fn migrate_llm_gemini_copies_fields() {
1757 let src = r#"
1758[llm]
1759provider = "gemini"
1760
1761[llm.gemini]
1762model = "gemini-2.0-flash"
1763max_tokens = 8192
1764base_url = "https://generativelanguage.googleapis.com"
1765"#;
1766 let result = migrate_llm_to_providers(src).expect("migrate");
1767 assert!(
1768 result.output.contains("type = \"gemini\""),
1769 "{}",
1770 result.output
1771 );
1772 assert!(
1773 result.output.contains("model = \"gemini-2.0-flash\""),
1774 "{}",
1775 result.output
1776 );
1777 }
1778
1779 #[test]
1780 fn migrate_llm_compatible_copies_multiple_entries() {
1781 let src = r#"
1782[llm]
1783provider = "compatible"
1784
1785[[llm.compatible]]
1786name = "proxy-a"
1787base_url = "http://proxy-a:8080/v1"
1788model = "llama3"
1789max_tokens = 4096
1790
1791[[llm.compatible]]
1792name = "proxy-b"
1793base_url = "http://proxy-b:8080/v1"
1794model = "mistral"
1795max_tokens = 2048
1796"#;
1797 let result = migrate_llm_to_providers(src).expect("migrate");
1798 let count = result.output.matches("[[llm.providers]]").count();
1800 assert_eq!(
1801 count, 2,
1802 "expected 2 [[llm.providers]] blocks:\n{}",
1803 result.output
1804 );
1805 assert!(
1806 result.output.contains("name = \"proxy-a\""),
1807 "{}",
1808 result.output
1809 );
1810 assert!(
1811 result.output.contains("name = \"proxy-b\""),
1812 "{}",
1813 result.output
1814 );
1815 }
1816
1817 #[test]
1818 fn migrate_llm_mixed_format_errors() {
1819 let src = r#"
1821[llm]
1822provider = "ollama"
1823
1824[[llm.providers]]
1825type = "ollama"
1826"#;
1827 assert!(
1828 migrate_llm_to_providers(src).is_err(),
1829 "mixed format must return error"
1830 );
1831 }
1832
1833 #[test]
1836 fn stt_migration_no_stt_section_returns_unchanged() {
1837 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
1838 let result = migrate_stt_to_provider(src).unwrap();
1839 assert_eq!(result.added_count, 0);
1840 assert_eq!(result.output, src);
1841 }
1842
1843 #[test]
1844 fn stt_migration_no_model_or_base_url_returns_unchanged() {
1845 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
1846 let result = migrate_stt_to_provider(src).unwrap();
1847 assert_eq!(result.added_count, 0);
1848 }
1849
1850 #[test]
1851 fn stt_migration_moves_model_to_provider_entry() {
1852 let src = r#"
1853[llm]
1854
1855[[llm.providers]]
1856type = "openai"
1857name = "quality"
1858model = "gpt-5.4"
1859
1860[llm.stt]
1861provider = "quality"
1862model = "gpt-4o-mini-transcribe"
1863language = "en"
1864"#;
1865 let result = migrate_stt_to_provider(src).unwrap();
1866 assert_eq!(result.added_count, 1);
1867 assert!(
1869 result.output.contains("stt_model"),
1870 "stt_model must be in output"
1871 );
1872 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1875 let stt = doc
1876 .get("llm")
1877 .and_then(toml_edit::Item::as_table)
1878 .and_then(|l| l.get("stt"))
1879 .and_then(toml_edit::Item::as_table)
1880 .unwrap();
1881 assert!(
1882 stt.get("model").is_none(),
1883 "model must be removed from [llm.stt]"
1884 );
1885 assert_eq!(
1886 stt.get("provider").and_then(toml_edit::Item::as_str),
1887 Some("quality")
1888 );
1889 }
1890
1891 #[test]
1892 fn stt_migration_creates_new_provider_when_no_match() {
1893 let src = r#"
1894[llm]
1895
1896[[llm.providers]]
1897type = "ollama"
1898name = "local"
1899model = "qwen3:8b"
1900
1901[llm.stt]
1902provider = "whisper"
1903model = "whisper-1"
1904base_url = "https://api.openai.com/v1"
1905language = "en"
1906"#;
1907 let result = migrate_stt_to_provider(src).unwrap();
1908 assert!(
1909 result.output.contains("openai-stt"),
1910 "new entry name must be openai-stt"
1911 );
1912 assert!(
1913 result.output.contains("stt_model"),
1914 "stt_model must be in output"
1915 );
1916 }
1917
1918 #[test]
1919 fn stt_migration_candle_whisper_creates_candle_entry() {
1920 let src = r#"
1921[llm]
1922
1923[llm.stt]
1924provider = "candle-whisper"
1925model = "openai/whisper-tiny"
1926language = "auto"
1927"#;
1928 let result = migrate_stt_to_provider(src).unwrap();
1929 assert!(
1930 result.output.contains("local-whisper"),
1931 "candle entry name must be local-whisper"
1932 );
1933 assert!(result.output.contains("candle"), "type must be candle");
1934 }
1935
1936 #[test]
1937 fn stt_migration_w2_assigns_explicit_name() {
1938 let src = r#"
1940[llm]
1941
1942[[llm.providers]]
1943type = "openai"
1944model = "gpt-5.4"
1945
1946[llm.stt]
1947provider = "openai"
1948model = "whisper-1"
1949language = "auto"
1950"#;
1951 let result = migrate_stt_to_provider(src).unwrap();
1952 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1953 let providers = doc
1954 .get("llm")
1955 .and_then(toml_edit::Item::as_table)
1956 .and_then(|l| l.get("providers"))
1957 .and_then(toml_edit::Item::as_array_of_tables)
1958 .unwrap();
1959 let entry = providers
1960 .iter()
1961 .find(|t| t.get("stt_model").is_some())
1962 .unwrap();
1963 assert!(
1965 entry.get("name").is_some(),
1966 "migrated entry must have explicit name"
1967 );
1968 }
1969
1970 #[test]
1971 fn stt_migration_removes_base_url_from_stt_table() {
1972 let src = r#"
1974[llm]
1975
1976[[llm.providers]]
1977type = "openai"
1978name = "quality"
1979model = "gpt-5.4"
1980
1981[llm.stt]
1982provider = "quality"
1983model = "whisper-1"
1984base_url = "https://api.openai.com/v1"
1985language = "en"
1986"#;
1987 let result = migrate_stt_to_provider(src).unwrap();
1988 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
1989 let stt = doc
1990 .get("llm")
1991 .and_then(toml_edit::Item::as_table)
1992 .and_then(|l| l.get("stt"))
1993 .and_then(toml_edit::Item::as_table)
1994 .unwrap();
1995 assert!(
1996 stt.get("model").is_none(),
1997 "model must be removed from [llm.stt]"
1998 );
1999 assert!(
2000 stt.get("base_url").is_none(),
2001 "base_url must be removed from [llm.stt]"
2002 );
2003 }
2004
2005 #[test]
2006 fn migrate_planner_model_to_provider_with_field() {
2007 let input = r#"
2008[orchestration]
2009enabled = true
2010planner_model = "gpt-4o"
2011max_tasks = 20
2012"#;
2013 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2014 assert_eq!(result.added_count, 1, "added_count must be 1");
2015 assert!(
2016 !result.output.contains("planner_model = "),
2017 "planner_model key must be removed from output"
2018 );
2019 assert!(
2020 result.output.contains("# planner_provider"),
2021 "commented-out planner_provider entry must be present"
2022 );
2023 assert!(
2024 result.output.contains("gpt-4o"),
2025 "old value must appear in the comment"
2026 );
2027 assert!(
2028 result.output.contains("MIGRATED"),
2029 "comment must include MIGRATED marker"
2030 );
2031 }
2032
2033 #[test]
2034 fn migrate_planner_model_to_provider_no_op() {
2035 let input = r"
2036[orchestration]
2037enabled = true
2038max_tasks = 20
2039";
2040 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2041 assert_eq!(
2042 result.added_count, 0,
2043 "added_count must be 0 when field is absent"
2044 );
2045 assert_eq!(
2046 result.output, input,
2047 "output must equal input when nothing to migrate"
2048 );
2049 }
2050
2051 #[test]
2052 fn migrate_error_invalid_structure_formats_correctly() {
2053 let err = MigrateError::InvalidStructure("test sentinel");
2058 assert!(
2059 matches!(err, MigrateError::InvalidStructure(_)),
2060 "variant must match"
2061 );
2062 let msg = err.to_string();
2063 assert!(
2064 msg.contains("invalid TOML structure"),
2065 "error message must mention 'invalid TOML structure', got: {msg}"
2066 );
2067 assert!(
2068 msg.contains("test sentinel"),
2069 "message must include reason: {msg}"
2070 );
2071 }
2072
2073 #[test]
2076 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2077 let src = r#"
2078[mcp]
2079allowed_commands = ["npx"]
2080
2081[[mcp.servers]]
2082id = "srv-a"
2083command = "npx"
2084args = ["-y", "some-mcp"]
2085
2086[[mcp.servers]]
2087id = "srv-b"
2088command = "npx"
2089args = ["-y", "other-mcp"]
2090"#;
2091 let result = migrate_mcp_trust_levels(src).expect("migrate");
2092 assert_eq!(
2093 result.added_count, 2,
2094 "both entries must get trust_level added"
2095 );
2096 assert!(
2097 result
2098 .sections_added
2099 .contains(&"mcp.servers.trust_level".to_owned()),
2100 "sections_added must report mcp.servers.trust_level"
2101 );
2102 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2104 assert_eq!(
2105 occurrences, 2,
2106 "each entry must have trust_level = \"trusted\""
2107 );
2108 }
2109
2110 #[test]
2111 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2112 let src = r#"
2113[[mcp.servers]]
2114id = "srv-a"
2115command = "npx"
2116trust_level = "sandboxed"
2117tool_allowlist = ["read_file"]
2118
2119[[mcp.servers]]
2120id = "srv-b"
2121command = "npx"
2122"#;
2123 let result = migrate_mcp_trust_levels(src).expect("migrate");
2124 assert_eq!(
2126 result.added_count, 1,
2127 "only entry without trust_level gets updated"
2128 );
2129 assert!(
2131 result.output.contains("trust_level = \"sandboxed\""),
2132 "existing trust_level must not be overwritten"
2133 );
2134 assert!(
2136 result.output.contains("trust_level = \"trusted\""),
2137 "entry without trust_level must get trusted"
2138 );
2139 }
2140
2141 #[test]
2142 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2143 let src = "[agent]\nname = \"Zeph\"\n";
2144 let result = migrate_mcp_trust_levels(src).expect("migrate");
2145 assert_eq!(result.added_count, 0);
2146 assert!(result.sections_added.is_empty());
2147 assert_eq!(result.output, src);
2148 }
2149
2150 #[test]
2151 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2152 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2153 let result = migrate_mcp_trust_levels(src).expect("migrate");
2154 assert_eq!(result.added_count, 0);
2155 assert!(result.sections_added.is_empty());
2156 assert_eq!(result.output, src);
2157 }
2158
2159 #[test]
2160 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2161 let src = r#"
2162[[mcp.servers]]
2163id = "srv-a"
2164trust_level = "trusted"
2165
2166[[mcp.servers]]
2167id = "srv-b"
2168trust_level = "untrusted"
2169"#;
2170 let result = migrate_mcp_trust_levels(src).expect("migrate");
2171 assert_eq!(result.added_count, 0);
2172 assert!(result.sections_added.is_empty());
2173 }
2174}