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
1676pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1682 use toml_edit::{Item, Table};
1683
1684 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1685
1686 if !doc.contains_key("memory") {
1687 doc.insert("memory", Item::Table(Table::new()));
1688 }
1689 let memory = doc
1690 .get_mut("memory")
1691 .and_then(Item::as_table_mut)
1692 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1693
1694 if memory.contains_key("microcompact") {
1695 return Ok(MigrationResult {
1696 output: toml_src.to_owned(),
1697 added_count: 0,
1698 sections_added: Vec::new(),
1699 });
1700 }
1701
1702 let comment = "# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1703 # [memory.microcompact]\n\
1704 # enabled = false\n\
1705 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1706 # keep_recent = 3 # always keep this many recent outputs intact\n";
1707 append_comment_to_table_suffix(memory, comment);
1708
1709 Ok(MigrationResult {
1710 output: doc.to_string(),
1711 added_count: 1,
1712 sections_added: vec!["memory.microcompact".to_owned()],
1713 })
1714}
1715
1716pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1722 use toml_edit::{Item, Table};
1723
1724 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1725
1726 if !doc.contains_key("memory") {
1727 doc.insert("memory", Item::Table(Table::new()));
1728 }
1729 let memory = doc
1730 .get_mut("memory")
1731 .and_then(Item::as_table_mut)
1732 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1733
1734 if memory.contains_key("autodream") {
1735 return Ok(MigrationResult {
1736 output: toml_src.to_owned(),
1737 added_count: 0,
1738 sections_added: Vec::new(),
1739 });
1740 }
1741
1742 let comment = "# autoDream background memory consolidation (#2697). Disabled by default.\n\
1743 # [memory.autodream]\n\
1744 # enabled = false\n\
1745 # min_sessions = 5 # sessions since last consolidation\n\
1746 # min_hours = 8 # hours since last consolidation\n\
1747 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1748 # max_iterations = 5\n";
1749 append_comment_to_table_suffix(memory, comment);
1750
1751 Ok(MigrationResult {
1752 output: doc.to_string(),
1753 added_count: 1,
1754 sections_added: vec!["memory.autodream".to_owned()],
1755 })
1756}
1757
1758pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1764 use toml_edit::{Item, Table};
1765
1766 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1767
1768 if doc.contains_key("magic_docs") {
1769 return Ok(MigrationResult {
1770 output: toml_src.to_owned(),
1771 added_count: 0,
1772 sections_added: Vec::new(),
1773 });
1774 }
1775
1776 doc.insert("magic_docs", Item::Table(Table::new()));
1777 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1778 # [magic_docs]\n\
1779 # enabled = false\n\
1780 # min_turns_between_updates = 10\n\
1781 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1782 # max_iterations = 3\n";
1783 doc.remove("magic_docs");
1785 let raw = doc.to_string();
1787 let output = format!("{raw}\n{comment}");
1788
1789 Ok(MigrationResult {
1790 output,
1791 added_count: 1,
1792 sections_added: vec!["magic_docs".to_owned()],
1793 })
1794}
1795
1796#[cfg(test)]
1798fn make_formatted_str(s: &str) -> Value {
1799 use toml_edit::Formatted;
1800 Value::String(Formatted::new(s.to_owned()))
1801}
1802
1803#[cfg(test)]
1804mod tests {
1805 use super::*;
1806
1807 #[test]
1808 fn empty_config_gets_sections_as_comments() {
1809 let migrator = ConfigMigrator::new();
1810 let result = migrator.migrate("").expect("migrate empty");
1811 assert!(result.added_count > 0 || !result.sections_added.is_empty());
1813 assert!(
1815 result.output.contains("[agent]") || result.output.contains("# [agent]"),
1816 "expected agent section in output, got:\n{}",
1817 result.output
1818 );
1819 }
1820
1821 #[test]
1822 fn existing_values_not_overwritten() {
1823 let user = r#"
1824[agent]
1825name = "MyAgent"
1826max_tool_iterations = 5
1827"#;
1828 let migrator = ConfigMigrator::new();
1829 let result = migrator.migrate(user).expect("migrate");
1830 assert!(
1832 result.output.contains("name = \"MyAgent\""),
1833 "user value should be preserved"
1834 );
1835 assert!(
1836 result.output.contains("max_tool_iterations = 5"),
1837 "user value should be preserved"
1838 );
1839 assert!(
1841 !result.output.contains("# max_tool_iterations = 10"),
1842 "already-set key should not appear as comment"
1843 );
1844 }
1845
1846 #[test]
1847 fn missing_nested_key_added_as_comment() {
1848 let user = r#"
1850[memory]
1851sqlite_path = ".zeph/data/zeph.db"
1852"#;
1853 let migrator = ConfigMigrator::new();
1854 let result = migrator.migrate(user).expect("migrate");
1855 assert!(
1857 result.output.contains("# history_limit"),
1858 "missing key should be added as comment, got:\n{}",
1859 result.output
1860 );
1861 }
1862
1863 #[test]
1864 fn unknown_user_keys_preserved() {
1865 let user = r#"
1866[agent]
1867name = "Test"
1868my_custom_key = "preserved"
1869"#;
1870 let migrator = ConfigMigrator::new();
1871 let result = migrator.migrate(user).expect("migrate");
1872 assert!(
1873 result.output.contains("my_custom_key = \"preserved\""),
1874 "custom user keys must not be removed"
1875 );
1876 }
1877
1878 #[test]
1879 fn idempotent() {
1880 let migrator = ConfigMigrator::new();
1881 let first = migrator
1882 .migrate("[agent]\nname = \"Zeph\"\n")
1883 .expect("first migrate");
1884 let second = migrator.migrate(&first.output).expect("second migrate");
1885 assert_eq!(
1886 first.output, second.output,
1887 "idempotent: full output must be identical on second run"
1888 );
1889 }
1890
1891 #[test]
1892 fn malformed_input_returns_error() {
1893 let migrator = ConfigMigrator::new();
1894 let err = migrator
1895 .migrate("[[invalid toml [[[")
1896 .expect_err("should error");
1897 assert!(
1898 matches!(err, MigrateError::Parse(_)),
1899 "expected Parse error"
1900 );
1901 }
1902
1903 #[test]
1904 fn array_of_tables_preserved() {
1905 let user = r#"
1906[mcp]
1907allowed_commands = ["npx"]
1908
1909[[mcp.servers]]
1910id = "my-server"
1911command = "npx"
1912args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
1913"#;
1914 let migrator = ConfigMigrator::new();
1915 let result = migrator.migrate(user).expect("migrate");
1916 assert!(
1918 result.output.contains("[[mcp.servers]]"),
1919 "array-of-tables entries must be preserved"
1920 );
1921 assert!(result.output.contains("id = \"my-server\""));
1922 }
1923
1924 #[test]
1925 fn canonical_ordering_applied() {
1926 let user = r#"
1928[memory]
1929sqlite_path = ".zeph/data/zeph.db"
1930
1931[agent]
1932name = "Test"
1933"#;
1934 let migrator = ConfigMigrator::new();
1935 let result = migrator.migrate(user).expect("migrate");
1936 let agent_pos = result.output.find("[agent]");
1938 let memory_pos = result.output.find("[memory]");
1939 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
1940 assert!(a < m, "agent section should precede memory section");
1941 }
1942 }
1943
1944 #[test]
1945 fn value_to_toml_string_formats_correctly() {
1946 use toml_edit::Formatted;
1947
1948 let s = make_formatted_str("hello");
1949 assert_eq!(value_to_toml_string(&s), "\"hello\"");
1950
1951 let i = Value::Integer(Formatted::new(42_i64));
1952 assert_eq!(value_to_toml_string(&i), "42");
1953
1954 let b = Value::Boolean(Formatted::new(true));
1955 assert_eq!(value_to_toml_string(&b), "true");
1956
1957 let f = Value::Float(Formatted::new(1.0_f64));
1958 assert_eq!(value_to_toml_string(&f), "1.0");
1959
1960 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
1961 assert_eq!(value_to_toml_string(&f2), "3.14");
1962
1963 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
1964 let arr_val = Value::Array(arr);
1965 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
1966
1967 let empty_arr = Value::Array(Array::new());
1968 assert_eq!(value_to_toml_string(&empty_arr), "[]");
1969 }
1970
1971 #[test]
1972 fn idempotent_full_output_unchanged() {
1973 let migrator = ConfigMigrator::new();
1975 let first = migrator
1976 .migrate("[agent]\nname = \"Zeph\"\n")
1977 .expect("first migrate");
1978 let second = migrator.migrate(&first.output).expect("second migrate");
1979 assert_eq!(
1980 first.output, second.output,
1981 "full output string must be identical after second migration pass"
1982 );
1983 }
1984
1985 #[test]
1986 fn full_config_produces_zero_additions() {
1987 let reference = include_str!("../config/default.toml");
1989 let migrator = ConfigMigrator::new();
1990 let result = migrator.migrate(reference).expect("migrate reference");
1991 assert_eq!(
1992 result.added_count, 0,
1993 "migrating the canonical reference should add nothing (added_count = {})",
1994 result.added_count
1995 );
1996 assert!(
1997 result.sections_added.is_empty(),
1998 "migrating the canonical reference should report no sections_added: {:?}",
1999 result.sections_added
2000 );
2001 }
2002
2003 #[test]
2004 fn empty_config_added_count_is_positive() {
2005 let migrator = ConfigMigrator::new();
2007 let result = migrator.migrate("").expect("migrate empty");
2008 assert!(
2009 result.added_count > 0,
2010 "empty config must report added_count > 0"
2011 );
2012 }
2013
2014 #[test]
2017 fn security_without_guardrail_gets_guardrail_commented() {
2018 let user = "[security]\nredact_secrets = true\n";
2019 let migrator = ConfigMigrator::new();
2020 let result = migrator.migrate(user).expect("migrate");
2021 assert!(
2023 result.output.contains("guardrail"),
2024 "migration must add guardrail keys for configs without [security.guardrail]: \
2025 got:\n{}",
2026 result.output
2027 );
2028 }
2029
2030 #[test]
2031 fn migrate_reference_contains_tools_policy() {
2032 let reference = include_str!("../config/default.toml");
2037 assert!(
2038 reference.contains("[tools.policy]"),
2039 "default.toml must contain [tools.policy] section so migrate-config can surface it"
2040 );
2041 assert!(
2042 reference.contains("enabled = false"),
2043 "tools.policy section must include enabled = false default"
2044 );
2045 }
2046
2047 #[test]
2048 fn migrate_reference_contains_probe_section() {
2049 let reference = include_str!("../config/default.toml");
2052 assert!(
2053 reference.contains("[memory.compression.probe]"),
2054 "default.toml must contain [memory.compression.probe] section comment"
2055 );
2056 assert!(
2057 reference.contains("hard_fail_threshold"),
2058 "probe section must include hard_fail_threshold default"
2059 );
2060 }
2061
2062 #[test]
2065 fn migrate_llm_no_llm_section_is_noop() {
2066 let src = "[agent]\nname = \"Zeph\"\n";
2067 let result = migrate_llm_to_providers(src).expect("migrate");
2068 assert_eq!(result.added_count, 0);
2069 assert_eq!(result.output, src);
2070 }
2071
2072 #[test]
2073 fn migrate_llm_already_new_format_is_noop() {
2074 let src = r#"
2075[llm]
2076[[llm.providers]]
2077type = "ollama"
2078model = "qwen3:8b"
2079"#;
2080 let result = migrate_llm_to_providers(src).expect("migrate");
2081 assert_eq!(result.added_count, 0);
2082 }
2083
2084 #[test]
2085 fn migrate_llm_ollama_produces_providers_block() {
2086 let src = r#"
2087[llm]
2088provider = "ollama"
2089model = "qwen3:8b"
2090base_url = "http://localhost:11434"
2091embedding_model = "nomic-embed-text"
2092"#;
2093 let result = migrate_llm_to_providers(src).expect("migrate");
2094 assert!(
2095 result.output.contains("[[llm.providers]]"),
2096 "should contain [[llm.providers]]:\n{}",
2097 result.output
2098 );
2099 assert!(
2100 result.output.contains("type = \"ollama\""),
2101 "{}",
2102 result.output
2103 );
2104 assert!(
2105 result.output.contains("model = \"qwen3:8b\""),
2106 "{}",
2107 result.output
2108 );
2109 }
2110
2111 #[test]
2112 fn migrate_llm_claude_produces_providers_block() {
2113 let src = r#"
2114[llm]
2115provider = "claude"
2116
2117[llm.cloud]
2118model = "claude-sonnet-4-6"
2119max_tokens = 8192
2120server_compaction = true
2121"#;
2122 let result = migrate_llm_to_providers(src).expect("migrate");
2123 assert!(
2124 result.output.contains("[[llm.providers]]"),
2125 "{}",
2126 result.output
2127 );
2128 assert!(
2129 result.output.contains("type = \"claude\""),
2130 "{}",
2131 result.output
2132 );
2133 assert!(
2134 result.output.contains("model = \"claude-sonnet-4-6\""),
2135 "{}",
2136 result.output
2137 );
2138 assert!(
2139 result.output.contains("server_compaction = true"),
2140 "{}",
2141 result.output
2142 );
2143 }
2144
2145 #[test]
2146 fn migrate_llm_openai_copies_fields() {
2147 let src = r#"
2148[llm]
2149provider = "openai"
2150
2151[llm.openai]
2152base_url = "https://api.openai.com/v1"
2153model = "gpt-4o"
2154max_tokens = 4096
2155"#;
2156 let result = migrate_llm_to_providers(src).expect("migrate");
2157 assert!(
2158 result.output.contains("type = \"openai\""),
2159 "{}",
2160 result.output
2161 );
2162 assert!(
2163 result
2164 .output
2165 .contains("base_url = \"https://api.openai.com/v1\""),
2166 "{}",
2167 result.output
2168 );
2169 }
2170
2171 #[test]
2172 fn migrate_llm_gemini_copies_fields() {
2173 let src = r#"
2174[llm]
2175provider = "gemini"
2176
2177[llm.gemini]
2178model = "gemini-2.0-flash"
2179max_tokens = 8192
2180base_url = "https://generativelanguage.googleapis.com"
2181"#;
2182 let result = migrate_llm_to_providers(src).expect("migrate");
2183 assert!(
2184 result.output.contains("type = \"gemini\""),
2185 "{}",
2186 result.output
2187 );
2188 assert!(
2189 result.output.contains("model = \"gemini-2.0-flash\""),
2190 "{}",
2191 result.output
2192 );
2193 }
2194
2195 #[test]
2196 fn migrate_llm_compatible_copies_multiple_entries() {
2197 let src = r#"
2198[llm]
2199provider = "compatible"
2200
2201[[llm.compatible]]
2202name = "proxy-a"
2203base_url = "http://proxy-a:8080/v1"
2204model = "llama3"
2205max_tokens = 4096
2206
2207[[llm.compatible]]
2208name = "proxy-b"
2209base_url = "http://proxy-b:8080/v1"
2210model = "mistral"
2211max_tokens = 2048
2212"#;
2213 let result = migrate_llm_to_providers(src).expect("migrate");
2214 let count = result.output.matches("[[llm.providers]]").count();
2216 assert_eq!(
2217 count, 2,
2218 "expected 2 [[llm.providers]] blocks:\n{}",
2219 result.output
2220 );
2221 assert!(
2222 result.output.contains("name = \"proxy-a\""),
2223 "{}",
2224 result.output
2225 );
2226 assert!(
2227 result.output.contains("name = \"proxy-b\""),
2228 "{}",
2229 result.output
2230 );
2231 }
2232
2233 #[test]
2234 fn migrate_llm_mixed_format_errors() {
2235 let src = r#"
2237[llm]
2238provider = "ollama"
2239
2240[[llm.providers]]
2241type = "ollama"
2242"#;
2243 assert!(
2244 migrate_llm_to_providers(src).is_err(),
2245 "mixed format must return error"
2246 );
2247 }
2248
2249 #[test]
2252 fn stt_migration_no_stt_section_returns_unchanged() {
2253 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2254 let result = migrate_stt_to_provider(src).unwrap();
2255 assert_eq!(result.added_count, 0);
2256 assert_eq!(result.output, src);
2257 }
2258
2259 #[test]
2260 fn stt_migration_no_model_or_base_url_returns_unchanged() {
2261 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2262 let result = migrate_stt_to_provider(src).unwrap();
2263 assert_eq!(result.added_count, 0);
2264 }
2265
2266 #[test]
2267 fn stt_migration_moves_model_to_provider_entry() {
2268 let src = r#"
2269[llm]
2270
2271[[llm.providers]]
2272type = "openai"
2273name = "quality"
2274model = "gpt-5.4"
2275
2276[llm.stt]
2277provider = "quality"
2278model = "gpt-4o-mini-transcribe"
2279language = "en"
2280"#;
2281 let result = migrate_stt_to_provider(src).unwrap();
2282 assert_eq!(result.added_count, 1);
2283 assert!(
2285 result.output.contains("stt_model"),
2286 "stt_model must be in output"
2287 );
2288 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2291 let stt = doc
2292 .get("llm")
2293 .and_then(toml_edit::Item::as_table)
2294 .and_then(|l| l.get("stt"))
2295 .and_then(toml_edit::Item::as_table)
2296 .unwrap();
2297 assert!(
2298 stt.get("model").is_none(),
2299 "model must be removed from [llm.stt]"
2300 );
2301 assert_eq!(
2302 stt.get("provider").and_then(toml_edit::Item::as_str),
2303 Some("quality")
2304 );
2305 }
2306
2307 #[test]
2308 fn stt_migration_creates_new_provider_when_no_match() {
2309 let src = r#"
2310[llm]
2311
2312[[llm.providers]]
2313type = "ollama"
2314name = "local"
2315model = "qwen3:8b"
2316
2317[llm.stt]
2318provider = "whisper"
2319model = "whisper-1"
2320base_url = "https://api.openai.com/v1"
2321language = "en"
2322"#;
2323 let result = migrate_stt_to_provider(src).unwrap();
2324 assert!(
2325 result.output.contains("openai-stt"),
2326 "new entry name must be openai-stt"
2327 );
2328 assert!(
2329 result.output.contains("stt_model"),
2330 "stt_model must be in output"
2331 );
2332 }
2333
2334 #[test]
2335 fn stt_migration_candle_whisper_creates_candle_entry() {
2336 let src = r#"
2337[llm]
2338
2339[llm.stt]
2340provider = "candle-whisper"
2341model = "openai/whisper-tiny"
2342language = "auto"
2343"#;
2344 let result = migrate_stt_to_provider(src).unwrap();
2345 assert!(
2346 result.output.contains("local-whisper"),
2347 "candle entry name must be local-whisper"
2348 );
2349 assert!(result.output.contains("candle"), "type must be candle");
2350 }
2351
2352 #[test]
2353 fn stt_migration_w2_assigns_explicit_name() {
2354 let src = r#"
2356[llm]
2357
2358[[llm.providers]]
2359type = "openai"
2360model = "gpt-5.4"
2361
2362[llm.stt]
2363provider = "openai"
2364model = "whisper-1"
2365language = "auto"
2366"#;
2367 let result = migrate_stt_to_provider(src).unwrap();
2368 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2369 let providers = doc
2370 .get("llm")
2371 .and_then(toml_edit::Item::as_table)
2372 .and_then(|l| l.get("providers"))
2373 .and_then(toml_edit::Item::as_array_of_tables)
2374 .unwrap();
2375 let entry = providers
2376 .iter()
2377 .find(|t| t.get("stt_model").is_some())
2378 .unwrap();
2379 assert!(
2381 entry.get("name").is_some(),
2382 "migrated entry must have explicit name"
2383 );
2384 }
2385
2386 #[test]
2387 fn stt_migration_removes_base_url_from_stt_table() {
2388 let src = r#"
2390[llm]
2391
2392[[llm.providers]]
2393type = "openai"
2394name = "quality"
2395model = "gpt-5.4"
2396
2397[llm.stt]
2398provider = "quality"
2399model = "whisper-1"
2400base_url = "https://api.openai.com/v1"
2401language = "en"
2402"#;
2403 let result = migrate_stt_to_provider(src).unwrap();
2404 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2405 let stt = doc
2406 .get("llm")
2407 .and_then(toml_edit::Item::as_table)
2408 .and_then(|l| l.get("stt"))
2409 .and_then(toml_edit::Item::as_table)
2410 .unwrap();
2411 assert!(
2412 stt.get("model").is_none(),
2413 "model must be removed from [llm.stt]"
2414 );
2415 assert!(
2416 stt.get("base_url").is_none(),
2417 "base_url must be removed from [llm.stt]"
2418 );
2419 }
2420
2421 #[test]
2422 fn migrate_planner_model_to_provider_with_field() {
2423 let input = r#"
2424[orchestration]
2425enabled = true
2426planner_model = "gpt-4o"
2427max_tasks = 20
2428"#;
2429 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2430 assert_eq!(result.added_count, 1, "added_count must be 1");
2431 assert!(
2432 !result.output.contains("planner_model = "),
2433 "planner_model key must be removed from output"
2434 );
2435 assert!(
2436 result.output.contains("# planner_provider"),
2437 "commented-out planner_provider entry must be present"
2438 );
2439 assert!(
2440 result.output.contains("gpt-4o"),
2441 "old value must appear in the comment"
2442 );
2443 assert!(
2444 result.output.contains("MIGRATED"),
2445 "comment must include MIGRATED marker"
2446 );
2447 }
2448
2449 #[test]
2450 fn migrate_planner_model_to_provider_no_op() {
2451 let input = r"
2452[orchestration]
2453enabled = true
2454max_tasks = 20
2455";
2456 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2457 assert_eq!(
2458 result.added_count, 0,
2459 "added_count must be 0 when field is absent"
2460 );
2461 assert_eq!(
2462 result.output, input,
2463 "output must equal input when nothing to migrate"
2464 );
2465 }
2466
2467 #[test]
2468 fn migrate_error_invalid_structure_formats_correctly() {
2469 let err = MigrateError::InvalidStructure("test sentinel");
2474 assert!(
2475 matches!(err, MigrateError::InvalidStructure(_)),
2476 "variant must match"
2477 );
2478 let msg = err.to_string();
2479 assert!(
2480 msg.contains("invalid TOML structure"),
2481 "error message must mention 'invalid TOML structure', got: {msg}"
2482 );
2483 assert!(
2484 msg.contains("test sentinel"),
2485 "message must include reason: {msg}"
2486 );
2487 }
2488
2489 #[test]
2492 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2493 let src = r#"
2494[mcp]
2495allowed_commands = ["npx"]
2496
2497[[mcp.servers]]
2498id = "srv-a"
2499command = "npx"
2500args = ["-y", "some-mcp"]
2501
2502[[mcp.servers]]
2503id = "srv-b"
2504command = "npx"
2505args = ["-y", "other-mcp"]
2506"#;
2507 let result = migrate_mcp_trust_levels(src).expect("migrate");
2508 assert_eq!(
2509 result.added_count, 2,
2510 "both entries must get trust_level added"
2511 );
2512 assert!(
2513 result
2514 .sections_added
2515 .contains(&"mcp.servers.trust_level".to_owned()),
2516 "sections_added must report mcp.servers.trust_level"
2517 );
2518 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2520 assert_eq!(
2521 occurrences, 2,
2522 "each entry must have trust_level = \"trusted\""
2523 );
2524 }
2525
2526 #[test]
2527 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2528 let src = r#"
2529[[mcp.servers]]
2530id = "srv-a"
2531command = "npx"
2532trust_level = "sandboxed"
2533tool_allowlist = ["read_file"]
2534
2535[[mcp.servers]]
2536id = "srv-b"
2537command = "npx"
2538"#;
2539 let result = migrate_mcp_trust_levels(src).expect("migrate");
2540 assert_eq!(
2542 result.added_count, 1,
2543 "only entry without trust_level gets updated"
2544 );
2545 assert!(
2547 result.output.contains("trust_level = \"sandboxed\""),
2548 "existing trust_level must not be overwritten"
2549 );
2550 assert!(
2552 result.output.contains("trust_level = \"trusted\""),
2553 "entry without trust_level must get trusted"
2554 );
2555 }
2556
2557 #[test]
2558 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2559 let src = "[agent]\nname = \"Zeph\"\n";
2560 let result = migrate_mcp_trust_levels(src).expect("migrate");
2561 assert_eq!(result.added_count, 0);
2562 assert!(result.sections_added.is_empty());
2563 assert_eq!(result.output, src);
2564 }
2565
2566 #[test]
2567 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2568 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2569 let result = migrate_mcp_trust_levels(src).expect("migrate");
2570 assert_eq!(result.added_count, 0);
2571 assert!(result.sections_added.is_empty());
2572 assert_eq!(result.output, src);
2573 }
2574
2575 #[test]
2576 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2577 let src = r#"
2578[[mcp.servers]]
2579id = "srv-a"
2580trust_level = "trusted"
2581
2582[[mcp.servers]]
2583id = "srv-b"
2584trust_level = "untrusted"
2585"#;
2586 let result = migrate_mcp_trust_levels(src).expect("migrate");
2587 assert_eq!(result.added_count, 0);
2588 assert!(result.sections_added.is_empty());
2589 }
2590
2591 #[test]
2592 fn migrate_database_url_adds_comment_when_absent() {
2593 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2594 let result = migrate_database_url(src).expect("migrate");
2595 assert_eq!(result.added_count, 1);
2596 assert!(
2597 result
2598 .sections_added
2599 .contains(&"memory.database_url".to_owned())
2600 );
2601 assert!(result.output.contains("# database_url = \"\""));
2602 }
2603
2604 #[test]
2605 fn migrate_database_url_is_noop_when_present() {
2606 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2607 let result = migrate_database_url(src).expect("migrate");
2608 assert_eq!(result.added_count, 0);
2609 assert!(result.sections_added.is_empty());
2610 assert_eq!(result.output, src);
2611 }
2612
2613 #[test]
2614 fn migrate_database_url_creates_memory_section_when_absent() {
2615 let src = "[agent]\nname = \"Zeph\"\n";
2616 let result = migrate_database_url(src).expect("migrate");
2617 assert_eq!(result.added_count, 1);
2618 assert!(result.output.contains("# database_url = \"\""));
2619 }
2620
2621 #[test]
2624 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2625 let src = "[agent]\nname = \"Zeph\"\n";
2626 let result = migrate_agent_budget_hint(src).expect("migrate");
2627 assert_eq!(result.added_count, 1);
2628 assert!(result.output.contains("budget_hint_enabled"));
2629 assert!(
2630 result
2631 .sections_added
2632 .contains(&"agent.budget_hint_enabled".to_owned())
2633 );
2634 }
2635
2636 #[test]
2637 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2638 let src = "[llm]\nmodel = \"gpt-4o\"\n";
2639 let result = migrate_agent_budget_hint(src).expect("migrate");
2640 assert_eq!(result.added_count, 0);
2641 assert_eq!(result.output, src);
2642 }
2643
2644 #[test]
2645 fn migrate_agent_budget_hint_already_present_is_noop() {
2646 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2647 let result = migrate_agent_budget_hint(src).expect("migrate");
2648 assert_eq!(result.added_count, 0);
2649 assert_eq!(result.output, src);
2650 }
2651}