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 "metrics",
28 "daemon",
29 "scheduler",
30 "orchestration",
31 "classifiers",
32 "security",
33 "vault",
34 "timeouts",
35 "cost",
36 "debug",
37 "logging",
38 "tui",
39 "agents",
40 "experiments",
41 "lsp",
42 "telemetry",
43];
44
45#[derive(Debug, thiserror::Error)]
47pub enum MigrateError {
48 #[error("failed to parse input config: {0}")]
50 Parse(#[from] toml_edit::TomlError),
51 #[error("failed to parse reference config: {0}")]
53 Reference(toml_edit::TomlError),
54 #[error("migration failed: invalid TOML structure — {0}")]
57 InvalidStructure(&'static str),
58}
59
60#[derive(Debug)]
62pub struct MigrationResult {
63 pub output: String,
65 pub added_count: usize,
67 pub sections_added: Vec<String>,
69}
70
71pub struct ConfigMigrator {
76 reference_src: &'static str,
77}
78
79impl Default for ConfigMigrator {
80 fn default() -> Self {
81 Self::new()
82 }
83}
84
85impl ConfigMigrator {
86 #[must_use]
88 pub fn new() -> Self {
89 Self {
90 reference_src: include_str!("../config/default.toml"),
91 }
92 }
93
94 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
106 let reference_doc = self
107 .reference_src
108 .parse::<DocumentMut>()
109 .map_err(MigrateError::Reference)?;
110 let mut user_doc = user_toml.parse::<DocumentMut>()?;
111
112 let mut added_count = 0usize;
113 let mut sections_added: Vec<String> = Vec::new();
114
115 for (key, ref_item) in reference_doc.as_table() {
117 if ref_item.is_table() {
118 let ref_table = ref_item.as_table().expect("is_table checked above");
119 if user_doc.contains_key(key) {
120 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
122 added_count += merge_table_commented(user_table, ref_table, key);
123 }
124 } else {
125 if user_toml.contains(&format!("# [{key}]")) {
128 continue;
129 }
130 let commented = commented_table_block(key, ref_table);
131 if !commented.is_empty() {
132 sections_added.push(key.to_owned());
133 }
134 added_count += 1;
135 }
136 } else {
137 if !user_doc.contains_key(key) {
139 let raw = format_commented_item(key, ref_item);
140 if !raw.is_empty() {
141 sections_added.push(format!("__scalar__{key}"));
142 added_count += 1;
143 }
144 }
145 }
146 }
147
148 let user_str = user_doc.to_string();
150
151 let mut output = user_str;
153 for key in §ions_added {
154 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
155 if let Some(ref_item) = reference_doc.get(scalar_key) {
156 let raw = format_commented_item(scalar_key, ref_item);
157 if !raw.is_empty() {
158 output.push('\n');
159 output.push_str(&raw);
160 output.push('\n');
161 }
162 }
163 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
164 {
165 let block = commented_table_block(key, ref_table);
166 if !block.is_empty() {
167 output.push('\n');
168 output.push_str(&block);
169 }
170 }
171 }
172
173 output = reorder_sections(&output, CANONICAL_ORDER);
175
176 let sections_added_clean: Vec<String> = sections_added
178 .into_iter()
179 .filter(|k| !k.starts_with("__scalar__"))
180 .collect();
181
182 Ok(MigrationResult {
183 output,
184 added_count,
185 sections_added: sections_added_clean,
186 })
187 }
188}
189
190fn merge_table_commented(user_table: &mut Table, ref_table: &Table, section_key: &str) -> usize {
194 let mut count = 0usize;
195 for (key, ref_item) in ref_table {
196 if ref_item.is_table() {
197 if user_table.contains_key(key) {
198 let pair = (
199 user_table.get_mut(key).and_then(Item::as_table_mut),
200 ref_item.as_table(),
201 );
202 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
203 let sub_key = format!("{section_key}.{key}");
204 count += merge_table_commented(user_sub_table, ref_sub_table, &sub_key);
205 }
206 } else if let Some(ref_sub_table) = ref_item.as_table() {
207 let dotted = format!("{section_key}.{key}");
209 let marker = format!("# [{dotted}]");
210 let existing = user_table
211 .decor()
212 .suffix()
213 .and_then(RawString::as_str)
214 .unwrap_or("");
215 if !existing.contains(&marker) {
216 let block = commented_table_block(&dotted, ref_sub_table);
217 if !block.is_empty() {
218 let new_suffix = format!("{existing}\n{block}");
219 user_table.decor_mut().set_suffix(new_suffix);
220 count += 1;
221 }
222 }
223 }
224 } else if ref_item.is_array_of_tables() {
225 } else {
227 if !user_table.contains_key(key) {
229 let raw_value = ref_item
230 .as_value()
231 .map(value_to_toml_string)
232 .unwrap_or_default();
233 if !raw_value.is_empty() {
234 let comment_line = format!("# {key} = {raw_value}\n");
235 append_comment_to_table_suffix(user_table, &comment_line);
236 count += 1;
237 }
238 }
239 }
240 }
241 count
242}
243
244fn append_comment_to_table_suffix(table: &mut Table, comment_line: &str) {
246 let existing: String = table
247 .decor()
248 .suffix()
249 .and_then(RawString::as_str)
250 .unwrap_or("")
251 .to_owned();
252 if !existing.contains(comment_line.trim()) {
254 let new_suffix = format!("{existing}{comment_line}");
255 table.decor_mut().set_suffix(new_suffix);
256 }
257}
258
259fn format_commented_item(key: &str, item: &Item) -> String {
261 if let Some(val) = item.as_value() {
262 let raw = value_to_toml_string(val);
263 if !raw.is_empty() {
264 return format!("# {key} = {raw}\n");
265 }
266 }
267 String::new()
268}
269
270fn commented_table_block(section_name: &str, table: &Table) -> String {
275 use std::fmt::Write as _;
276
277 let mut lines = format!("# [{section_name}]\n");
278
279 for (key, item) in table {
280 if item.is_table() {
281 if let Some(sub_table) = item.as_table() {
282 let sub_name = format!("{section_name}.{key}");
283 let sub_block = commented_table_block(&sub_name, sub_table);
284 if !sub_block.is_empty() {
285 lines.push('\n');
286 lines.push_str(&sub_block);
287 }
288 }
289 } else if item.is_array_of_tables() {
290 } else if let Some(val) = item.as_value() {
292 let raw = value_to_toml_string(val);
293 if !raw.is_empty() {
294 let _ = writeln!(lines, "# {key} = {raw}");
295 }
296 }
297 }
298
299 if lines.trim() == format!("[{section_name}]") {
301 return String::new();
302 }
303 lines
304}
305
306fn value_to_toml_string(val: &Value) -> String {
308 match val {
309 Value::String(s) => {
310 let inner = s.value();
311 format!("\"{inner}\"")
312 }
313 Value::Integer(i) => i.value().to_string(),
314 Value::Float(f) => {
315 let v = f.value();
316 if v.fract() == 0.0 {
318 format!("{v:.1}")
319 } else {
320 format!("{v}")
321 }
322 }
323 Value::Boolean(b) => b.value().to_string(),
324 Value::Array(arr) => format_array(arr),
325 Value::InlineTable(t) => {
326 let pairs: Vec<String> = t
327 .iter()
328 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
329 .collect();
330 format!("{{ {} }}", pairs.join(", "))
331 }
332 Value::Datetime(dt) => dt.value().to_string(),
333 }
334}
335
336fn format_array(arr: &Array) -> String {
337 if arr.is_empty() {
338 return "[]".to_owned();
339 }
340 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
341 format!("[{}]", items.join(", "))
342}
343
344fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
350 let sections = split_into_sections(toml_str);
351 if sections.is_empty() {
352 return toml_str.to_owned();
353 }
354
355 let preamble_block = sections
357 .iter()
358 .find(|(h, _)| h.is_empty())
359 .map_or("", |(_, c)| c.as_str());
360
361 let section_map: Vec<(&str, &str)> = sections
362 .iter()
363 .filter(|(h, _)| !h.is_empty())
364 .map(|(h, c)| (h.as_str(), c.as_str()))
365 .collect();
366
367 let mut out = String::new();
368 if !preamble_block.is_empty() {
369 out.push_str(preamble_block);
370 }
371
372 let mut emitted: Vec<bool> = vec![false; section_map.len()];
373
374 for &canon in canonical_order {
375 for (idx, &(header, content)) in section_map.iter().enumerate() {
376 let section_name = extract_section_name(header);
377 let top_level = section_name
378 .split('.')
379 .next()
380 .unwrap_or("")
381 .trim_start_matches('#')
382 .trim();
383 if top_level == canon && !emitted[idx] {
384 out.push_str(content);
385 emitted[idx] = true;
386 }
387 }
388 }
389
390 for (idx, &(_, content)) in section_map.iter().enumerate() {
392 if !emitted[idx] {
393 out.push_str(content);
394 }
395 }
396
397 out
398}
399
400fn extract_section_name(header: &str) -> &str {
402 let trimmed = header.trim().trim_start_matches("# ");
404 if trimmed.starts_with('[') && trimmed.contains(']') {
406 let inner = &trimmed[1..];
407 if let Some(end) = inner.find(']') {
408 return &inner[..end];
409 }
410 }
411 trimmed
412}
413
414fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
418 let mut sections: Vec<(String, String)> = Vec::new();
419 let mut current_header = String::new();
420 let mut current_content = String::new();
421
422 for line in toml_str.lines() {
423 let trimmed = line.trim();
424 if is_top_level_section_header(trimmed) {
425 sections.push((current_header.clone(), current_content.clone()));
426 trimmed.clone_into(&mut current_header);
427 line.clone_into(&mut current_content);
428 current_content.push('\n');
429 } else {
430 current_content.push_str(line);
431 current_content.push('\n');
432 }
433 }
434
435 if !current_header.is_empty() || !current_content.is_empty() {
437 sections.push((current_header, current_content));
438 }
439
440 sections
441}
442
443fn is_top_level_section_header(line: &str) -> bool {
448 if line.starts_with('[')
449 && !line.starts_with("[[")
450 && let Some(end) = line.find(']')
451 {
452 return !line[1..end].contains('.');
453 }
454 false
455}
456
457#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
458fn migrate_ollama_provider(
459 llm: &toml_edit::Table,
460 model: &Option<String>,
461 base_url: &Option<String>,
462 embedding_model: &Option<String>,
463) -> Vec<String> {
464 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
465 if let Some(m) = model {
466 block.push_str(&format!("model = \"{m}\"\n"));
467 }
468 if let Some(em) = embedding_model {
469 block.push_str(&format!("embedding_model = \"{em}\"\n"));
470 }
471 if let Some(u) = base_url {
472 block.push_str(&format!("base_url = \"{u}\"\n"));
473 }
474 let _ = llm; vec![block]
476}
477
478#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
479fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
480 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
481 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
482 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
483 block.push_str(&format!("model = \"{m}\"\n"));
484 }
485 if let Some(t) = cloud
486 .get("max_tokens")
487 .and_then(toml_edit::Item::as_integer)
488 {
489 block.push_str(&format!("max_tokens = {t}\n"));
490 }
491 if cloud
492 .get("server_compaction")
493 .and_then(toml_edit::Item::as_bool)
494 == Some(true)
495 {
496 block.push_str("server_compaction = true\n");
497 }
498 if cloud
499 .get("enable_extended_context")
500 .and_then(toml_edit::Item::as_bool)
501 == Some(true)
502 {
503 block.push_str("enable_extended_context = true\n");
504 }
505 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
506 let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
507 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
508 }
509 } else if let Some(m) = model {
510 block.push_str(&format!("model = \"{m}\"\n"));
511 }
512 vec![block]
513}
514
515#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
516fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
517 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
518 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
519 copy_str_field(openai, "model", &mut block);
520 copy_str_field(openai, "base_url", &mut block);
521 copy_int_field(openai, "max_tokens", &mut block);
522 copy_str_field(openai, "embedding_model", &mut block);
523 copy_str_field(openai, "reasoning_effort", &mut block);
524 } else if let Some(m) = model {
525 block.push_str(&format!("model = \"{m}\"\n"));
526 }
527 vec![block]
528}
529
530#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
531fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
532 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
533 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
534 copy_str_field(gemini, "model", &mut block);
535 copy_int_field(gemini, "max_tokens", &mut block);
536 copy_str_field(gemini, "base_url", &mut block);
537 copy_str_field(gemini, "embedding_model", &mut block);
538 copy_str_field(gemini, "thinking_level", &mut block);
539 copy_int_field(gemini, "thinking_budget", &mut block);
540 if let Some(v) = gemini
541 .get("include_thoughts")
542 .and_then(toml_edit::Item::as_bool)
543 {
544 block.push_str(&format!("include_thoughts = {v}\n"));
545 }
546 } else if let Some(m) = model {
547 block.push_str(&format!("model = \"{m}\"\n"));
548 }
549 vec![block]
550}
551
552#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
553fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
554 let mut blocks = Vec::new();
555 if let Some(compat_arr) = llm
556 .get("compatible")
557 .and_then(toml_edit::Item::as_array_of_tables)
558 {
559 for entry in compat_arr {
560 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
561 copy_str_field(entry, "name", &mut block);
562 copy_str_field(entry, "base_url", &mut block);
563 copy_str_field(entry, "model", &mut block);
564 copy_int_field(entry, "max_tokens", &mut block);
565 copy_str_field(entry, "embedding_model", &mut block);
566 blocks.push(block);
567 }
568 }
569 blocks
570}
571
572#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
574fn migrate_orchestrator_provider(
575 llm: &toml_edit::Table,
576 model: &Option<String>,
577 base_url: &Option<String>,
578 embedding_model: &Option<String>,
579) -> (Vec<String>, Option<String>, Option<String>) {
580 let mut blocks = Vec::new();
581 let routing = Some("task".to_owned());
582 let mut routes_block = None;
583 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
584 let default_name = orch
585 .get("default")
586 .and_then(toml_edit::Item::as_str)
587 .unwrap_or("")
588 .to_owned();
589 let embed_name = orch
590 .get("embed")
591 .and_then(toml_edit::Item::as_str)
592 .unwrap_or("")
593 .to_owned();
594 if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
595 let mut rb = "[llm.routes]\n".to_owned();
596 for (key, val) in routes {
597 if let Some(arr) = val.as_array() {
598 let items: Vec<String> = arr
599 .iter()
600 .filter_map(toml_edit::Value::as_str)
601 .map(|s| format!("\"{s}\""))
602 .collect();
603 rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
604 }
605 }
606 routes_block = Some(rb);
607 }
608 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
609 for (name, pcfg_item) in providers {
610 let Some(pcfg) = pcfg_item.as_table() else {
611 continue;
612 };
613 let ptype = pcfg
614 .get("type")
615 .and_then(toml_edit::Item::as_str)
616 .unwrap_or("ollama");
617 let mut block =
618 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
619 if name == default_name {
620 block.push_str("default = true\n");
621 }
622 if name == embed_name {
623 block.push_str("embed = true\n");
624 }
625 copy_str_field(pcfg, "model", &mut block);
626 copy_str_field(pcfg, "base_url", &mut block);
627 copy_str_field(pcfg, "embedding_model", &mut block);
628 if ptype == "claude" && !pcfg.contains_key("model") {
629 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
630 copy_str_field(cloud, "model", &mut block);
631 copy_int_field(cloud, "max_tokens", &mut block);
632 }
633 }
634 if ptype == "openai" && !pcfg.contains_key("model") {
635 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
636 copy_str_field(openai, "model", &mut block);
637 copy_str_field(openai, "base_url", &mut block);
638 copy_int_field(openai, "max_tokens", &mut block);
639 copy_str_field(openai, "embedding_model", &mut block);
640 }
641 }
642 if ptype == "ollama" && !pcfg.contains_key("base_url") {
643 if let Some(u) = base_url {
644 block.push_str(&format!("base_url = \"{u}\"\n"));
645 }
646 }
647 if ptype == "ollama" && !pcfg.contains_key("model") {
648 if let Some(m) = model {
649 block.push_str(&format!("model = \"{m}\"\n"));
650 }
651 }
652 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
653 if let Some(em) = embedding_model {
654 block.push_str(&format!("embedding_model = \"{em}\"\n"));
655 }
656 }
657 blocks.push(block);
658 }
659 }
660 }
661 (blocks, routing, routes_block)
662}
663
664#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
666fn migrate_router_provider(
667 llm: &toml_edit::Table,
668 model: &Option<String>,
669 base_url: &Option<String>,
670 embedding_model: &Option<String>,
671) -> (Vec<String>, Option<String>) {
672 let mut blocks = Vec::new();
673 let mut routing = None;
674 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
675 let strategy = router
676 .get("strategy")
677 .and_then(toml_edit::Item::as_str)
678 .unwrap_or("ema");
679 routing = Some(strategy.to_owned());
680 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
681 for item in chain {
682 let name = item.as_str().unwrap_or_default();
683 let ptype = infer_provider_type(name, llm);
684 let mut block =
685 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
686 match ptype {
687 "claude" => {
688 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
689 copy_str_field(cloud, "model", &mut block);
690 copy_int_field(cloud, "max_tokens", &mut block);
691 }
692 }
693 "openai" => {
694 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
695 {
696 copy_str_field(openai, "model", &mut block);
697 copy_str_field(openai, "base_url", &mut block);
698 copy_int_field(openai, "max_tokens", &mut block);
699 copy_str_field(openai, "embedding_model", &mut block);
700 } else {
701 if let Some(m) = model {
702 block.push_str(&format!("model = \"{m}\"\n"));
703 }
704 if let Some(u) = base_url {
705 block.push_str(&format!("base_url = \"{u}\"\n"));
706 }
707 }
708 }
709 "ollama" => {
710 if let Some(m) = model {
711 block.push_str(&format!("model = \"{m}\"\n"));
712 }
713 if let Some(em) = embedding_model {
714 block.push_str(&format!("embedding_model = \"{em}\"\n"));
715 }
716 if let Some(u) = base_url {
717 block.push_str(&format!("base_url = \"{u}\"\n"));
718 }
719 }
720 _ => {
721 if let Some(m) = model {
722 block.push_str(&format!("model = \"{m}\"\n"));
723 }
724 }
725 }
726 blocks.push(block);
727 }
728 }
729 }
730 (blocks, routing)
731}
732
733#[allow(
744 clippy::too_many_lines,
745 clippy::format_push_string,
746 clippy::manual_let_else,
747 clippy::op_ref,
748 clippy::collapsible_if
749)]
750pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
751 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
752
753 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
755 Some(t) => t,
756 None => {
757 return Ok(MigrationResult {
759 output: toml_src.to_owned(),
760 added_count: 0,
761 sections_added: Vec::new(),
762 });
763 }
764 };
765
766 let has_provider_field = llm.contains_key("provider");
767 let has_cloud = llm.contains_key("cloud");
768 let has_openai = llm.contains_key("openai");
769 let has_gemini = llm.contains_key("gemini");
770 let has_orchestrator = llm.contains_key("orchestrator");
771 let has_router = llm.contains_key("router");
772 let has_providers = llm.contains_key("providers");
773
774 if !has_provider_field
775 && !has_cloud
776 && !has_openai
777 && !has_orchestrator
778 && !has_router
779 && !has_gemini
780 {
781 return Ok(MigrationResult {
783 output: toml_src.to_owned(),
784 added_count: 0,
785 sections_added: Vec::new(),
786 });
787 }
788
789 if has_providers {
790 return Err(MigrateError::Parse(
792 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
793 .parse::<toml_edit::DocumentMut>()
794 .unwrap_err(),
795 ));
796 }
797
798 let provider_str = llm
800 .get("provider")
801 .and_then(toml_edit::Item::as_str)
802 .unwrap_or("ollama");
803 let base_url = llm
804 .get("base_url")
805 .and_then(toml_edit::Item::as_str)
806 .map(str::to_owned);
807 let model = llm
808 .get("model")
809 .and_then(toml_edit::Item::as_str)
810 .map(str::to_owned);
811 let embedding_model = llm
812 .get("embedding_model")
813 .and_then(toml_edit::Item::as_str)
814 .map(str::to_owned);
815
816 let mut provider_blocks: Vec<String> = Vec::new();
818 let mut routing: Option<String> = None;
819 let mut routes_block: Option<String> = None;
820
821 match provider_str {
822 "ollama" => {
823 provider_blocks.extend(migrate_ollama_provider(
824 llm,
825 &model,
826 &base_url,
827 &embedding_model,
828 ));
829 }
830 "claude" => {
831 provider_blocks.extend(migrate_claude_provider(llm, &model));
832 }
833 "openai" => {
834 provider_blocks.extend(migrate_openai_provider(llm, &model));
835 }
836 "gemini" => {
837 provider_blocks.extend(migrate_gemini_provider(llm, &model));
838 }
839 "compatible" => {
840 provider_blocks.extend(migrate_compatible_provider(llm));
841 }
842 "orchestrator" => {
843 let (blocks, r, rb) =
844 migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
845 provider_blocks.extend(blocks);
846 routing = r;
847 routes_block = rb;
848 }
849 "router" => {
850 let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
851 provider_blocks.extend(blocks);
852 routing = r;
853 }
854 other => {
855 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
856 if let Some(ref m) = model {
857 block.push_str(&format!("model = \"{m}\"\n"));
858 }
859 provider_blocks.push(block);
860 }
861 }
862
863 if provider_blocks.is_empty() {
864 return Ok(MigrationResult {
866 output: toml_src.to_owned(),
867 added_count: 0,
868 sections_added: Vec::new(),
869 });
870 }
871
872 let mut new_llm = "[llm]\n".to_owned();
874 if let Some(ref r) = routing {
875 new_llm.push_str(&format!("routing = \"{r}\"\n"));
876 }
877 for key in &[
879 "response_cache_enabled",
880 "response_cache_ttl_secs",
881 "semantic_cache_enabled",
882 "semantic_cache_threshold",
883 "semantic_cache_max_candidates",
884 "summary_model",
885 "instruction_file",
886 ] {
887 if let Some(val) = llm.get(key) {
888 if let Some(v) = val.as_value() {
889 let raw = value_to_toml_string(v);
890 if !raw.is_empty() {
891 new_llm.push_str(&format!("{key} = {raw}\n"));
892 }
893 }
894 }
895 }
896 new_llm.push('\n');
897
898 if let Some(rb) = routes_block {
899 new_llm.push_str(&rb);
900 new_llm.push('\n');
901 }
902
903 for block in &provider_blocks {
904 new_llm.push_str(block);
905 new_llm.push('\n');
906 }
907
908 let output = replace_llm_section(toml_src, &new_llm);
911
912 Ok(MigrationResult {
913 output,
914 added_count: provider_blocks.len(),
915 sections_added: vec!["llm.providers".to_owned()],
916 })
917}
918
919fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
921 match name {
922 "claude" => "claude",
923 "openai" => "openai",
924 "gemini" => "gemini",
925 "ollama" => "ollama",
926 "candle" => "candle",
927 _ => {
928 if llm.contains_key("compatible") {
930 "compatible"
931 } else if llm.contains_key("openai") {
932 "openai"
933 } else {
934 "ollama"
935 }
936 }
937 }
938}
939
940fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
941 use std::fmt::Write as _;
942 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
943 let _ = writeln!(out, "{key} = \"{v}\"");
944 }
945}
946
947fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
948 use std::fmt::Write as _;
949 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
950 let _ = writeln!(out, "{key} = {v}");
951 }
952}
953
954fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
957 let mut out = String::new();
958 let mut in_llm = false;
959 let mut skip_until_next_top = false;
960
961 for line in toml_str.lines() {
962 let trimmed = line.trim();
963
964 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
966 && trimmed.ends_with(']')
967 && !trimmed[1..trimmed.len() - 1].contains('.');
968 let is_top_aot = trimmed.starts_with("[[")
969 && trimmed.ends_with("]]")
970 && !trimmed[2..trimmed.len() - 2].contains('.');
971 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
972 && (trimmed.contains(']'));
973
974 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
975 in_llm = true;
976 skip_until_next_top = true;
977 continue;
978 }
979
980 if is_top_section || is_top_aot {
981 if skip_until_next_top {
982 out.push_str(new_llm_section);
984 skip_until_next_top = false;
985 }
986 in_llm = false;
987 }
988
989 if !skip_until_next_top {
990 out.push_str(line);
991 out.push('\n');
992 }
993 }
994
995 if skip_until_next_top {
997 out.push_str(new_llm_section);
998 }
999
1000 out
1001}
1002
1003#[allow(clippy::too_many_lines)]
1022pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1023 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1024
1025 let stt_model = doc
1027 .get("llm")
1028 .and_then(toml_edit::Item::as_table)
1029 .and_then(|llm| llm.get("stt"))
1030 .and_then(toml_edit::Item::as_table)
1031 .and_then(|stt| stt.get("model"))
1032 .and_then(toml_edit::Item::as_str)
1033 .map(ToOwned::to_owned);
1034
1035 let stt_base_url = doc
1036 .get("llm")
1037 .and_then(toml_edit::Item::as_table)
1038 .and_then(|llm| llm.get("stt"))
1039 .and_then(toml_edit::Item::as_table)
1040 .and_then(|stt| stt.get("base_url"))
1041 .and_then(toml_edit::Item::as_str)
1042 .map(ToOwned::to_owned);
1043
1044 let stt_provider_hint = doc
1045 .get("llm")
1046 .and_then(toml_edit::Item::as_table)
1047 .and_then(|llm| llm.get("stt"))
1048 .and_then(toml_edit::Item::as_table)
1049 .and_then(|stt| stt.get("provider"))
1050 .and_then(toml_edit::Item::as_str)
1051 .map(ToOwned::to_owned)
1052 .unwrap_or_default();
1053
1054 if stt_model.is_none() && stt_base_url.is_none() {
1056 return Ok(MigrationResult {
1057 output: toml_src.to_owned(),
1058 added_count: 0,
1059 sections_added: Vec::new(),
1060 });
1061 }
1062
1063 let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1064
1065 let target_type = match stt_provider_hint.as_str() {
1067 "candle-whisper" | "candle" => "candle",
1068 _ => "openai",
1069 };
1070
1071 let providers = doc
1074 .get("llm")
1075 .and_then(toml_edit::Item::as_table)
1076 .and_then(|llm| llm.get("providers"))
1077 .and_then(toml_edit::Item::as_array_of_tables);
1078
1079 let matching_idx = providers.and_then(|arr| {
1080 arr.iter().enumerate().find_map(|(i, t)| {
1081 let name = t
1082 .get("name")
1083 .and_then(toml_edit::Item::as_str)
1084 .unwrap_or("");
1085 let ptype = t
1086 .get("type")
1087 .and_then(toml_edit::Item::as_str)
1088 .unwrap_or("");
1089 let name_match = !stt_provider_hint.is_empty()
1091 && (name == stt_provider_hint || ptype == stt_provider_hint);
1092 let type_match = ptype == target_type;
1093 if name_match || type_match {
1094 Some(i)
1095 } else {
1096 None
1097 }
1098 })
1099 });
1100
1101 let resolved_provider_name: String;
1103
1104 if let Some(idx) = matching_idx {
1105 let llm_mut = doc
1107 .get_mut("llm")
1108 .and_then(toml_edit::Item::as_table_mut)
1109 .ok_or(MigrateError::InvalidStructure(
1110 "[llm] table not accessible for mutation",
1111 ))?;
1112 let providers_mut = llm_mut
1113 .get_mut("providers")
1114 .and_then(toml_edit::Item::as_array_of_tables_mut)
1115 .ok_or(MigrateError::InvalidStructure(
1116 "[[llm.providers]] array not accessible for mutation",
1117 ))?;
1118 let entry = providers_mut
1119 .iter_mut()
1120 .nth(idx)
1121 .ok_or(MigrateError::InvalidStructure(
1122 "[[llm.providers]] entry index out of range during mutation",
1123 ))?;
1124
1125 let existing_name = entry
1127 .get("name")
1128 .and_then(toml_edit::Item::as_str)
1129 .map(ToOwned::to_owned);
1130 let entry_name = existing_name.unwrap_or_else(|| {
1131 let t = entry
1132 .get("type")
1133 .and_then(toml_edit::Item::as_str)
1134 .unwrap_or("openai");
1135 format!("{t}-stt")
1136 });
1137 entry.insert("name", toml_edit::value(entry_name.clone()));
1138 entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1139 if stt_base_url.is_some() && entry.get("base_url").is_none() {
1140 entry.insert(
1141 "base_url",
1142 toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1143 );
1144 }
1145 resolved_provider_name = entry_name;
1146 } else {
1147 let new_name = if target_type == "candle" {
1149 "local-whisper".to_owned()
1150 } else {
1151 "openai-stt".to_owned()
1152 };
1153 let mut new_entry = toml_edit::Table::new();
1154 new_entry.insert("name", toml_edit::value(new_name.clone()));
1155 new_entry.insert("type", toml_edit::value(target_type));
1156 new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1157 if let Some(ref url) = stt_base_url {
1158 new_entry.insert("base_url", toml_edit::value(url.clone()));
1159 }
1160 let llm_mut = doc
1162 .get_mut("llm")
1163 .and_then(toml_edit::Item::as_table_mut)
1164 .ok_or(MigrateError::InvalidStructure(
1165 "[llm] table not accessible for mutation",
1166 ))?;
1167 if let Some(item) = llm_mut.get_mut("providers") {
1168 if let Some(arr) = item.as_array_of_tables_mut() {
1169 arr.push(new_entry);
1170 }
1171 } else {
1172 let mut arr = toml_edit::ArrayOfTables::new();
1173 arr.push(new_entry);
1174 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1175 }
1176 resolved_provider_name = new_name;
1177 }
1178
1179 if let Some(stt_table) = doc
1181 .get_mut("llm")
1182 .and_then(toml_edit::Item::as_table_mut)
1183 .and_then(|llm| llm.get_mut("stt"))
1184 .and_then(toml_edit::Item::as_table_mut)
1185 {
1186 stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1187 stt_table.remove("model");
1188 stt_table.remove("base_url");
1189 }
1190
1191 Ok(MigrationResult {
1192 output: doc.to_string(),
1193 added_count: 1,
1194 sections_added: vec!["llm.providers.stt_model".to_owned()],
1195 })
1196}
1197
1198pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1211 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1212
1213 let old_value = doc
1214 .get("orchestration")
1215 .and_then(toml_edit::Item::as_table)
1216 .and_then(|t| t.get("planner_model"))
1217 .and_then(toml_edit::Item::as_value)
1218 .and_then(toml_edit::Value::as_str)
1219 .map(ToOwned::to_owned);
1220
1221 let Some(old_model) = old_value else {
1222 return Ok(MigrationResult {
1223 output: toml_src.to_owned(),
1224 added_count: 0,
1225 sections_added: Vec::new(),
1226 });
1227 };
1228
1229 let commented_out = format!(
1233 "# planner_provider = \"{old_model}\" \
1234 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1235 );
1236
1237 let orch_table = doc
1238 .get_mut("orchestration")
1239 .and_then(toml_edit::Item::as_table_mut)
1240 .ok_or(MigrateError::InvalidStructure(
1241 "[orchestration] is not a table",
1242 ))?;
1243 orch_table.remove("planner_model");
1244 let decor = orch_table.decor_mut();
1245 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1246 let new_suffix = if existing_suffix.trim().is_empty() {
1248 format!("\n{commented_out}\n")
1249 } else {
1250 format!("{existing_suffix}\n{commented_out}\n")
1251 };
1252 decor.set_suffix(new_suffix);
1253
1254 eprintln!(
1255 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1256 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1257 `name` field, not a raw model name. Update or remove the commented line."
1258 );
1259
1260 Ok(MigrationResult {
1261 output: doc.to_string(),
1262 added_count: 1,
1263 sections_added: vec!["orchestration.planner_provider".to_owned()],
1264 })
1265}
1266
1267pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1281 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1282 let mut added = 0usize;
1283
1284 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1285 return Ok(MigrationResult {
1286 output: toml_src.to_owned(),
1287 added_count: 0,
1288 sections_added: Vec::new(),
1289 });
1290 };
1291
1292 let Some(servers) = mcp
1293 .get_mut("servers")
1294 .and_then(toml_edit::Item::as_array_of_tables_mut)
1295 else {
1296 return Ok(MigrationResult {
1297 output: toml_src.to_owned(),
1298 added_count: 0,
1299 sections_added: Vec::new(),
1300 });
1301 };
1302
1303 for entry in servers.iter_mut() {
1304 if !entry.contains_key("trust_level") {
1305 entry.insert(
1306 "trust_level",
1307 toml_edit::value(toml_edit::Value::from("trusted")),
1308 );
1309 added += 1;
1310 }
1311 }
1312
1313 if added > 0 {
1314 eprintln!(
1315 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1316 entr{} (preserving previous SSRF-skip behavior). \
1317 Review and adjust trust levels as needed.",
1318 if added == 1 { "y" } else { "ies" }
1319 );
1320 }
1321
1322 Ok(MigrationResult {
1323 output: doc.to_string(),
1324 added_count: added,
1325 sections_added: if added > 0 {
1326 vec!["mcp.servers.trust_level".to_owned()]
1327 } else {
1328 Vec::new()
1329 },
1330 })
1331}
1332
1333pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1344 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1345
1346 let max_retries = doc
1347 .get("agent")
1348 .and_then(toml_edit::Item::as_table)
1349 .and_then(|t| t.get("max_tool_retries"))
1350 .and_then(toml_edit::Item::as_value)
1351 .and_then(toml_edit::Value::as_integer)
1352 .map(i64::cast_unsigned);
1353
1354 let budget_secs = doc
1355 .get("agent")
1356 .and_then(toml_edit::Item::as_table)
1357 .and_then(|t| t.get("max_retry_duration_secs"))
1358 .and_then(toml_edit::Item::as_value)
1359 .and_then(toml_edit::Value::as_integer)
1360 .map(i64::cast_unsigned);
1361
1362 if max_retries.is_none() && budget_secs.is_none() {
1363 return Ok(MigrationResult {
1364 output: toml_src.to_owned(),
1365 added_count: 0,
1366 sections_added: Vec::new(),
1367 });
1368 }
1369
1370 if !doc.contains_key("tools") {
1372 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1373 }
1374 let tools_table = doc
1375 .get_mut("tools")
1376 .and_then(toml_edit::Item::as_table_mut)
1377 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1378
1379 if !tools_table.contains_key("retry") {
1380 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1381 }
1382 let retry_table = tools_table
1383 .get_mut("retry")
1384 .and_then(toml_edit::Item::as_table_mut)
1385 .ok_or(MigrateError::InvalidStructure(
1386 "[tools.retry] is not a table",
1387 ))?;
1388
1389 let mut added_count = 0usize;
1390
1391 if let Some(retries) = max_retries
1392 && !retry_table.contains_key("max_attempts")
1393 {
1394 retry_table.insert(
1395 "max_attempts",
1396 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1397 );
1398 added_count += 1;
1399 }
1400
1401 if let Some(secs) = budget_secs
1402 && !retry_table.contains_key("budget_secs")
1403 {
1404 retry_table.insert(
1405 "budget_secs",
1406 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1407 );
1408 added_count += 1;
1409 }
1410
1411 if added_count > 0 {
1412 eprintln!(
1413 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1414 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1415 );
1416 }
1417
1418 Ok(MigrationResult {
1419 output: doc.to_string(),
1420 added_count,
1421 sections_added: if added_count > 0 {
1422 vec!["tools.retry".to_owned()]
1423 } else {
1424 Vec::new()
1425 },
1426 })
1427}
1428
1429pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1438 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1439
1440 if !doc.contains_key("memory") {
1442 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1443 }
1444
1445 let memory = doc
1446 .get_mut("memory")
1447 .and_then(toml_edit::Item::as_table_mut)
1448 .ok_or(MigrateError::InvalidStructure(
1449 "[memory] key exists but is not a table",
1450 ))?;
1451
1452 if memory.contains_key("database_url") {
1453 return Ok(MigrationResult {
1454 output: toml_src.to_owned(),
1455 added_count: 0,
1456 sections_added: Vec::new(),
1457 });
1458 }
1459
1460 let comment = "# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1462 # Leave empty and store the actual URL in the vault:\n\
1463 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1464 # database_url = \"\"\n";
1465 append_comment_to_table_suffix(memory, comment);
1466
1467 Ok(MigrationResult {
1468 output: doc.to_string(),
1469 added_count: 1,
1470 sections_added: vec!["memory.database_url".to_owned()],
1471 })
1472}
1473
1474pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1483 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1484
1485 let tools_shell_exists = doc
1486 .get("tools")
1487 .and_then(toml_edit::Item::as_table)
1488 .is_some_and(|t| t.contains_key("shell"));
1489 if !tools_shell_exists {
1490 return Ok(MigrationResult {
1492 output: toml_src.to_owned(),
1493 added_count: 0,
1494 sections_added: Vec::new(),
1495 });
1496 }
1497
1498 let shell = doc
1499 .get_mut("tools")
1500 .and_then(toml_edit::Item::as_table_mut)
1501 .and_then(|t| t.get_mut("shell"))
1502 .and_then(toml_edit::Item::as_table_mut)
1503 .ok_or(MigrateError::InvalidStructure(
1504 "[tools.shell] is not a table",
1505 ))?;
1506
1507 if shell.contains_key("transactional") {
1508 return Ok(MigrationResult {
1509 output: toml_src.to_owned(),
1510 added_count: 0,
1511 sections_added: Vec::new(),
1512 });
1513 }
1514
1515 let comment = "# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1516 # transactional = false\n\
1517 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1518 # auto_rollback = false # rollback when exit code >= 2\n\
1519 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1520 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1521 append_comment_to_table_suffix(shell, comment);
1522
1523 Ok(MigrationResult {
1524 output: doc.to_string(),
1525 added_count: 1,
1526 sections_added: vec!["tools.shell.transactional".to_owned()],
1527 })
1528}
1529
1530pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1536 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1537
1538 let agent_exists = doc.contains_key("agent");
1539 if !agent_exists {
1540 return Ok(MigrationResult {
1541 output: toml_src.to_owned(),
1542 added_count: 0,
1543 sections_added: Vec::new(),
1544 });
1545 }
1546
1547 let agent = doc
1548 .get_mut("agent")
1549 .and_then(toml_edit::Item::as_table_mut)
1550 .ok_or(MigrateError::InvalidStructure("[agent] is not a table"))?;
1551
1552 if agent.contains_key("budget_hint_enabled") {
1553 return Ok(MigrationResult {
1554 output: toml_src.to_owned(),
1555 added_count: 0,
1556 sections_added: Vec::new(),
1557 });
1558 }
1559
1560 let comment = "# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1561 # budget_hint_enabled = true\n";
1562 append_comment_to_table_suffix(agent, comment);
1563
1564 Ok(MigrationResult {
1565 output: doc.to_string(),
1566 added_count: 1,
1567 sections_added: vec!["agent.budget_hint_enabled".to_owned()],
1568 })
1569}
1570
1571pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1580 use toml_edit::{Item, Table};
1581
1582 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1583
1584 if !doc.contains_key("memory") {
1586 doc.insert("memory", Item::Table(Table::new()));
1587 }
1588
1589 let memory = doc
1590 .get_mut("memory")
1591 .and_then(Item::as_table_mut)
1592 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1593
1594 if memory.contains_key("forgetting") {
1595 return Ok(MigrationResult {
1596 output: toml_src.to_owned(),
1597 added_count: 0,
1598 sections_added: Vec::new(),
1599 });
1600 }
1601
1602 let comment = "# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1603 # [memory.forgetting]\n\
1604 # enabled = false\n\
1605 # decay_rate = 0.1 # per-sweep importance decay\n\
1606 # forgetting_floor = 0.05 # prune below this score\n\
1607 # sweep_interval_secs = 7200 # run every 2 hours\n\
1608 # sweep_batch_size = 500\n\
1609 # protect_recent_hours = 24\n\
1610 # protect_min_access_count = 3\n";
1611 append_comment_to_table_suffix(memory, comment);
1612
1613 Ok(MigrationResult {
1614 output: doc.to_string(),
1615 added_count: 1,
1616 sections_added: vec!["memory.forgetting".to_owned()],
1617 })
1618}
1619
1620pub fn migrate_compression_predictor_config(
1628 toml_src: &str,
1629) -> Result<MigrationResult, MigrateError> {
1630 use toml_edit::{Item, Table};
1631
1632 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1633
1634 if !doc.contains_key("memory") {
1636 doc.insert("memory", Item::Table(Table::new()));
1637 }
1638 let memory = doc
1639 .get_mut("memory")
1640 .and_then(Item::as_table_mut)
1641 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1642
1643 if !memory.contains_key("compression") {
1644 memory.insert("compression", Item::Table(Table::new()));
1645 }
1646 let compression = memory
1647 .get_mut("compression")
1648 .and_then(Item::as_table_mut)
1649 .ok_or(MigrateError::InvalidStructure(
1650 "[memory.compression] is not a table",
1651 ))?;
1652
1653 if compression.contains_key("predictor") {
1654 return Ok(MigrationResult {
1655 output: toml_src.to_owned(),
1656 added_count: 0,
1657 sections_added: Vec::new(),
1658 });
1659 }
1660
1661 let comment = "# Performance-floor compression ratio predictor (#2460). Disabled by default.\n\
1662 # [memory.compression.predictor]\n\
1663 # enabled = false\n\
1664 # min_samples = 10 # cold-start threshold\n\
1665 # candidate_ratios = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]\n\
1666 # retrain_interval = 5\n\
1667 # max_training_samples = 200\n";
1668 append_comment_to_table_suffix(compression, comment);
1669
1670 Ok(MigrationResult {
1671 output: doc.to_string(),
1672 added_count: 1,
1673 sections_added: vec!["memory.compression.predictor".to_owned()],
1674 })
1675}
1676
1677pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1683 use toml_edit::{Item, Table};
1684
1685 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1686
1687 if !doc.contains_key("memory") {
1688 doc.insert("memory", Item::Table(Table::new()));
1689 }
1690 let memory = doc
1691 .get_mut("memory")
1692 .and_then(Item::as_table_mut)
1693 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1694
1695 if memory.contains_key("microcompact") {
1696 return Ok(MigrationResult {
1697 output: toml_src.to_owned(),
1698 added_count: 0,
1699 sections_added: Vec::new(),
1700 });
1701 }
1702
1703 let comment = "# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1704 # [memory.microcompact]\n\
1705 # enabled = false\n\
1706 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1707 # keep_recent = 3 # always keep this many recent outputs intact\n";
1708 append_comment_to_table_suffix(memory, comment);
1709
1710 Ok(MigrationResult {
1711 output: doc.to_string(),
1712 added_count: 1,
1713 sections_added: vec!["memory.microcompact".to_owned()],
1714 })
1715}
1716
1717pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1723 use toml_edit::{Item, Table};
1724
1725 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1726
1727 if !doc.contains_key("memory") {
1728 doc.insert("memory", Item::Table(Table::new()));
1729 }
1730 let memory = doc
1731 .get_mut("memory")
1732 .and_then(Item::as_table_mut)
1733 .ok_or(MigrateError::InvalidStructure("[memory] is not a table"))?;
1734
1735 if memory.contains_key("autodream") {
1736 return Ok(MigrationResult {
1737 output: toml_src.to_owned(),
1738 added_count: 0,
1739 sections_added: Vec::new(),
1740 });
1741 }
1742
1743 let comment = "# autoDream background memory consolidation (#2697). Disabled by default.\n\
1744 # [memory.autodream]\n\
1745 # enabled = false\n\
1746 # min_sessions = 5 # sessions since last consolidation\n\
1747 # min_hours = 8 # hours since last consolidation\n\
1748 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1749 # max_iterations = 5\n";
1750 append_comment_to_table_suffix(memory, comment);
1751
1752 Ok(MigrationResult {
1753 output: doc.to_string(),
1754 added_count: 1,
1755 sections_added: vec!["memory.autodream".to_owned()],
1756 })
1757}
1758
1759pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1765 use toml_edit::{Item, Table};
1766
1767 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1768
1769 if doc.contains_key("magic_docs") {
1770 return Ok(MigrationResult {
1771 output: toml_src.to_owned(),
1772 added_count: 0,
1773 sections_added: Vec::new(),
1774 });
1775 }
1776
1777 doc.insert("magic_docs", Item::Table(Table::new()));
1778 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1779 # [magic_docs]\n\
1780 # enabled = false\n\
1781 # min_turns_between_updates = 10\n\
1782 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1783 # max_iterations = 3\n";
1784 doc.remove("magic_docs");
1786 let raw = doc.to_string();
1788 let output = format!("{raw}\n{comment}");
1789
1790 Ok(MigrationResult {
1791 output,
1792 added_count: 1,
1793 sections_added: vec!["magic_docs".to_owned()],
1794 })
1795}
1796
1797pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1806 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1807
1808 if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1809 return Ok(MigrationResult {
1810 output: toml_src.to_owned(),
1811 added_count: 0,
1812 sections_added: Vec::new(),
1813 });
1814 }
1815
1816 let comment = "\n\
1817 # Profiling and distributed tracing (requires --features profiling). All\n\
1818 # instrumentation points are zero-overhead when the feature is absent.\n\
1819 # [telemetry]\n\
1820 # enabled = false\n\
1821 # backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1822 # trace_dir = \".local/traces\"\n\
1823 # include_args = true\n\
1824 # service_name = \"zeph-agent\"\n\
1825 # sample_rate = 1.0\n";
1826
1827 let raw = doc.to_string();
1828 let output = format!("{raw}{comment}");
1829
1830 Ok(MigrationResult {
1831 output,
1832 added_count: 1,
1833 sections_added: vec!["telemetry".to_owned()],
1834 })
1835}
1836
1837pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1846 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1848 return Ok(MigrationResult {
1849 output: toml_src.to_owned(),
1850 added_count: 0,
1851 sections_added: Vec::new(),
1852 });
1853 }
1854
1855 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1856
1857 if !doc.contains_key("agent") {
1860 return Ok(MigrationResult {
1861 output: toml_src.to_owned(),
1862 added_count: 0,
1863 sections_added: Vec::new(),
1864 });
1865 }
1866
1867 let comment = "\n\
1868 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1869 # [agent.supervisor]\n\
1870 # enrichment_limit = 4\n\
1871 # telemetry_limit = 8\n\
1872 # abort_enrichment_on_turn = false\n";
1873
1874 let raw = doc.to_string();
1875 let output = format!("{raw}{comment}");
1876
1877 Ok(MigrationResult {
1878 output,
1879 added_count: 1,
1880 sections_added: vec!["agent.supervisor".to_owned()],
1881 })
1882}
1883
1884#[cfg(test)]
1886fn make_formatted_str(s: &str) -> Value {
1887 use toml_edit::Formatted;
1888 Value::String(Formatted::new(s.to_owned()))
1889}
1890
1891#[cfg(test)]
1892mod tests {
1893 use super::*;
1894
1895 #[test]
1896 fn empty_config_gets_sections_as_comments() {
1897 let migrator = ConfigMigrator::new();
1898 let result = migrator.migrate("").expect("migrate empty");
1899 assert!(result.added_count > 0 || !result.sections_added.is_empty());
1901 assert!(
1903 result.output.contains("[agent]") || result.output.contains("# [agent]"),
1904 "expected agent section in output, got:\n{}",
1905 result.output
1906 );
1907 }
1908
1909 #[test]
1910 fn existing_values_not_overwritten() {
1911 let user = r#"
1912[agent]
1913name = "MyAgent"
1914max_tool_iterations = 5
1915"#;
1916 let migrator = ConfigMigrator::new();
1917 let result = migrator.migrate(user).expect("migrate");
1918 assert!(
1920 result.output.contains("name = \"MyAgent\""),
1921 "user value should be preserved"
1922 );
1923 assert!(
1924 result.output.contains("max_tool_iterations = 5"),
1925 "user value should be preserved"
1926 );
1927 assert!(
1929 !result.output.contains("# max_tool_iterations = 10"),
1930 "already-set key should not appear as comment"
1931 );
1932 }
1933
1934 #[test]
1935 fn missing_nested_key_added_as_comment() {
1936 let user = r#"
1938[memory]
1939sqlite_path = ".zeph/data/zeph.db"
1940"#;
1941 let migrator = ConfigMigrator::new();
1942 let result = migrator.migrate(user).expect("migrate");
1943 assert!(
1945 result.output.contains("# history_limit"),
1946 "missing key should be added as comment, got:\n{}",
1947 result.output
1948 );
1949 }
1950
1951 #[test]
1952 fn unknown_user_keys_preserved() {
1953 let user = r#"
1954[agent]
1955name = "Test"
1956my_custom_key = "preserved"
1957"#;
1958 let migrator = ConfigMigrator::new();
1959 let result = migrator.migrate(user).expect("migrate");
1960 assert!(
1961 result.output.contains("my_custom_key = \"preserved\""),
1962 "custom user keys must not be removed"
1963 );
1964 }
1965
1966 #[test]
1967 fn idempotent() {
1968 let migrator = ConfigMigrator::new();
1969 let first = migrator
1970 .migrate("[agent]\nname = \"Zeph\"\n")
1971 .expect("first migrate");
1972 let second = migrator.migrate(&first.output).expect("second migrate");
1973 assert_eq!(
1974 first.output, second.output,
1975 "idempotent: full output must be identical on second run"
1976 );
1977 }
1978
1979 #[test]
1980 fn malformed_input_returns_error() {
1981 let migrator = ConfigMigrator::new();
1982 let err = migrator
1983 .migrate("[[invalid toml [[[")
1984 .expect_err("should error");
1985 assert!(
1986 matches!(err, MigrateError::Parse(_)),
1987 "expected Parse error"
1988 );
1989 }
1990
1991 #[test]
1992 fn array_of_tables_preserved() {
1993 let user = r#"
1994[mcp]
1995allowed_commands = ["npx"]
1996
1997[[mcp.servers]]
1998id = "my-server"
1999command = "npx"
2000args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2001"#;
2002 let migrator = ConfigMigrator::new();
2003 let result = migrator.migrate(user).expect("migrate");
2004 assert!(
2006 result.output.contains("[[mcp.servers]]"),
2007 "array-of-tables entries must be preserved"
2008 );
2009 assert!(result.output.contains("id = \"my-server\""));
2010 }
2011
2012 #[test]
2013 fn canonical_ordering_applied() {
2014 let user = r#"
2016[memory]
2017sqlite_path = ".zeph/data/zeph.db"
2018
2019[agent]
2020name = "Test"
2021"#;
2022 let migrator = ConfigMigrator::new();
2023 let result = migrator.migrate(user).expect("migrate");
2024 let agent_pos = result.output.find("[agent]");
2026 let memory_pos = result.output.find("[memory]");
2027 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2028 assert!(a < m, "agent section should precede memory section");
2029 }
2030 }
2031
2032 #[test]
2033 fn value_to_toml_string_formats_correctly() {
2034 use toml_edit::Formatted;
2035
2036 let s = make_formatted_str("hello");
2037 assert_eq!(value_to_toml_string(&s), "\"hello\"");
2038
2039 let i = Value::Integer(Formatted::new(42_i64));
2040 assert_eq!(value_to_toml_string(&i), "42");
2041
2042 let b = Value::Boolean(Formatted::new(true));
2043 assert_eq!(value_to_toml_string(&b), "true");
2044
2045 let f = Value::Float(Formatted::new(1.0_f64));
2046 assert_eq!(value_to_toml_string(&f), "1.0");
2047
2048 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2049 assert_eq!(value_to_toml_string(&f2), "3.14");
2050
2051 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2052 let arr_val = Value::Array(arr);
2053 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2054
2055 let empty_arr = Value::Array(Array::new());
2056 assert_eq!(value_to_toml_string(&empty_arr), "[]");
2057 }
2058
2059 #[test]
2060 fn idempotent_full_output_unchanged() {
2061 let migrator = ConfigMigrator::new();
2063 let first = migrator
2064 .migrate("[agent]\nname = \"Zeph\"\n")
2065 .expect("first migrate");
2066 let second = migrator.migrate(&first.output).expect("second migrate");
2067 assert_eq!(
2068 first.output, second.output,
2069 "full output string must be identical after second migration pass"
2070 );
2071 }
2072
2073 #[test]
2074 fn full_config_produces_zero_additions() {
2075 let reference = include_str!("../config/default.toml");
2077 let migrator = ConfigMigrator::new();
2078 let result = migrator.migrate(reference).expect("migrate reference");
2079 assert_eq!(
2080 result.added_count, 0,
2081 "migrating the canonical reference should add nothing (added_count = {})",
2082 result.added_count
2083 );
2084 assert!(
2085 result.sections_added.is_empty(),
2086 "migrating the canonical reference should report no sections_added: {:?}",
2087 result.sections_added
2088 );
2089 }
2090
2091 #[test]
2092 fn empty_config_added_count_is_positive() {
2093 let migrator = ConfigMigrator::new();
2095 let result = migrator.migrate("").expect("migrate empty");
2096 assert!(
2097 result.added_count > 0,
2098 "empty config must report added_count > 0"
2099 );
2100 }
2101
2102 #[test]
2105 fn security_without_guardrail_gets_guardrail_commented() {
2106 let user = "[security]\nredact_secrets = true\n";
2107 let migrator = ConfigMigrator::new();
2108 let result = migrator.migrate(user).expect("migrate");
2109 assert!(
2111 result.output.contains("guardrail"),
2112 "migration must add guardrail keys for configs without [security.guardrail]: \
2113 got:\n{}",
2114 result.output
2115 );
2116 }
2117
2118 #[test]
2119 fn migrate_reference_contains_tools_policy() {
2120 let reference = include_str!("../config/default.toml");
2125 assert!(
2126 reference.contains("[tools.policy]"),
2127 "default.toml must contain [tools.policy] section so migrate-config can surface it"
2128 );
2129 assert!(
2130 reference.contains("enabled = false"),
2131 "tools.policy section must include enabled = false default"
2132 );
2133 }
2134
2135 #[test]
2136 fn migrate_reference_contains_probe_section() {
2137 let reference = include_str!("../config/default.toml");
2140 assert!(
2141 reference.contains("[memory.compression.probe]"),
2142 "default.toml must contain [memory.compression.probe] section comment"
2143 );
2144 assert!(
2145 reference.contains("hard_fail_threshold"),
2146 "probe section must include hard_fail_threshold default"
2147 );
2148 }
2149
2150 #[test]
2153 fn migrate_llm_no_llm_section_is_noop() {
2154 let src = "[agent]\nname = \"Zeph\"\n";
2155 let result = migrate_llm_to_providers(src).expect("migrate");
2156 assert_eq!(result.added_count, 0);
2157 assert_eq!(result.output, src);
2158 }
2159
2160 #[test]
2161 fn migrate_llm_already_new_format_is_noop() {
2162 let src = r#"
2163[llm]
2164[[llm.providers]]
2165type = "ollama"
2166model = "qwen3:8b"
2167"#;
2168 let result = migrate_llm_to_providers(src).expect("migrate");
2169 assert_eq!(result.added_count, 0);
2170 }
2171
2172 #[test]
2173 fn migrate_llm_ollama_produces_providers_block() {
2174 let src = r#"
2175[llm]
2176provider = "ollama"
2177model = "qwen3:8b"
2178base_url = "http://localhost:11434"
2179embedding_model = "nomic-embed-text"
2180"#;
2181 let result = migrate_llm_to_providers(src).expect("migrate");
2182 assert!(
2183 result.output.contains("[[llm.providers]]"),
2184 "should contain [[llm.providers]]:\n{}",
2185 result.output
2186 );
2187 assert!(
2188 result.output.contains("type = \"ollama\""),
2189 "{}",
2190 result.output
2191 );
2192 assert!(
2193 result.output.contains("model = \"qwen3:8b\""),
2194 "{}",
2195 result.output
2196 );
2197 }
2198
2199 #[test]
2200 fn migrate_llm_claude_produces_providers_block() {
2201 let src = r#"
2202[llm]
2203provider = "claude"
2204
2205[llm.cloud]
2206model = "claude-sonnet-4-6"
2207max_tokens = 8192
2208server_compaction = true
2209"#;
2210 let result = migrate_llm_to_providers(src).expect("migrate");
2211 assert!(
2212 result.output.contains("[[llm.providers]]"),
2213 "{}",
2214 result.output
2215 );
2216 assert!(
2217 result.output.contains("type = \"claude\""),
2218 "{}",
2219 result.output
2220 );
2221 assert!(
2222 result.output.contains("model = \"claude-sonnet-4-6\""),
2223 "{}",
2224 result.output
2225 );
2226 assert!(
2227 result.output.contains("server_compaction = true"),
2228 "{}",
2229 result.output
2230 );
2231 }
2232
2233 #[test]
2234 fn migrate_llm_openai_copies_fields() {
2235 let src = r#"
2236[llm]
2237provider = "openai"
2238
2239[llm.openai]
2240base_url = "https://api.openai.com/v1"
2241model = "gpt-4o"
2242max_tokens = 4096
2243"#;
2244 let result = migrate_llm_to_providers(src).expect("migrate");
2245 assert!(
2246 result.output.contains("type = \"openai\""),
2247 "{}",
2248 result.output
2249 );
2250 assert!(
2251 result
2252 .output
2253 .contains("base_url = \"https://api.openai.com/v1\""),
2254 "{}",
2255 result.output
2256 );
2257 }
2258
2259 #[test]
2260 fn migrate_llm_gemini_copies_fields() {
2261 let src = r#"
2262[llm]
2263provider = "gemini"
2264
2265[llm.gemini]
2266model = "gemini-2.0-flash"
2267max_tokens = 8192
2268base_url = "https://generativelanguage.googleapis.com"
2269"#;
2270 let result = migrate_llm_to_providers(src).expect("migrate");
2271 assert!(
2272 result.output.contains("type = \"gemini\""),
2273 "{}",
2274 result.output
2275 );
2276 assert!(
2277 result.output.contains("model = \"gemini-2.0-flash\""),
2278 "{}",
2279 result.output
2280 );
2281 }
2282
2283 #[test]
2284 fn migrate_llm_compatible_copies_multiple_entries() {
2285 let src = r#"
2286[llm]
2287provider = "compatible"
2288
2289[[llm.compatible]]
2290name = "proxy-a"
2291base_url = "http://proxy-a:8080/v1"
2292model = "llama3"
2293max_tokens = 4096
2294
2295[[llm.compatible]]
2296name = "proxy-b"
2297base_url = "http://proxy-b:8080/v1"
2298model = "mistral"
2299max_tokens = 2048
2300"#;
2301 let result = migrate_llm_to_providers(src).expect("migrate");
2302 let count = result.output.matches("[[llm.providers]]").count();
2304 assert_eq!(
2305 count, 2,
2306 "expected 2 [[llm.providers]] blocks:\n{}",
2307 result.output
2308 );
2309 assert!(
2310 result.output.contains("name = \"proxy-a\""),
2311 "{}",
2312 result.output
2313 );
2314 assert!(
2315 result.output.contains("name = \"proxy-b\""),
2316 "{}",
2317 result.output
2318 );
2319 }
2320
2321 #[test]
2322 fn migrate_llm_mixed_format_errors() {
2323 let src = r#"
2325[llm]
2326provider = "ollama"
2327
2328[[llm.providers]]
2329type = "ollama"
2330"#;
2331 assert!(
2332 migrate_llm_to_providers(src).is_err(),
2333 "mixed format must return error"
2334 );
2335 }
2336
2337 #[test]
2340 fn stt_migration_no_stt_section_returns_unchanged() {
2341 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2342 let result = migrate_stt_to_provider(src).unwrap();
2343 assert_eq!(result.added_count, 0);
2344 assert_eq!(result.output, src);
2345 }
2346
2347 #[test]
2348 fn stt_migration_no_model_or_base_url_returns_unchanged() {
2349 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2350 let result = migrate_stt_to_provider(src).unwrap();
2351 assert_eq!(result.added_count, 0);
2352 }
2353
2354 #[test]
2355 fn stt_migration_moves_model_to_provider_entry() {
2356 let src = r#"
2357[llm]
2358
2359[[llm.providers]]
2360type = "openai"
2361name = "quality"
2362model = "gpt-5.4"
2363
2364[llm.stt]
2365provider = "quality"
2366model = "gpt-4o-mini-transcribe"
2367language = "en"
2368"#;
2369 let result = migrate_stt_to_provider(src).unwrap();
2370 assert_eq!(result.added_count, 1);
2371 assert!(
2373 result.output.contains("stt_model"),
2374 "stt_model must be in output"
2375 );
2376 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2379 let stt = doc
2380 .get("llm")
2381 .and_then(toml_edit::Item::as_table)
2382 .and_then(|l| l.get("stt"))
2383 .and_then(toml_edit::Item::as_table)
2384 .unwrap();
2385 assert!(
2386 stt.get("model").is_none(),
2387 "model must be removed from [llm.stt]"
2388 );
2389 assert_eq!(
2390 stt.get("provider").and_then(toml_edit::Item::as_str),
2391 Some("quality")
2392 );
2393 }
2394
2395 #[test]
2396 fn stt_migration_creates_new_provider_when_no_match() {
2397 let src = r#"
2398[llm]
2399
2400[[llm.providers]]
2401type = "ollama"
2402name = "local"
2403model = "qwen3:8b"
2404
2405[llm.stt]
2406provider = "whisper"
2407model = "whisper-1"
2408base_url = "https://api.openai.com/v1"
2409language = "en"
2410"#;
2411 let result = migrate_stt_to_provider(src).unwrap();
2412 assert!(
2413 result.output.contains("openai-stt"),
2414 "new entry name must be openai-stt"
2415 );
2416 assert!(
2417 result.output.contains("stt_model"),
2418 "stt_model must be in output"
2419 );
2420 }
2421
2422 #[test]
2423 fn stt_migration_candle_whisper_creates_candle_entry() {
2424 let src = r#"
2425[llm]
2426
2427[llm.stt]
2428provider = "candle-whisper"
2429model = "openai/whisper-tiny"
2430language = "auto"
2431"#;
2432 let result = migrate_stt_to_provider(src).unwrap();
2433 assert!(
2434 result.output.contains("local-whisper"),
2435 "candle entry name must be local-whisper"
2436 );
2437 assert!(result.output.contains("candle"), "type must be candle");
2438 }
2439
2440 #[test]
2441 fn stt_migration_w2_assigns_explicit_name() {
2442 let src = r#"
2444[llm]
2445
2446[[llm.providers]]
2447type = "openai"
2448model = "gpt-5.4"
2449
2450[llm.stt]
2451provider = "openai"
2452model = "whisper-1"
2453language = "auto"
2454"#;
2455 let result = migrate_stt_to_provider(src).unwrap();
2456 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2457 let providers = doc
2458 .get("llm")
2459 .and_then(toml_edit::Item::as_table)
2460 .and_then(|l| l.get("providers"))
2461 .and_then(toml_edit::Item::as_array_of_tables)
2462 .unwrap();
2463 let entry = providers
2464 .iter()
2465 .find(|t| t.get("stt_model").is_some())
2466 .unwrap();
2467 assert!(
2469 entry.get("name").is_some(),
2470 "migrated entry must have explicit name"
2471 );
2472 }
2473
2474 #[test]
2475 fn stt_migration_removes_base_url_from_stt_table() {
2476 let src = r#"
2478[llm]
2479
2480[[llm.providers]]
2481type = "openai"
2482name = "quality"
2483model = "gpt-5.4"
2484
2485[llm.stt]
2486provider = "quality"
2487model = "whisper-1"
2488base_url = "https://api.openai.com/v1"
2489language = "en"
2490"#;
2491 let result = migrate_stt_to_provider(src).unwrap();
2492 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2493 let stt = doc
2494 .get("llm")
2495 .and_then(toml_edit::Item::as_table)
2496 .and_then(|l| l.get("stt"))
2497 .and_then(toml_edit::Item::as_table)
2498 .unwrap();
2499 assert!(
2500 stt.get("model").is_none(),
2501 "model must be removed from [llm.stt]"
2502 );
2503 assert!(
2504 stt.get("base_url").is_none(),
2505 "base_url must be removed from [llm.stt]"
2506 );
2507 }
2508
2509 #[test]
2510 fn migrate_planner_model_to_provider_with_field() {
2511 let input = r#"
2512[orchestration]
2513enabled = true
2514planner_model = "gpt-4o"
2515max_tasks = 20
2516"#;
2517 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2518 assert_eq!(result.added_count, 1, "added_count must be 1");
2519 assert!(
2520 !result.output.contains("planner_model = "),
2521 "planner_model key must be removed from output"
2522 );
2523 assert!(
2524 result.output.contains("# planner_provider"),
2525 "commented-out planner_provider entry must be present"
2526 );
2527 assert!(
2528 result.output.contains("gpt-4o"),
2529 "old value must appear in the comment"
2530 );
2531 assert!(
2532 result.output.contains("MIGRATED"),
2533 "comment must include MIGRATED marker"
2534 );
2535 }
2536
2537 #[test]
2538 fn migrate_planner_model_to_provider_no_op() {
2539 let input = r"
2540[orchestration]
2541enabled = true
2542max_tasks = 20
2543";
2544 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2545 assert_eq!(
2546 result.added_count, 0,
2547 "added_count must be 0 when field is absent"
2548 );
2549 assert_eq!(
2550 result.output, input,
2551 "output must equal input when nothing to migrate"
2552 );
2553 }
2554
2555 #[test]
2556 fn migrate_error_invalid_structure_formats_correctly() {
2557 let err = MigrateError::InvalidStructure("test sentinel");
2562 assert!(
2563 matches!(err, MigrateError::InvalidStructure(_)),
2564 "variant must match"
2565 );
2566 let msg = err.to_string();
2567 assert!(
2568 msg.contains("invalid TOML structure"),
2569 "error message must mention 'invalid TOML structure', got: {msg}"
2570 );
2571 assert!(
2572 msg.contains("test sentinel"),
2573 "message must include reason: {msg}"
2574 );
2575 }
2576
2577 #[test]
2580 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2581 let src = r#"
2582[mcp]
2583allowed_commands = ["npx"]
2584
2585[[mcp.servers]]
2586id = "srv-a"
2587command = "npx"
2588args = ["-y", "some-mcp"]
2589
2590[[mcp.servers]]
2591id = "srv-b"
2592command = "npx"
2593args = ["-y", "other-mcp"]
2594"#;
2595 let result = migrate_mcp_trust_levels(src).expect("migrate");
2596 assert_eq!(
2597 result.added_count, 2,
2598 "both entries must get trust_level added"
2599 );
2600 assert!(
2601 result
2602 .sections_added
2603 .contains(&"mcp.servers.trust_level".to_owned()),
2604 "sections_added must report mcp.servers.trust_level"
2605 );
2606 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2608 assert_eq!(
2609 occurrences, 2,
2610 "each entry must have trust_level = \"trusted\""
2611 );
2612 }
2613
2614 #[test]
2615 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2616 let src = r#"
2617[[mcp.servers]]
2618id = "srv-a"
2619command = "npx"
2620trust_level = "sandboxed"
2621tool_allowlist = ["read_file"]
2622
2623[[mcp.servers]]
2624id = "srv-b"
2625command = "npx"
2626"#;
2627 let result = migrate_mcp_trust_levels(src).expect("migrate");
2628 assert_eq!(
2630 result.added_count, 1,
2631 "only entry without trust_level gets updated"
2632 );
2633 assert!(
2635 result.output.contains("trust_level = \"sandboxed\""),
2636 "existing trust_level must not be overwritten"
2637 );
2638 assert!(
2640 result.output.contains("trust_level = \"trusted\""),
2641 "entry without trust_level must get trusted"
2642 );
2643 }
2644
2645 #[test]
2646 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2647 let src = "[agent]\nname = \"Zeph\"\n";
2648 let result = migrate_mcp_trust_levels(src).expect("migrate");
2649 assert_eq!(result.added_count, 0);
2650 assert!(result.sections_added.is_empty());
2651 assert_eq!(result.output, src);
2652 }
2653
2654 #[test]
2655 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2656 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2657 let result = migrate_mcp_trust_levels(src).expect("migrate");
2658 assert_eq!(result.added_count, 0);
2659 assert!(result.sections_added.is_empty());
2660 assert_eq!(result.output, src);
2661 }
2662
2663 #[test]
2664 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2665 let src = r#"
2666[[mcp.servers]]
2667id = "srv-a"
2668trust_level = "trusted"
2669
2670[[mcp.servers]]
2671id = "srv-b"
2672trust_level = "untrusted"
2673"#;
2674 let result = migrate_mcp_trust_levels(src).expect("migrate");
2675 assert_eq!(result.added_count, 0);
2676 assert!(result.sections_added.is_empty());
2677 }
2678
2679 #[test]
2680 fn migrate_database_url_adds_comment_when_absent() {
2681 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2682 let result = migrate_database_url(src).expect("migrate");
2683 assert_eq!(result.added_count, 1);
2684 assert!(
2685 result
2686 .sections_added
2687 .contains(&"memory.database_url".to_owned())
2688 );
2689 assert!(result.output.contains("# database_url = \"\""));
2690 }
2691
2692 #[test]
2693 fn migrate_database_url_is_noop_when_present() {
2694 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2695 let result = migrate_database_url(src).expect("migrate");
2696 assert_eq!(result.added_count, 0);
2697 assert!(result.sections_added.is_empty());
2698 assert_eq!(result.output, src);
2699 }
2700
2701 #[test]
2702 fn migrate_database_url_creates_memory_section_when_absent() {
2703 let src = "[agent]\nname = \"Zeph\"\n";
2704 let result = migrate_database_url(src).expect("migrate");
2705 assert_eq!(result.added_count, 1);
2706 assert!(result.output.contains("# database_url = \"\""));
2707 }
2708
2709 #[test]
2712 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2713 let src = "[agent]\nname = \"Zeph\"\n";
2714 let result = migrate_agent_budget_hint(src).expect("migrate");
2715 assert_eq!(result.added_count, 1);
2716 assert!(result.output.contains("budget_hint_enabled"));
2717 assert!(
2718 result
2719 .sections_added
2720 .contains(&"agent.budget_hint_enabled".to_owned())
2721 );
2722 }
2723
2724 #[test]
2725 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2726 let src = "[llm]\nmodel = \"gpt-4o\"\n";
2727 let result = migrate_agent_budget_hint(src).expect("migrate");
2728 assert_eq!(result.added_count, 0);
2729 assert_eq!(result.output, src);
2730 }
2731
2732 #[test]
2733 fn migrate_agent_budget_hint_already_present_is_noop() {
2734 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2735 let result = migrate_agent_budget_hint(src).expect("migrate");
2736 assert_eq!(result.added_count, 0);
2737 assert_eq!(result.output, src);
2738 }
2739
2740 #[test]
2741 fn migrate_telemetry_config_empty_config_appends_comment_block() {
2742 let src = "[agent]\nname = \"Zeph\"\n";
2743 let result = migrate_telemetry_config(src).expect("migrate");
2744 assert_eq!(result.added_count, 1);
2745 assert_eq!(result.sections_added, vec!["telemetry"]);
2746 assert!(
2747 result.output.contains("# [telemetry]"),
2748 "expected commented-out [telemetry] block in output"
2749 );
2750 assert!(
2751 result.output.contains("enabled = false"),
2752 "expected enabled = false in telemetry comment block"
2753 );
2754 }
2755
2756 #[test]
2757 fn migrate_telemetry_config_existing_section_is_noop() {
2758 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
2759 let result = migrate_telemetry_config(src).expect("migrate");
2760 assert_eq!(result.added_count, 0);
2761 assert_eq!(result.output, src);
2762 }
2763
2764 #[test]
2765 fn migrate_telemetry_config_existing_comment_is_noop() {
2766 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
2768 let result = migrate_telemetry_config(src).expect("migrate");
2769 assert_eq!(result.added_count, 0);
2770 assert_eq!(result.output, src);
2771 }
2772}