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(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
457fn migrate_ollama_provider(
458 llm: &toml_edit::Table,
459 model: &Option<String>,
460 base_url: &Option<String>,
461 embedding_model: &Option<String>,
462) -> Vec<String> {
463 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
464 if let Some(m) = model {
465 block.push_str(&format!("model = \"{m}\"\n"));
466 }
467 if let Some(em) = embedding_model {
468 block.push_str(&format!("embedding_model = \"{em}\"\n"));
469 }
470 if let Some(u) = base_url {
471 block.push_str(&format!("base_url = \"{u}\"\n"));
472 }
473 let _ = llm; vec![block]
475}
476
477#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
478fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
479 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
480 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
481 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
482 block.push_str(&format!("model = \"{m}\"\n"));
483 }
484 if let Some(t) = cloud
485 .get("max_tokens")
486 .and_then(toml_edit::Item::as_integer)
487 {
488 block.push_str(&format!("max_tokens = {t}\n"));
489 }
490 if cloud
491 .get("server_compaction")
492 .and_then(toml_edit::Item::as_bool)
493 == Some(true)
494 {
495 block.push_str("server_compaction = true\n");
496 }
497 if cloud
498 .get("enable_extended_context")
499 .and_then(toml_edit::Item::as_bool)
500 == Some(true)
501 {
502 block.push_str("enable_extended_context = true\n");
503 }
504 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
505 let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
506 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
507 }
508 } else if let Some(m) = model {
509 block.push_str(&format!("model = \"{m}\"\n"));
510 }
511 vec![block]
512}
513
514#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
515fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
516 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
517 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
518 copy_str_field(openai, "model", &mut block);
519 copy_str_field(openai, "base_url", &mut block);
520 copy_int_field(openai, "max_tokens", &mut block);
521 copy_str_field(openai, "embedding_model", &mut block);
522 copy_str_field(openai, "reasoning_effort", &mut block);
523 } else if let Some(m) = model {
524 block.push_str(&format!("model = \"{m}\"\n"));
525 }
526 vec![block]
527}
528
529#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
530fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
531 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
532 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
533 copy_str_field(gemini, "model", &mut block);
534 copy_int_field(gemini, "max_tokens", &mut block);
535 copy_str_field(gemini, "base_url", &mut block);
536 copy_str_field(gemini, "embedding_model", &mut block);
537 copy_str_field(gemini, "thinking_level", &mut block);
538 copy_int_field(gemini, "thinking_budget", &mut block);
539 if let Some(v) = gemini
540 .get("include_thoughts")
541 .and_then(toml_edit::Item::as_bool)
542 {
543 block.push_str(&format!("include_thoughts = {v}\n"));
544 }
545 } else if let Some(m) = model {
546 block.push_str(&format!("model = \"{m}\"\n"));
547 }
548 vec![block]
549}
550
551#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
552fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
553 let mut blocks = Vec::new();
554 if let Some(compat_arr) = llm
555 .get("compatible")
556 .and_then(toml_edit::Item::as_array_of_tables)
557 {
558 for entry in compat_arr {
559 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
560 copy_str_field(entry, "name", &mut block);
561 copy_str_field(entry, "base_url", &mut block);
562 copy_str_field(entry, "model", &mut block);
563 copy_int_field(entry, "max_tokens", &mut block);
564 copy_str_field(entry, "embedding_model", &mut block);
565 blocks.push(block);
566 }
567 }
568 blocks
569}
570
571#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
573fn migrate_orchestrator_provider(
574 llm: &toml_edit::Table,
575 model: &Option<String>,
576 base_url: &Option<String>,
577 embedding_model: &Option<String>,
578) -> (Vec<String>, Option<String>, Option<String>) {
579 let mut blocks = Vec::new();
580 let routing = Some("task".to_owned());
581 let mut routes_block = None;
582 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
583 let default_name = orch
584 .get("default")
585 .and_then(toml_edit::Item::as_str)
586 .unwrap_or("")
587 .to_owned();
588 let embed_name = orch
589 .get("embed")
590 .and_then(toml_edit::Item::as_str)
591 .unwrap_or("")
592 .to_owned();
593 if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
594 let mut rb = "[llm.routes]\n".to_owned();
595 for (key, val) in routes {
596 if let Some(arr) = val.as_array() {
597 let items: Vec<String> = arr
598 .iter()
599 .filter_map(toml_edit::Value::as_str)
600 .map(|s| format!("\"{s}\""))
601 .collect();
602 rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
603 }
604 }
605 routes_block = Some(rb);
606 }
607 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
608 for (name, pcfg_item) in providers {
609 let Some(pcfg) = pcfg_item.as_table() else {
610 continue;
611 };
612 let ptype = pcfg
613 .get("type")
614 .and_then(toml_edit::Item::as_str)
615 .unwrap_or("ollama");
616 let mut block =
617 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
618 if name == default_name {
619 block.push_str("default = true\n");
620 }
621 if name == embed_name {
622 block.push_str("embed = true\n");
623 }
624 copy_str_field(pcfg, "model", &mut block);
625 copy_str_field(pcfg, "base_url", &mut block);
626 copy_str_field(pcfg, "embedding_model", &mut block);
627 if ptype == "claude" && !pcfg.contains_key("model") {
628 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
629 copy_str_field(cloud, "model", &mut block);
630 copy_int_field(cloud, "max_tokens", &mut block);
631 }
632 }
633 if ptype == "openai" && !pcfg.contains_key("model") {
634 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
635 copy_str_field(openai, "model", &mut block);
636 copy_str_field(openai, "base_url", &mut block);
637 copy_int_field(openai, "max_tokens", &mut block);
638 copy_str_field(openai, "embedding_model", &mut block);
639 }
640 }
641 if ptype == "ollama" && !pcfg.contains_key("base_url") {
642 if let Some(u) = base_url {
643 block.push_str(&format!("base_url = \"{u}\"\n"));
644 }
645 }
646 if ptype == "ollama" && !pcfg.contains_key("model") {
647 if let Some(m) = model {
648 block.push_str(&format!("model = \"{m}\"\n"));
649 }
650 }
651 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
652 if let Some(em) = embedding_model {
653 block.push_str(&format!("embedding_model = \"{em}\"\n"));
654 }
655 }
656 blocks.push(block);
657 }
658 }
659 }
660 (blocks, routing, routes_block)
661}
662
663#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
665fn migrate_router_provider(
666 llm: &toml_edit::Table,
667 model: &Option<String>,
668 base_url: &Option<String>,
669 embedding_model: &Option<String>,
670) -> (Vec<String>, Option<String>) {
671 let mut blocks = Vec::new();
672 let mut routing = None;
673 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
674 let strategy = router
675 .get("strategy")
676 .and_then(toml_edit::Item::as_str)
677 .unwrap_or("ema");
678 routing = Some(strategy.to_owned());
679 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
680 for item in chain {
681 let name = item.as_str().unwrap_or_default();
682 let ptype = infer_provider_type(name, llm);
683 let mut block =
684 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
685 match ptype {
686 "claude" => {
687 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
688 copy_str_field(cloud, "model", &mut block);
689 copy_int_field(cloud, "max_tokens", &mut block);
690 }
691 }
692 "openai" => {
693 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
694 {
695 copy_str_field(openai, "model", &mut block);
696 copy_str_field(openai, "base_url", &mut block);
697 copy_int_field(openai, "max_tokens", &mut block);
698 copy_str_field(openai, "embedding_model", &mut block);
699 } else {
700 if let Some(m) = model {
701 block.push_str(&format!("model = \"{m}\"\n"));
702 }
703 if let Some(u) = base_url {
704 block.push_str(&format!("base_url = \"{u}\"\n"));
705 }
706 }
707 }
708 "ollama" => {
709 if let Some(m) = model {
710 block.push_str(&format!("model = \"{m}\"\n"));
711 }
712 if let Some(em) = embedding_model {
713 block.push_str(&format!("embedding_model = \"{em}\"\n"));
714 }
715 if let Some(u) = base_url {
716 block.push_str(&format!("base_url = \"{u}\"\n"));
717 }
718 }
719 _ => {
720 if let Some(m) = model {
721 block.push_str(&format!("model = \"{m}\"\n"));
722 }
723 }
724 }
725 blocks.push(block);
726 }
727 }
728 }
729 (blocks, routing)
730}
731
732#[allow(
743 clippy::too_many_lines,
744 clippy::format_push_string,
745 clippy::manual_let_else,
746 clippy::op_ref,
747 clippy::collapsible_if
748)]
749pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
750 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
751
752 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
754 Some(t) => t,
755 None => {
756 return Ok(MigrationResult {
758 output: toml_src.to_owned(),
759 added_count: 0,
760 sections_added: Vec::new(),
761 });
762 }
763 };
764
765 let has_provider_field = llm.contains_key("provider");
766 let has_cloud = llm.contains_key("cloud");
767 let has_openai = llm.contains_key("openai");
768 let has_gemini = llm.contains_key("gemini");
769 let has_orchestrator = llm.contains_key("orchestrator");
770 let has_router = llm.contains_key("router");
771 let has_providers = llm.contains_key("providers");
772
773 if !has_provider_field
774 && !has_cloud
775 && !has_openai
776 && !has_orchestrator
777 && !has_router
778 && !has_gemini
779 {
780 return Ok(MigrationResult {
782 output: toml_src.to_owned(),
783 added_count: 0,
784 sections_added: Vec::new(),
785 });
786 }
787
788 if has_providers {
789 return Err(MigrateError::Parse(
791 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
792 .parse::<toml_edit::DocumentMut>()
793 .unwrap_err(),
794 ));
795 }
796
797 let provider_str = llm
799 .get("provider")
800 .and_then(toml_edit::Item::as_str)
801 .unwrap_or("ollama");
802 let base_url = llm
803 .get("base_url")
804 .and_then(toml_edit::Item::as_str)
805 .map(str::to_owned);
806 let model = llm
807 .get("model")
808 .and_then(toml_edit::Item::as_str)
809 .map(str::to_owned);
810 let embedding_model = llm
811 .get("embedding_model")
812 .and_then(toml_edit::Item::as_str)
813 .map(str::to_owned);
814
815 let mut provider_blocks: Vec<String> = Vec::new();
817 let mut routing: Option<String> = None;
818 let mut routes_block: Option<String> = None;
819
820 match provider_str {
821 "ollama" => {
822 provider_blocks.extend(migrate_ollama_provider(
823 llm,
824 &model,
825 &base_url,
826 &embedding_model,
827 ));
828 }
829 "claude" => {
830 provider_blocks.extend(migrate_claude_provider(llm, &model));
831 }
832 "openai" => {
833 provider_blocks.extend(migrate_openai_provider(llm, &model));
834 }
835 "gemini" => {
836 provider_blocks.extend(migrate_gemini_provider(llm, &model));
837 }
838 "compatible" => {
839 provider_blocks.extend(migrate_compatible_provider(llm));
840 }
841 "orchestrator" => {
842 let (blocks, r, rb) =
843 migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
844 provider_blocks.extend(blocks);
845 routing = r;
846 routes_block = rb;
847 }
848 "router" => {
849 let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
850 provider_blocks.extend(blocks);
851 routing = r;
852 }
853 other => {
854 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
855 if let Some(ref m) = model {
856 block.push_str(&format!("model = \"{m}\"\n"));
857 }
858 provider_blocks.push(block);
859 }
860 }
861
862 if provider_blocks.is_empty() {
863 return Ok(MigrationResult {
865 output: toml_src.to_owned(),
866 added_count: 0,
867 sections_added: Vec::new(),
868 });
869 }
870
871 let mut new_llm = "[llm]\n".to_owned();
873 if let Some(ref r) = routing {
874 new_llm.push_str(&format!("routing = \"{r}\"\n"));
875 }
876 for key in &[
878 "response_cache_enabled",
879 "response_cache_ttl_secs",
880 "semantic_cache_enabled",
881 "semantic_cache_threshold",
882 "semantic_cache_max_candidates",
883 "summary_model",
884 "instruction_file",
885 ] {
886 if let Some(val) = llm.get(key) {
887 if let Some(v) = val.as_value() {
888 let raw = value_to_toml_string(v);
889 if !raw.is_empty() {
890 new_llm.push_str(&format!("{key} = {raw}\n"));
891 }
892 }
893 }
894 }
895 new_llm.push('\n');
896
897 if let Some(rb) = routes_block {
898 new_llm.push_str(&rb);
899 new_llm.push('\n');
900 }
901
902 for block in &provider_blocks {
903 new_llm.push_str(block);
904 new_llm.push('\n');
905 }
906
907 let output = replace_llm_section(toml_src, &new_llm);
910
911 Ok(MigrationResult {
912 output,
913 added_count: provider_blocks.len(),
914 sections_added: vec!["llm.providers".to_owned()],
915 })
916}
917
918fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
920 match name {
921 "claude" => "claude",
922 "openai" => "openai",
923 "gemini" => "gemini",
924 "ollama" => "ollama",
925 "candle" => "candle",
926 _ => {
927 if llm.contains_key("compatible") {
929 "compatible"
930 } else if llm.contains_key("openai") {
931 "openai"
932 } else {
933 "ollama"
934 }
935 }
936 }
937}
938
939fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
940 use std::fmt::Write as _;
941 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
942 let _ = writeln!(out, "{key} = \"{v}\"");
943 }
944}
945
946fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
947 use std::fmt::Write as _;
948 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
949 let _ = writeln!(out, "{key} = {v}");
950 }
951}
952
953fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
956 let mut out = String::new();
957 let mut in_llm = false;
958 let mut skip_until_next_top = false;
959
960 for line in toml_str.lines() {
961 let trimmed = line.trim();
962
963 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
965 && trimmed.ends_with(']')
966 && !trimmed[1..trimmed.len() - 1].contains('.');
967 let is_top_aot = trimmed.starts_with("[[")
968 && trimmed.ends_with("]]")
969 && !trimmed[2..trimmed.len() - 2].contains('.');
970 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
971 && (trimmed.contains(']'));
972
973 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
974 in_llm = true;
975 skip_until_next_top = true;
976 continue;
977 }
978
979 if is_top_section || is_top_aot {
980 if skip_until_next_top {
981 out.push_str(new_llm_section);
983 skip_until_next_top = false;
984 }
985 in_llm = false;
986 }
987
988 if !skip_until_next_top {
989 out.push_str(line);
990 out.push('\n');
991 }
992 }
993
994 if skip_until_next_top {
996 out.push_str(new_llm_section);
997 }
998
999 out
1000}
1001
1002#[allow(clippy::too_many_lines)]
1021pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1022 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1023
1024 let stt_model = doc
1026 .get("llm")
1027 .and_then(toml_edit::Item::as_table)
1028 .and_then(|llm| llm.get("stt"))
1029 .and_then(toml_edit::Item::as_table)
1030 .and_then(|stt| stt.get("model"))
1031 .and_then(toml_edit::Item::as_str)
1032 .map(ToOwned::to_owned);
1033
1034 let stt_base_url = doc
1035 .get("llm")
1036 .and_then(toml_edit::Item::as_table)
1037 .and_then(|llm| llm.get("stt"))
1038 .and_then(toml_edit::Item::as_table)
1039 .and_then(|stt| stt.get("base_url"))
1040 .and_then(toml_edit::Item::as_str)
1041 .map(ToOwned::to_owned);
1042
1043 let stt_provider_hint = doc
1044 .get("llm")
1045 .and_then(toml_edit::Item::as_table)
1046 .and_then(|llm| llm.get("stt"))
1047 .and_then(toml_edit::Item::as_table)
1048 .and_then(|stt| stt.get("provider"))
1049 .and_then(toml_edit::Item::as_str)
1050 .map(ToOwned::to_owned)
1051 .unwrap_or_default();
1052
1053 if stt_model.is_none() && stt_base_url.is_none() {
1055 return Ok(MigrationResult {
1056 output: toml_src.to_owned(),
1057 added_count: 0,
1058 sections_added: Vec::new(),
1059 });
1060 }
1061
1062 let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1063
1064 let target_type = match stt_provider_hint.as_str() {
1066 "candle-whisper" | "candle" => "candle",
1067 _ => "openai",
1068 };
1069
1070 let providers = doc
1073 .get("llm")
1074 .and_then(toml_edit::Item::as_table)
1075 .and_then(|llm| llm.get("providers"))
1076 .and_then(toml_edit::Item::as_array_of_tables);
1077
1078 let matching_idx = providers.and_then(|arr| {
1079 arr.iter().enumerate().find_map(|(i, t)| {
1080 let name = t
1081 .get("name")
1082 .and_then(toml_edit::Item::as_str)
1083 .unwrap_or("");
1084 let ptype = t
1085 .get("type")
1086 .and_then(toml_edit::Item::as_str)
1087 .unwrap_or("");
1088 let name_match = !stt_provider_hint.is_empty()
1090 && (name == stt_provider_hint || ptype == stt_provider_hint);
1091 let type_match = ptype == target_type;
1092 if name_match || type_match {
1093 Some(i)
1094 } else {
1095 None
1096 }
1097 })
1098 });
1099
1100 let resolved_provider_name: String;
1102
1103 if let Some(idx) = matching_idx {
1104 let llm_mut = doc
1106 .get_mut("llm")
1107 .and_then(toml_edit::Item::as_table_mut)
1108 .ok_or(MigrateError::InvalidStructure(
1109 "[llm] table not accessible for mutation",
1110 ))?;
1111 let providers_mut = llm_mut
1112 .get_mut("providers")
1113 .and_then(toml_edit::Item::as_array_of_tables_mut)
1114 .ok_or(MigrateError::InvalidStructure(
1115 "[[llm.providers]] array not accessible for mutation",
1116 ))?;
1117 let entry = providers_mut
1118 .iter_mut()
1119 .nth(idx)
1120 .ok_or(MigrateError::InvalidStructure(
1121 "[[llm.providers]] entry index out of range during mutation",
1122 ))?;
1123
1124 let existing_name = entry
1126 .get("name")
1127 .and_then(toml_edit::Item::as_str)
1128 .map(ToOwned::to_owned);
1129 let entry_name = existing_name.unwrap_or_else(|| {
1130 let t = entry
1131 .get("type")
1132 .and_then(toml_edit::Item::as_str)
1133 .unwrap_or("openai");
1134 format!("{t}-stt")
1135 });
1136 entry.insert("name", toml_edit::value(entry_name.clone()));
1137 entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1138 if stt_base_url.is_some() && entry.get("base_url").is_none() {
1139 entry.insert(
1140 "base_url",
1141 toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1142 );
1143 }
1144 resolved_provider_name = entry_name;
1145 } else {
1146 let new_name = if target_type == "candle" {
1148 "local-whisper".to_owned()
1149 } else {
1150 "openai-stt".to_owned()
1151 };
1152 let mut new_entry = toml_edit::Table::new();
1153 new_entry.insert("name", toml_edit::value(new_name.clone()));
1154 new_entry.insert("type", toml_edit::value(target_type));
1155 new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1156 if let Some(ref url) = stt_base_url {
1157 new_entry.insert("base_url", toml_edit::value(url.clone()));
1158 }
1159 let llm_mut = doc
1161 .get_mut("llm")
1162 .and_then(toml_edit::Item::as_table_mut)
1163 .ok_or(MigrateError::InvalidStructure(
1164 "[llm] table not accessible for mutation",
1165 ))?;
1166 if let Some(item) = llm_mut.get_mut("providers") {
1167 if let Some(arr) = item.as_array_of_tables_mut() {
1168 arr.push(new_entry);
1169 }
1170 } else {
1171 let mut arr = toml_edit::ArrayOfTables::new();
1172 arr.push(new_entry);
1173 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1174 }
1175 resolved_provider_name = new_name;
1176 }
1177
1178 if let Some(stt_table) = doc
1180 .get_mut("llm")
1181 .and_then(toml_edit::Item::as_table_mut)
1182 .and_then(|llm| llm.get_mut("stt"))
1183 .and_then(toml_edit::Item::as_table_mut)
1184 {
1185 stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1186 stt_table.remove("model");
1187 stt_table.remove("base_url");
1188 }
1189
1190 Ok(MigrationResult {
1191 output: doc.to_string(),
1192 added_count: 1,
1193 sections_added: vec!["llm.providers.stt_model".to_owned()],
1194 })
1195}
1196
1197pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1210 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1211
1212 let old_value = doc
1213 .get("orchestration")
1214 .and_then(toml_edit::Item::as_table)
1215 .and_then(|t| t.get("planner_model"))
1216 .and_then(toml_edit::Item::as_value)
1217 .and_then(toml_edit::Value::as_str)
1218 .map(ToOwned::to_owned);
1219
1220 let Some(old_model) = old_value else {
1221 return Ok(MigrationResult {
1222 output: toml_src.to_owned(),
1223 added_count: 0,
1224 sections_added: Vec::new(),
1225 });
1226 };
1227
1228 let commented_out = format!(
1232 "# planner_provider = \"{old_model}\" \
1233 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1234 );
1235
1236 let orch_table = doc
1237 .get_mut("orchestration")
1238 .and_then(toml_edit::Item::as_table_mut)
1239 .ok_or(MigrateError::InvalidStructure(
1240 "[orchestration] is not a table",
1241 ))?;
1242 orch_table.remove("planner_model");
1243 let decor = orch_table.decor_mut();
1244 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1245 let new_suffix = if existing_suffix.trim().is_empty() {
1247 format!("\n{commented_out}\n")
1248 } else {
1249 format!("{existing_suffix}\n{commented_out}\n")
1250 };
1251 decor.set_suffix(new_suffix);
1252
1253 eprintln!(
1254 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1255 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1256 `name` field, not a raw model name. Update or remove the commented line."
1257 );
1258
1259 Ok(MigrationResult {
1260 output: doc.to_string(),
1261 added_count: 1,
1262 sections_added: vec!["orchestration.planner_provider".to_owned()],
1263 })
1264}
1265
1266pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1280 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1281 let mut added = 0usize;
1282
1283 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1284 return Ok(MigrationResult {
1285 output: toml_src.to_owned(),
1286 added_count: 0,
1287 sections_added: Vec::new(),
1288 });
1289 };
1290
1291 let Some(servers) = mcp
1292 .get_mut("servers")
1293 .and_then(toml_edit::Item::as_array_of_tables_mut)
1294 else {
1295 return Ok(MigrationResult {
1296 output: toml_src.to_owned(),
1297 added_count: 0,
1298 sections_added: Vec::new(),
1299 });
1300 };
1301
1302 for entry in servers.iter_mut() {
1303 if !entry.contains_key("trust_level") {
1304 entry.insert(
1305 "trust_level",
1306 toml_edit::value(toml_edit::Value::from("trusted")),
1307 );
1308 added += 1;
1309 }
1310 }
1311
1312 if added > 0 {
1313 eprintln!(
1314 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1315 entr{} (preserving previous SSRF-skip behavior). \
1316 Review and adjust trust levels as needed.",
1317 if added == 1 { "y" } else { "ies" }
1318 );
1319 }
1320
1321 Ok(MigrationResult {
1322 output: doc.to_string(),
1323 added_count: added,
1324 sections_added: if added > 0 {
1325 vec!["mcp.servers.trust_level".to_owned()]
1326 } else {
1327 Vec::new()
1328 },
1329 })
1330}
1331
1332pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1343 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1344
1345 let max_retries = doc
1346 .get("agent")
1347 .and_then(toml_edit::Item::as_table)
1348 .and_then(|t| t.get("max_tool_retries"))
1349 .and_then(toml_edit::Item::as_value)
1350 .and_then(toml_edit::Value::as_integer)
1351 .map(i64::cast_unsigned);
1352
1353 let budget_secs = doc
1354 .get("agent")
1355 .and_then(toml_edit::Item::as_table)
1356 .and_then(|t| t.get("max_retry_duration_secs"))
1357 .and_then(toml_edit::Item::as_value)
1358 .and_then(toml_edit::Value::as_integer)
1359 .map(i64::cast_unsigned);
1360
1361 if max_retries.is_none() && budget_secs.is_none() {
1362 return Ok(MigrationResult {
1363 output: toml_src.to_owned(),
1364 added_count: 0,
1365 sections_added: Vec::new(),
1366 });
1367 }
1368
1369 if !doc.contains_key("tools") {
1371 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1372 }
1373 let tools_table = doc
1374 .get_mut("tools")
1375 .and_then(toml_edit::Item::as_table_mut)
1376 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1377
1378 if !tools_table.contains_key("retry") {
1379 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1380 }
1381 let retry_table = tools_table
1382 .get_mut("retry")
1383 .and_then(toml_edit::Item::as_table_mut)
1384 .ok_or(MigrateError::InvalidStructure(
1385 "[tools.retry] is not a table",
1386 ))?;
1387
1388 let mut added_count = 0usize;
1389
1390 if let Some(retries) = max_retries
1391 && !retry_table.contains_key("max_attempts")
1392 {
1393 retry_table.insert(
1394 "max_attempts",
1395 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1396 );
1397 added_count += 1;
1398 }
1399
1400 if let Some(secs) = budget_secs
1401 && !retry_table.contains_key("budget_secs")
1402 {
1403 retry_table.insert(
1404 "budget_secs",
1405 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1406 );
1407 added_count += 1;
1408 }
1409
1410 if added_count > 0 {
1411 eprintln!(
1412 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1413 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1414 );
1415 }
1416
1417 Ok(MigrationResult {
1418 output: doc.to_string(),
1419 added_count,
1420 sections_added: if added_count > 0 {
1421 vec!["tools.retry".to_owned()]
1422 } else {
1423 Vec::new()
1424 },
1425 })
1426}
1427
1428pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1437 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1438
1439 if !doc.contains_key("memory") {
1441 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1442 }
1443
1444 let memory = doc
1445 .get_mut("memory")
1446 .and_then(toml_edit::Item::as_table_mut)
1447 .ok_or(MigrateError::InvalidStructure(
1448 "[memory] key exists but is not a table",
1449 ))?;
1450
1451 if memory.contains_key("database_url") {
1452 return Ok(MigrationResult {
1453 output: toml_src.to_owned(),
1454 added_count: 0,
1455 sections_added: Vec::new(),
1456 });
1457 }
1458
1459 let comment = "# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1461 # Leave empty and store the actual URL in the vault:\n\
1462 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1463 # database_url = \"\"\n";
1464 append_comment_to_table_suffix(memory, comment);
1465
1466 Ok(MigrationResult {
1467 output: doc.to_string(),
1468 added_count: 1,
1469 sections_added: vec!["memory.database_url".to_owned()],
1470 })
1471}
1472
1473pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1482 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1483
1484 let tools_shell_exists = doc
1485 .get("tools")
1486 .and_then(toml_edit::Item::as_table)
1487 .is_some_and(|t| t.contains_key("shell"));
1488 if !tools_shell_exists {
1489 return Ok(MigrationResult {
1491 output: toml_src.to_owned(),
1492 added_count: 0,
1493 sections_added: Vec::new(),
1494 });
1495 }
1496
1497 let shell = doc
1498 .get_mut("tools")
1499 .and_then(toml_edit::Item::as_table_mut)
1500 .and_then(|t| t.get_mut("shell"))
1501 .and_then(toml_edit::Item::as_table_mut)
1502 .ok_or(MigrateError::InvalidStructure(
1503 "[tools.shell] is not a table",
1504 ))?;
1505
1506 if shell.contains_key("transactional") {
1507 return Ok(MigrationResult {
1508 output: toml_src.to_owned(),
1509 added_count: 0,
1510 sections_added: Vec::new(),
1511 });
1512 }
1513
1514 let comment = "# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1515 # transactional = false\n\
1516 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1517 # auto_rollback = false # rollback when exit code >= 2\n\
1518 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1519 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1520 append_comment_to_table_suffix(shell, comment);
1521
1522 Ok(MigrationResult {
1523 output: doc.to_string(),
1524 added_count: 1,
1525 sections_added: vec!["tools.shell.transactional".to_owned()],
1526 })
1527}
1528
1529pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1535 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1536
1537 let agent_exists = doc.contains_key("agent");
1538 if !agent_exists {
1539 return Ok(MigrationResult {
1540 output: toml_src.to_owned(),
1541 added_count: 0,
1542 sections_added: Vec::new(),
1543 });
1544 }
1545
1546 let agent = doc
1547 .get_mut("agent")
1548 .and_then(toml_edit::Item::as_table_mut)
1549 .ok_or(MigrateError::InvalidStructure("[agent] is not a table"))?;
1550
1551 if agent.contains_key("budget_hint_enabled") {
1552 return Ok(MigrationResult {
1553 output: toml_src.to_owned(),
1554 added_count: 0,
1555 sections_added: Vec::new(),
1556 });
1557 }
1558
1559 let comment = "# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1560 # budget_hint_enabled = true\n";
1561 append_comment_to_table_suffix(agent, comment);
1562
1563 Ok(MigrationResult {
1564 output: doc.to_string(),
1565 added_count: 1,
1566 sections_added: vec!["agent.budget_hint_enabled".to_owned()],
1567 })
1568}
1569
1570pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1579 use toml_edit::{Item, Table};
1580
1581 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1582
1583 if !doc.contains_key("memory") {
1585 doc.insert("memory", Item::Table(Table::new()));
1586 }
1587
1588 let memory = doc
1589 .get_mut("memory")
1590 .and_then(Item::as_table_mut)
1591 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1592
1593 if memory.contains_key("forgetting") {
1594 return Ok(MigrationResult {
1595 output: toml_src.to_owned(),
1596 added_count: 0,
1597 sections_added: Vec::new(),
1598 });
1599 }
1600
1601 let comment = "# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1602 # [memory.forgetting]\n\
1603 # enabled = false\n\
1604 # decay_rate = 0.1 # per-sweep importance decay\n\
1605 # forgetting_floor = 0.05 # prune below this score\n\
1606 # sweep_interval_secs = 7200 # run every 2 hours\n\
1607 # sweep_batch_size = 500\n\
1608 # protect_recent_hours = 24\n\
1609 # protect_min_access_count = 3\n";
1610 append_comment_to_table_suffix(memory, comment);
1611
1612 Ok(MigrationResult {
1613 output: doc.to_string(),
1614 added_count: 1,
1615 sections_added: vec!["memory.forgetting".to_owned()],
1616 })
1617}
1618
1619pub fn migrate_compression_predictor_config(
1627 toml_src: &str,
1628) -> Result<MigrationResult, MigrateError> {
1629 use toml_edit::{Item, Table};
1630
1631 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1632
1633 if !doc.contains_key("memory") {
1635 doc.insert("memory", Item::Table(Table::new()));
1636 }
1637 let memory = doc
1638 .get_mut("memory")
1639 .and_then(Item::as_table_mut)
1640 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1641
1642 if !memory.contains_key("compression") {
1643 memory.insert("compression", Item::Table(Table::new()));
1644 }
1645 let compression = memory
1646 .get_mut("compression")
1647 .and_then(Item::as_table_mut)
1648 .ok_or(MigrateError::InvalidStructure(
1649 "[memory.compression] is not a table",
1650 ))?;
1651
1652 if compression.contains_key("predictor") {
1653 return Ok(MigrationResult {
1654 output: toml_src.to_owned(),
1655 added_count: 0,
1656 sections_added: Vec::new(),
1657 });
1658 }
1659
1660 let comment = "# Performance-floor compression ratio predictor (#2460). Disabled by default.\n\
1661 # [memory.compression.predictor]\n\
1662 # enabled = false\n\
1663 # min_samples = 10 # cold-start threshold\n\
1664 # candidate_ratios = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]\n\
1665 # retrain_interval = 5\n\
1666 # max_training_samples = 200\n";
1667 append_comment_to_table_suffix(compression, comment);
1668
1669 Ok(MigrationResult {
1670 output: doc.to_string(),
1671 added_count: 1,
1672 sections_added: vec!["memory.compression.predictor".to_owned()],
1673 })
1674}
1675
1676#[cfg(test)]
1678fn make_formatted_str(s: &str) -> Value {
1679 use toml_edit::Formatted;
1680 Value::String(Formatted::new(s.to_owned()))
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685 use super::*;
1686
1687 #[test]
1688 fn empty_config_gets_sections_as_comments() {
1689 let migrator = ConfigMigrator::new();
1690 let result = migrator.migrate("").expect("migrate empty");
1691 assert!(result.added_count > 0 || !result.sections_added.is_empty());
1693 assert!(
1695 result.output.contains("[agent]") || result.output.contains("# [agent]"),
1696 "expected agent section in output, got:\n{}",
1697 result.output
1698 );
1699 }
1700
1701 #[test]
1702 fn existing_values_not_overwritten() {
1703 let user = r#"
1704[agent]
1705name = "MyAgent"
1706max_tool_iterations = 5
1707"#;
1708 let migrator = ConfigMigrator::new();
1709 let result = migrator.migrate(user).expect("migrate");
1710 assert!(
1712 result.output.contains("name = \"MyAgent\""),
1713 "user value should be preserved"
1714 );
1715 assert!(
1716 result.output.contains("max_tool_iterations = 5"),
1717 "user value should be preserved"
1718 );
1719 assert!(
1721 !result.output.contains("# max_tool_iterations = 10"),
1722 "already-set key should not appear as comment"
1723 );
1724 }
1725
1726 #[test]
1727 fn missing_nested_key_added_as_comment() {
1728 let user = r#"
1730[memory]
1731sqlite_path = ".zeph/data/zeph.db"
1732"#;
1733 let migrator = ConfigMigrator::new();
1734 let result = migrator.migrate(user).expect("migrate");
1735 assert!(
1737 result.output.contains("# history_limit"),
1738 "missing key should be added as comment, got:\n{}",
1739 result.output
1740 );
1741 }
1742
1743 #[test]
1744 fn unknown_user_keys_preserved() {
1745 let user = r#"
1746[agent]
1747name = "Test"
1748my_custom_key = "preserved"
1749"#;
1750 let migrator = ConfigMigrator::new();
1751 let result = migrator.migrate(user).expect("migrate");
1752 assert!(
1753 result.output.contains("my_custom_key = \"preserved\""),
1754 "custom user keys must not be removed"
1755 );
1756 }
1757
1758 #[test]
1759 fn idempotent() {
1760 let migrator = ConfigMigrator::new();
1761 let first = migrator
1762 .migrate("[agent]\nname = \"Zeph\"\n")
1763 .expect("first migrate");
1764 let second = migrator.migrate(&first.output).expect("second migrate");
1765 assert_eq!(
1766 first.output, second.output,
1767 "idempotent: full output must be identical on second run"
1768 );
1769 }
1770
1771 #[test]
1772 fn malformed_input_returns_error() {
1773 let migrator = ConfigMigrator::new();
1774 let err = migrator
1775 .migrate("[[invalid toml [[[")
1776 .expect_err("should error");
1777 assert!(
1778 matches!(err, MigrateError::Parse(_)),
1779 "expected Parse error"
1780 );
1781 }
1782
1783 #[test]
1784 fn array_of_tables_preserved() {
1785 let user = r#"
1786[mcp]
1787allowed_commands = ["npx"]
1788
1789[[mcp.servers]]
1790id = "my-server"
1791command = "npx"
1792args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1793"#;
1794 let migrator = ConfigMigrator::new();
1795 let result = migrator.migrate(user).expect("migrate");
1796 assert!(
1798 result.output.contains("[[mcp.servers]]"),
1799 "array-of-tables entries must be preserved"
1800 );
1801 assert!(result.output.contains("id = \"my-server\""));
1802 }
1803
1804 #[test]
1805 fn canonical_ordering_applied() {
1806 let user = r#"
1808[memory]
1809sqlite_path = ".zeph/data/zeph.db"
1810
1811[agent]
1812name = "Test"
1813"#;
1814 let migrator = ConfigMigrator::new();
1815 let result = migrator.migrate(user).expect("migrate");
1816 let agent_pos = result.output.find("[agent]");
1818 let memory_pos = result.output.find("[memory]");
1819 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1820 assert!(a < m, "agent section should precede memory section");
1821 }
1822 }
1823
1824 #[test]
1825 fn value_to_toml_string_formats_correctly() {
1826 use toml_edit::Formatted;
1827
1828 let s = make_formatted_str("hello");
1829 assert_eq!(value_to_toml_string(&s), "\"hello\"");
1830
1831 let i = Value::Integer(Formatted::new(42_i64));
1832 assert_eq!(value_to_toml_string(&i), "42");
1833
1834 let b = Value::Boolean(Formatted::new(true));
1835 assert_eq!(value_to_toml_string(&b), "true");
1836
1837 let f = Value::Float(Formatted::new(1.0_f64));
1838 assert_eq!(value_to_toml_string(&f), "1.0");
1839
1840 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1841 assert_eq!(value_to_toml_string(&f2), "3.14");
1842
1843 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1844 let arr_val = Value::Array(arr);
1845 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1846
1847 let empty_arr = Value::Array(Array::new());
1848 assert_eq!(value_to_toml_string(&empty_arr), "[]");
1849 }
1850
1851 #[test]
1852 fn idempotent_full_output_unchanged() {
1853 let migrator = ConfigMigrator::new();
1855 let first = migrator
1856 .migrate("[agent]\nname = \"Zeph\"\n")
1857 .expect("first migrate");
1858 let second = migrator.migrate(&first.output).expect("second migrate");
1859 assert_eq!(
1860 first.output, second.output,
1861 "full output string must be identical after second migration pass"
1862 );
1863 }
1864
1865 #[test]
1866 fn full_config_produces_zero_additions() {
1867 let reference = include_str!("../config/default.toml");
1869 let migrator = ConfigMigrator::new();
1870 let result = migrator.migrate(reference).expect("migrate reference");
1871 assert_eq!(
1872 result.added_count, 0,
1873 "migrating the canonical reference should add nothing (added_count = {})",
1874 result.added_count
1875 );
1876 assert!(
1877 result.sections_added.is_empty(),
1878 "migrating the canonical reference should report no sections_added: {:?}",
1879 result.sections_added
1880 );
1881 }
1882
1883 #[test]
1884 fn empty_config_added_count_is_positive() {
1885 let migrator = ConfigMigrator::new();
1887 let result = migrator.migrate("").expect("migrate empty");
1888 assert!(
1889 result.added_count > 0,
1890 "empty config must report added_count > 0"
1891 );
1892 }
1893
1894 #[test]
1897 fn security_without_guardrail_gets_guardrail_commented() {
1898 let user = "[security]\nredact_secrets = true\n";
1899 let migrator = ConfigMigrator::new();
1900 let result = migrator.migrate(user).expect("migrate");
1901 assert!(
1903 result.output.contains("guardrail"),
1904 "migration must add guardrail keys for configs without [security.guardrail]: \
1905 got:\n{}",
1906 result.output
1907 );
1908 }
1909
1910 #[test]
1911 fn migrate_reference_contains_tools_policy() {
1912 let reference = include_str!("../config/default.toml");
1917 assert!(
1918 reference.contains("[tools.policy]"),
1919 "default.toml must contain [tools.policy] section so migrate-config can surface it"
1920 );
1921 assert!(
1922 reference.contains("enabled = false"),
1923 "tools.policy section must include enabled = false default"
1924 );
1925 }
1926
1927 #[test]
1928 fn migrate_reference_contains_probe_section() {
1929 let reference = include_str!("../config/default.toml");
1932 assert!(
1933 reference.contains("[memory.compression.probe]"),
1934 "default.toml must contain [memory.compression.probe] section comment"
1935 );
1936 assert!(
1937 reference.contains("hard_fail_threshold"),
1938 "probe section must include hard_fail_threshold default"
1939 );
1940 }
1941
1942 #[test]
1945 fn migrate_llm_no_llm_section_is_noop() {
1946 let src = "[agent]\nname = \"Zeph\"\n";
1947 let result = migrate_llm_to_providers(src).expect("migrate");
1948 assert_eq!(result.added_count, 0);
1949 assert_eq!(result.output, src);
1950 }
1951
1952 #[test]
1953 fn migrate_llm_already_new_format_is_noop() {
1954 let src = r#"
1955[llm]
1956[[llm.providers]]
1957type = "ollama"
1958model = "qwen3:8b"
1959"#;
1960 let result = migrate_llm_to_providers(src).expect("migrate");
1961 assert_eq!(result.added_count, 0);
1962 }
1963
1964 #[test]
1965 fn migrate_llm_ollama_produces_providers_block() {
1966 let src = r#"
1967[llm]
1968provider = "ollama"
1969model = "qwen3:8b"
1970base_url = "http://localhost:11434"
1971embedding_model = "nomic-embed-text"
1972"#;
1973 let result = migrate_llm_to_providers(src).expect("migrate");
1974 assert!(
1975 result.output.contains("[[llm.providers]]"),
1976 "should contain [[llm.providers]]:\n{}",
1977 result.output
1978 );
1979 assert!(
1980 result.output.contains("type = \"ollama\""),
1981 "{}",
1982 result.output
1983 );
1984 assert!(
1985 result.output.contains("model = \"qwen3:8b\""),
1986 "{}",
1987 result.output
1988 );
1989 }
1990
1991 #[test]
1992 fn migrate_llm_claude_produces_providers_block() {
1993 let src = r#"
1994[llm]
1995provider = "claude"
1996
1997[llm.cloud]
1998model = "claude-sonnet-4-6"
1999max_tokens = 8192
2000server_compaction = true
2001"#;
2002 let result = migrate_llm_to_providers(src).expect("migrate");
2003 assert!(
2004 result.output.contains("[[llm.providers]]"),
2005 "{}",
2006 result.output
2007 );
2008 assert!(
2009 result.output.contains("type = \"claude\""),
2010 "{}",
2011 result.output
2012 );
2013 assert!(
2014 result.output.contains("model = \"claude-sonnet-4-6\""),
2015 "{}",
2016 result.output
2017 );
2018 assert!(
2019 result.output.contains("server_compaction = true"),
2020 "{}",
2021 result.output
2022 );
2023 }
2024
2025 #[test]
2026 fn migrate_llm_openai_copies_fields() {
2027 let src = r#"
2028[llm]
2029provider = "openai"
2030
2031[llm.openai]
2032base_url = "https://api.openai.com/v1"
2033model = "gpt-4o"
2034max_tokens = 4096
2035"#;
2036 let result = migrate_llm_to_providers(src).expect("migrate");
2037 assert!(
2038 result.output.contains("type = \"openai\""),
2039 "{}",
2040 result.output
2041 );
2042 assert!(
2043 result
2044 .output
2045 .contains("base_url = \"https://api.openai.com/v1\""),
2046 "{}",
2047 result.output
2048 );
2049 }
2050
2051 #[test]
2052 fn migrate_llm_gemini_copies_fields() {
2053 let src = r#"
2054[llm]
2055provider = "gemini"
2056
2057[llm.gemini]
2058model = "gemini-2.0-flash"
2059max_tokens = 8192
2060base_url = "https://generativelanguage.googleapis.com"
2061"#;
2062 let result = migrate_llm_to_providers(src).expect("migrate");
2063 assert!(
2064 result.output.contains("type = \"gemini\""),
2065 "{}",
2066 result.output
2067 );
2068 assert!(
2069 result.output.contains("model = \"gemini-2.0-flash\""),
2070 "{}",
2071 result.output
2072 );
2073 }
2074
2075 #[test]
2076 fn migrate_llm_compatible_copies_multiple_entries() {
2077 let src = r#"
2078[llm]
2079provider = "compatible"
2080
2081[[llm.compatible]]
2082name = "proxy-a"
2083base_url = "http://proxy-a:8080/v1"
2084model = "llama3"
2085max_tokens = 4096
2086
2087[[llm.compatible]]
2088name = "proxy-b"
2089base_url = "http://proxy-b:8080/v1"
2090model = "mistral"
2091max_tokens = 2048
2092"#;
2093 let result = migrate_llm_to_providers(src).expect("migrate");
2094 let count = result.output.matches("[[llm.providers]]").count();
2096 assert_eq!(
2097 count, 2,
2098 "expected 2 [[llm.providers]] blocks:\n{}",
2099 result.output
2100 );
2101 assert!(
2102 result.output.contains("name = \"proxy-a\""),
2103 "{}",
2104 result.output
2105 );
2106 assert!(
2107 result.output.contains("name = \"proxy-b\""),
2108 "{}",
2109 result.output
2110 );
2111 }
2112
2113 #[test]
2114 fn migrate_llm_mixed_format_errors() {
2115 let src = r#"
2117[llm]
2118provider = "ollama"
2119
2120[[llm.providers]]
2121type = "ollama"
2122"#;
2123 assert!(
2124 migrate_llm_to_providers(src).is_err(),
2125 "mixed format must return error"
2126 );
2127 }
2128
2129 #[test]
2132 fn stt_migration_no_stt_section_returns_unchanged() {
2133 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2134 let result = migrate_stt_to_provider(src).unwrap();
2135 assert_eq!(result.added_count, 0);
2136 assert_eq!(result.output, src);
2137 }
2138
2139 #[test]
2140 fn stt_migration_no_model_or_base_url_returns_unchanged() {
2141 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2142 let result = migrate_stt_to_provider(src).unwrap();
2143 assert_eq!(result.added_count, 0);
2144 }
2145
2146 #[test]
2147 fn stt_migration_moves_model_to_provider_entry() {
2148 let src = r#"
2149[llm]
2150
2151[[llm.providers]]
2152type = "openai"
2153name = "quality"
2154model = "gpt-5.4"
2155
2156[llm.stt]
2157provider = "quality"
2158model = "gpt-4o-mini-transcribe"
2159language = "en"
2160"#;
2161 let result = migrate_stt_to_provider(src).unwrap();
2162 assert_eq!(result.added_count, 1);
2163 assert!(
2165 result.output.contains("stt_model"),
2166 "stt_model must be in output"
2167 );
2168 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2171 let stt = doc
2172 .get("llm")
2173 .and_then(toml_edit::Item::as_table)
2174 .and_then(|l| l.get("stt"))
2175 .and_then(toml_edit::Item::as_table)
2176 .unwrap();
2177 assert!(
2178 stt.get("model").is_none(),
2179 "model must be removed from [llm.stt]"
2180 );
2181 assert_eq!(
2182 stt.get("provider").and_then(toml_edit::Item::as_str),
2183 Some("quality")
2184 );
2185 }
2186
2187 #[test]
2188 fn stt_migration_creates_new_provider_when_no_match() {
2189 let src = r#"
2190[llm]
2191
2192[[llm.providers]]
2193type = "ollama"
2194name = "local"
2195model = "qwen3:8b"
2196
2197[llm.stt]
2198provider = "whisper"
2199model = "whisper-1"
2200base_url = "https://api.openai.com/v1"
2201language = "en"
2202"#;
2203 let result = migrate_stt_to_provider(src).unwrap();
2204 assert!(
2205 result.output.contains("openai-stt"),
2206 "new entry name must be openai-stt"
2207 );
2208 assert!(
2209 result.output.contains("stt_model"),
2210 "stt_model must be in output"
2211 );
2212 }
2213
2214 #[test]
2215 fn stt_migration_candle_whisper_creates_candle_entry() {
2216 let src = r#"
2217[llm]
2218
2219[llm.stt]
2220provider = "candle-whisper"
2221model = "openai/whisper-tiny"
2222language = "auto"
2223"#;
2224 let result = migrate_stt_to_provider(src).unwrap();
2225 assert!(
2226 result.output.contains("local-whisper"),
2227 "candle entry name must be local-whisper"
2228 );
2229 assert!(result.output.contains("candle"), "type must be candle");
2230 }
2231
2232 #[test]
2233 fn stt_migration_w2_assigns_explicit_name() {
2234 let src = r#"
2236[llm]
2237
2238[[llm.providers]]
2239type = "openai"
2240model = "gpt-5.4"
2241
2242[llm.stt]
2243provider = "openai"
2244model = "whisper-1"
2245language = "auto"
2246"#;
2247 let result = migrate_stt_to_provider(src).unwrap();
2248 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2249 let providers = doc
2250 .get("llm")
2251 .and_then(toml_edit::Item::as_table)
2252 .and_then(|l| l.get("providers"))
2253 .and_then(toml_edit::Item::as_array_of_tables)
2254 .unwrap();
2255 let entry = providers
2256 .iter()
2257 .find(|t| t.get("stt_model").is_some())
2258 .unwrap();
2259 assert!(
2261 entry.get("name").is_some(),
2262 "migrated entry must have explicit name"
2263 );
2264 }
2265
2266 #[test]
2267 fn stt_migration_removes_base_url_from_stt_table() {
2268 let src = r#"
2270[llm]
2271
2272[[llm.providers]]
2273type = "openai"
2274name = "quality"
2275model = "gpt-5.4"
2276
2277[llm.stt]
2278provider = "quality"
2279model = "whisper-1"
2280base_url = "https://api.openai.com/v1"
2281language = "en"
2282"#;
2283 let result = migrate_stt_to_provider(src).unwrap();
2284 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2285 let stt = doc
2286 .get("llm")
2287 .and_then(toml_edit::Item::as_table)
2288 .and_then(|l| l.get("stt"))
2289 .and_then(toml_edit::Item::as_table)
2290 .unwrap();
2291 assert!(
2292 stt.get("model").is_none(),
2293 "model must be removed from [llm.stt]"
2294 );
2295 assert!(
2296 stt.get("base_url").is_none(),
2297 "base_url must be removed from [llm.stt]"
2298 );
2299 }
2300
2301 #[test]
2302 fn migrate_planner_model_to_provider_with_field() {
2303 let input = r#"
2304[orchestration]
2305enabled = true
2306planner_model = "gpt-4o"
2307max_tasks = 20
2308"#;
2309 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2310 assert_eq!(result.added_count, 1, "added_count must be 1");
2311 assert!(
2312 !result.output.contains("planner_model = "),
2313 "planner_model key must be removed from output"
2314 );
2315 assert!(
2316 result.output.contains("# planner_provider"),
2317 "commented-out planner_provider entry must be present"
2318 );
2319 assert!(
2320 result.output.contains("gpt-4o"),
2321 "old value must appear in the comment"
2322 );
2323 assert!(
2324 result.output.contains("MIGRATED"),
2325 "comment must include MIGRATED marker"
2326 );
2327 }
2328
2329 #[test]
2330 fn migrate_planner_model_to_provider_no_op() {
2331 let input = r"
2332[orchestration]
2333enabled = true
2334max_tasks = 20
2335";
2336 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2337 assert_eq!(
2338 result.added_count, 0,
2339 "added_count must be 0 when field is absent"
2340 );
2341 assert_eq!(
2342 result.output, input,
2343 "output must equal input when nothing to migrate"
2344 );
2345 }
2346
2347 #[test]
2348 fn migrate_error_invalid_structure_formats_correctly() {
2349 let err = MigrateError::InvalidStructure("test sentinel");
2354 assert!(
2355 matches!(err, MigrateError::InvalidStructure(_)),
2356 "variant must match"
2357 );
2358 let msg = err.to_string();
2359 assert!(
2360 msg.contains("invalid TOML structure"),
2361 "error message must mention 'invalid TOML structure', got: {msg}"
2362 );
2363 assert!(
2364 msg.contains("test sentinel"),
2365 "message must include reason: {msg}"
2366 );
2367 }
2368
2369 #[test]
2372 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2373 let src = r#"
2374[mcp]
2375allowed_commands = ["npx"]
2376
2377[[mcp.servers]]
2378id = "srv-a"
2379command = "npx"
2380args = ["-y", "some-mcp"]
2381
2382[[mcp.servers]]
2383id = "srv-b"
2384command = "npx"
2385args = ["-y", "other-mcp"]
2386"#;
2387 let result = migrate_mcp_trust_levels(src).expect("migrate");
2388 assert_eq!(
2389 result.added_count, 2,
2390 "both entries must get trust_level added"
2391 );
2392 assert!(
2393 result
2394 .sections_added
2395 .contains(&"mcp.servers.trust_level".to_owned()),
2396 "sections_added must report mcp.servers.trust_level"
2397 );
2398 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2400 assert_eq!(
2401 occurrences, 2,
2402 "each entry must have trust_level = \"trusted\""
2403 );
2404 }
2405
2406 #[test]
2407 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2408 let src = r#"
2409[[mcp.servers]]
2410id = "srv-a"
2411command = "npx"
2412trust_level = "sandboxed"
2413tool_allowlist = ["read_file"]
2414
2415[[mcp.servers]]
2416id = "srv-b"
2417command = "npx"
2418"#;
2419 let result = migrate_mcp_trust_levels(src).expect("migrate");
2420 assert_eq!(
2422 result.added_count, 1,
2423 "only entry without trust_level gets updated"
2424 );
2425 assert!(
2427 result.output.contains("trust_level = \"sandboxed\""),
2428 "existing trust_level must not be overwritten"
2429 );
2430 assert!(
2432 result.output.contains("trust_level = \"trusted\""),
2433 "entry without trust_level must get trusted"
2434 );
2435 }
2436
2437 #[test]
2438 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2439 let src = "[agent]\nname = \"Zeph\"\n";
2440 let result = migrate_mcp_trust_levels(src).expect("migrate");
2441 assert_eq!(result.added_count, 0);
2442 assert!(result.sections_added.is_empty());
2443 assert_eq!(result.output, src);
2444 }
2445
2446 #[test]
2447 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2448 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2449 let result = migrate_mcp_trust_levels(src).expect("migrate");
2450 assert_eq!(result.added_count, 0);
2451 assert!(result.sections_added.is_empty());
2452 assert_eq!(result.output, src);
2453 }
2454
2455 #[test]
2456 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2457 let src = r#"
2458[[mcp.servers]]
2459id = "srv-a"
2460trust_level = "trusted"
2461
2462[[mcp.servers]]
2463id = "srv-b"
2464trust_level = "untrusted"
2465"#;
2466 let result = migrate_mcp_trust_levels(src).expect("migrate");
2467 assert_eq!(result.added_count, 0);
2468 assert!(result.sections_added.is_empty());
2469 }
2470
2471 #[test]
2472 fn migrate_database_url_adds_comment_when_absent() {
2473 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2474 let result = migrate_database_url(src).expect("migrate");
2475 assert_eq!(result.added_count, 1);
2476 assert!(
2477 result
2478 .sections_added
2479 .contains(&"memory.database_url".to_owned())
2480 );
2481 assert!(result.output.contains("# database_url = \"\""));
2482 }
2483
2484 #[test]
2485 fn migrate_database_url_is_noop_when_present() {
2486 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2487 let result = migrate_database_url(src).expect("migrate");
2488 assert_eq!(result.added_count, 0);
2489 assert!(result.sections_added.is_empty());
2490 assert_eq!(result.output, src);
2491 }
2492
2493 #[test]
2494 fn migrate_database_url_creates_memory_section_when_absent() {
2495 let src = "[agent]\nname = \"Zeph\"\n";
2496 let result = migrate_database_url(src).expect("migrate");
2497 assert_eq!(result.added_count, 1);
2498 assert!(result.output.contains("# database_url = \"\""));
2499 }
2500
2501 #[test]
2504 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2505 let src = "[agent]\nname = \"Zeph\"\n";
2506 let result = migrate_agent_budget_hint(src).expect("migrate");
2507 assert_eq!(result.added_count, 1);
2508 assert!(result.output.contains("budget_hint_enabled"));
2509 assert!(
2510 result
2511 .sections_added
2512 .contains(&"agent.budget_hint_enabled".to_owned())
2513 );
2514 }
2515
2516 #[test]
2517 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2518 let src = "[llm]\nmodel = \"gpt-4o\"\n";
2519 let result = migrate_agent_budget_hint(src).expect("migrate");
2520 assert_eq!(result.added_count, 0);
2521 assert_eq!(result.output, src);
2522 }
2523
2524 #[test]
2525 fn migrate_agent_budget_hint_already_present_is_noop() {
2526 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2527 let result = migrate_agent_budget_hint(src).expect("migrate");
2528 assert_eq!(result.added_count, 0);
2529 assert_eq!(result.output, src);
2530 }
2531}