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 = false\n\
1824 # service_name = \"zeph-agent\"\n\
1825 # sample_rate = 1.0\n\
1826 # otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1827
1828 let raw = doc.to_string();
1829 let output = format!("{raw}{comment}");
1830
1831 Ok(MigrationResult {
1832 output,
1833 added_count: 1,
1834 sections_added: vec!["telemetry".to_owned()],
1835 })
1836}
1837
1838pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1847 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1849 return Ok(MigrationResult {
1850 output: toml_src.to_owned(),
1851 added_count: 0,
1852 sections_added: Vec::new(),
1853 });
1854 }
1855
1856 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1857
1858 if !doc.contains_key("agent") {
1861 return Ok(MigrationResult {
1862 output: toml_src.to_owned(),
1863 added_count: 0,
1864 sections_added: Vec::new(),
1865 });
1866 }
1867
1868 let comment = "\n\
1869 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1870 # [agent.supervisor]\n\
1871 # enrichment_limit = 4\n\
1872 # telemetry_limit = 8\n\
1873 # abort_enrichment_on_turn = false\n";
1874
1875 let raw = doc.to_string();
1876 let output = format!("{raw}{comment}");
1877
1878 Ok(MigrationResult {
1879 output,
1880 added_count: 1,
1881 sections_added: vec!["agent.supervisor".to_owned()],
1882 })
1883}
1884
1885pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1895 if toml_src.contains("otel_filter") {
1897 return Ok(MigrationResult {
1898 output: toml_src.to_owned(),
1899 added_count: 0,
1900 sections_added: Vec::new(),
1901 });
1902 }
1903
1904 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1905
1906 if !doc.contains_key("telemetry") {
1909 return Ok(MigrationResult {
1910 output: toml_src.to_owned(),
1911 added_count: 0,
1912 sections_added: Vec::new(),
1913 });
1914 }
1915
1916 let telemetry = doc
1917 .get_mut("telemetry")
1918 .and_then(toml_edit::Item::as_table_mut)
1919 .ok_or(MigrateError::InvalidStructure("[telemetry] is not a table"))?;
1920
1921 let comment = "# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
1924 (tonic=warn etc.) are always appended (#2997).\n\
1925 # otel_filter = \"info\"\n";
1926 append_comment_to_table_suffix(telemetry, comment);
1927
1928 Ok(MigrationResult {
1929 output: doc.to_string(),
1930 added_count: 1,
1931 sections_added: vec!["telemetry.otel_filter".to_owned()],
1932 })
1933}
1934
1935#[cfg(test)]
1937fn make_formatted_str(s: &str) -> Value {
1938 use toml_edit::Formatted;
1939 Value::String(Formatted::new(s.to_owned()))
1940}
1941
1942#[cfg(test)]
1943mod tests {
1944 use super::*;
1945
1946 #[test]
1947 fn empty_config_gets_sections_as_comments() {
1948 let migrator = ConfigMigrator::new();
1949 let result = migrator.migrate("").expect("migrate empty");
1950 assert!(result.added_count > 0 || !result.sections_added.is_empty());
1952 assert!(
1954 result.output.contains("[agent]") || result.output.contains("# [agent]"),
1955 "expected agent section in output, got:\n{}",
1956 result.output
1957 );
1958 }
1959
1960 #[test]
1961 fn existing_values_not_overwritten() {
1962 let user = r#"
1963[agent]
1964name = "MyAgent"
1965max_tool_iterations = 5
1966"#;
1967 let migrator = ConfigMigrator::new();
1968 let result = migrator.migrate(user).expect("migrate");
1969 assert!(
1971 result.output.contains("name = \"MyAgent\""),
1972 "user value should be preserved"
1973 );
1974 assert!(
1975 result.output.contains("max_tool_iterations = 5"),
1976 "user value should be preserved"
1977 );
1978 assert!(
1980 !result.output.contains("# max_tool_iterations = 10"),
1981 "already-set key should not appear as comment"
1982 );
1983 }
1984
1985 #[test]
1986 fn missing_nested_key_added_as_comment() {
1987 let user = r#"
1989[memory]
1990sqlite_path = ".zeph/data/zeph.db"
1991"#;
1992 let migrator = ConfigMigrator::new();
1993 let result = migrator.migrate(user).expect("migrate");
1994 assert!(
1996 result.output.contains("# history_limit"),
1997 "missing key should be added as comment, got:\n{}",
1998 result.output
1999 );
2000 }
2001
2002 #[test]
2003 fn unknown_user_keys_preserved() {
2004 let user = r#"
2005[agent]
2006name = "Test"
2007my_custom_key = "preserved"
2008"#;
2009 let migrator = ConfigMigrator::new();
2010 let result = migrator.migrate(user).expect("migrate");
2011 assert!(
2012 result.output.contains("my_custom_key = \"preserved\""),
2013 "custom user keys must not be removed"
2014 );
2015 }
2016
2017 #[test]
2018 fn idempotent() {
2019 let migrator = ConfigMigrator::new();
2020 let first = migrator
2021 .migrate("[agent]\nname = \"Zeph\"\n")
2022 .expect("first migrate");
2023 let second = migrator.migrate(&first.output).expect("second migrate");
2024 assert_eq!(
2025 first.output, second.output,
2026 "idempotent: full output must be identical on second run"
2027 );
2028 }
2029
2030 #[test]
2031 fn malformed_input_returns_error() {
2032 let migrator = ConfigMigrator::new();
2033 let err = migrator
2034 .migrate("[[invalid toml [[[")
2035 .expect_err("should error");
2036 assert!(
2037 matches!(err, MigrateError::Parse(_)),
2038 "expected Parse error"
2039 );
2040 }
2041
2042 #[test]
2043 fn array_of_tables_preserved() {
2044 let user = r#"
2045[mcp]
2046allowed_commands = ["npx"]
2047
2048[[mcp.servers]]
2049id = "my-server"
2050command = "npx"
2051args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2052"#;
2053 let migrator = ConfigMigrator::new();
2054 let result = migrator.migrate(user).expect("migrate");
2055 assert!(
2057 result.output.contains("[[mcp.servers]]"),
2058 "array-of-tables entries must be preserved"
2059 );
2060 assert!(result.output.contains("id = \"my-server\""));
2061 }
2062
2063 #[test]
2064 fn canonical_ordering_applied() {
2065 let user = r#"
2067[memory]
2068sqlite_path = ".zeph/data/zeph.db"
2069
2070[agent]
2071name = "Test"
2072"#;
2073 let migrator = ConfigMigrator::new();
2074 let result = migrator.migrate(user).expect("migrate");
2075 let agent_pos = result.output.find("[agent]");
2077 let memory_pos = result.output.find("[memory]");
2078 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2079 assert!(a < m, "agent section should precede memory section");
2080 }
2081 }
2082
2083 #[test]
2084 fn value_to_toml_string_formats_correctly() {
2085 use toml_edit::Formatted;
2086
2087 let s = make_formatted_str("hello");
2088 assert_eq!(value_to_toml_string(&s), "\"hello\"");
2089
2090 let i = Value::Integer(Formatted::new(42_i64));
2091 assert_eq!(value_to_toml_string(&i), "42");
2092
2093 let b = Value::Boolean(Formatted::new(true));
2094 assert_eq!(value_to_toml_string(&b), "true");
2095
2096 let f = Value::Float(Formatted::new(1.0_f64));
2097 assert_eq!(value_to_toml_string(&f), "1.0");
2098
2099 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2100 assert_eq!(value_to_toml_string(&f2), "3.14");
2101
2102 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2103 let arr_val = Value::Array(arr);
2104 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2105
2106 let empty_arr = Value::Array(Array::new());
2107 assert_eq!(value_to_toml_string(&empty_arr), "[]");
2108 }
2109
2110 #[test]
2111 fn idempotent_full_output_unchanged() {
2112 let migrator = ConfigMigrator::new();
2114 let first = migrator
2115 .migrate("[agent]\nname = \"Zeph\"\n")
2116 .expect("first migrate");
2117 let second = migrator.migrate(&first.output).expect("second migrate");
2118 assert_eq!(
2119 first.output, second.output,
2120 "full output string must be identical after second migration pass"
2121 );
2122 }
2123
2124 #[test]
2125 fn full_config_produces_zero_additions() {
2126 let reference = include_str!("../config/default.toml");
2128 let migrator = ConfigMigrator::new();
2129 let result = migrator.migrate(reference).expect("migrate reference");
2130 assert_eq!(
2131 result.added_count, 0,
2132 "migrating the canonical reference should add nothing (added_count = {})",
2133 result.added_count
2134 );
2135 assert!(
2136 result.sections_added.is_empty(),
2137 "migrating the canonical reference should report no sections_added: {:?}",
2138 result.sections_added
2139 );
2140 }
2141
2142 #[test]
2143 fn empty_config_added_count_is_positive() {
2144 let migrator = ConfigMigrator::new();
2146 let result = migrator.migrate("").expect("migrate empty");
2147 assert!(
2148 result.added_count > 0,
2149 "empty config must report added_count > 0"
2150 );
2151 }
2152
2153 #[test]
2156 fn security_without_guardrail_gets_guardrail_commented() {
2157 let user = "[security]\nredact_secrets = true\n";
2158 let migrator = ConfigMigrator::new();
2159 let result = migrator.migrate(user).expect("migrate");
2160 assert!(
2162 result.output.contains("guardrail"),
2163 "migration must add guardrail keys for configs without [security.guardrail]: \
2164 got:\n{}",
2165 result.output
2166 );
2167 }
2168
2169 #[test]
2170 fn migrate_reference_contains_tools_policy() {
2171 let reference = include_str!("../config/default.toml");
2176 assert!(
2177 reference.contains("[tools.policy]"),
2178 "default.toml must contain [tools.policy] section so migrate-config can surface it"
2179 );
2180 assert!(
2181 reference.contains("enabled = false"),
2182 "tools.policy section must include enabled = false default"
2183 );
2184 }
2185
2186 #[test]
2187 fn migrate_reference_contains_probe_section() {
2188 let reference = include_str!("../config/default.toml");
2191 assert!(
2192 reference.contains("[memory.compression.probe]"),
2193 "default.toml must contain [memory.compression.probe] section comment"
2194 );
2195 assert!(
2196 reference.contains("hard_fail_threshold"),
2197 "probe section must include hard_fail_threshold default"
2198 );
2199 }
2200
2201 #[test]
2204 fn migrate_llm_no_llm_section_is_noop() {
2205 let src = "[agent]\nname = \"Zeph\"\n";
2206 let result = migrate_llm_to_providers(src).expect("migrate");
2207 assert_eq!(result.added_count, 0);
2208 assert_eq!(result.output, src);
2209 }
2210
2211 #[test]
2212 fn migrate_llm_already_new_format_is_noop() {
2213 let src = r#"
2214[llm]
2215[[llm.providers]]
2216type = "ollama"
2217model = "qwen3:8b"
2218"#;
2219 let result = migrate_llm_to_providers(src).expect("migrate");
2220 assert_eq!(result.added_count, 0);
2221 }
2222
2223 #[test]
2224 fn migrate_llm_ollama_produces_providers_block() {
2225 let src = r#"
2226[llm]
2227provider = "ollama"
2228model = "qwen3:8b"
2229base_url = "http://localhost:11434"
2230embedding_model = "nomic-embed-text"
2231"#;
2232 let result = migrate_llm_to_providers(src).expect("migrate");
2233 assert!(
2234 result.output.contains("[[llm.providers]]"),
2235 "should contain [[llm.providers]]:\n{}",
2236 result.output
2237 );
2238 assert!(
2239 result.output.contains("type = \"ollama\""),
2240 "{}",
2241 result.output
2242 );
2243 assert!(
2244 result.output.contains("model = \"qwen3:8b\""),
2245 "{}",
2246 result.output
2247 );
2248 }
2249
2250 #[test]
2251 fn migrate_llm_claude_produces_providers_block() {
2252 let src = r#"
2253[llm]
2254provider = "claude"
2255
2256[llm.cloud]
2257model = "claude-sonnet-4-6"
2258max_tokens = 8192
2259server_compaction = true
2260"#;
2261 let result = migrate_llm_to_providers(src).expect("migrate");
2262 assert!(
2263 result.output.contains("[[llm.providers]]"),
2264 "{}",
2265 result.output
2266 );
2267 assert!(
2268 result.output.contains("type = \"claude\""),
2269 "{}",
2270 result.output
2271 );
2272 assert!(
2273 result.output.contains("model = \"claude-sonnet-4-6\""),
2274 "{}",
2275 result.output
2276 );
2277 assert!(
2278 result.output.contains("server_compaction = true"),
2279 "{}",
2280 result.output
2281 );
2282 }
2283
2284 #[test]
2285 fn migrate_llm_openai_copies_fields() {
2286 let src = r#"
2287[llm]
2288provider = "openai"
2289
2290[llm.openai]
2291base_url = "https://api.openai.com/v1"
2292model = "gpt-4o"
2293max_tokens = 4096
2294"#;
2295 let result = migrate_llm_to_providers(src).expect("migrate");
2296 assert!(
2297 result.output.contains("type = \"openai\""),
2298 "{}",
2299 result.output
2300 );
2301 assert!(
2302 result
2303 .output
2304 .contains("base_url = \"https://api.openai.com/v1\""),
2305 "{}",
2306 result.output
2307 );
2308 }
2309
2310 #[test]
2311 fn migrate_llm_gemini_copies_fields() {
2312 let src = r#"
2313[llm]
2314provider = "gemini"
2315
2316[llm.gemini]
2317model = "gemini-2.0-flash"
2318max_tokens = 8192
2319base_url = "https://generativelanguage.googleapis.com"
2320"#;
2321 let result = migrate_llm_to_providers(src).expect("migrate");
2322 assert!(
2323 result.output.contains("type = \"gemini\""),
2324 "{}",
2325 result.output
2326 );
2327 assert!(
2328 result.output.contains("model = \"gemini-2.0-flash\""),
2329 "{}",
2330 result.output
2331 );
2332 }
2333
2334 #[test]
2335 fn migrate_llm_compatible_copies_multiple_entries() {
2336 let src = r#"
2337[llm]
2338provider = "compatible"
2339
2340[[llm.compatible]]
2341name = "proxy-a"
2342base_url = "http://proxy-a:8080/v1"
2343model = "llama3"
2344max_tokens = 4096
2345
2346[[llm.compatible]]
2347name = "proxy-b"
2348base_url = "http://proxy-b:8080/v1"
2349model = "mistral"
2350max_tokens = 2048
2351"#;
2352 let result = migrate_llm_to_providers(src).expect("migrate");
2353 let count = result.output.matches("[[llm.providers]]").count();
2355 assert_eq!(
2356 count, 2,
2357 "expected 2 [[llm.providers]] blocks:\n{}",
2358 result.output
2359 );
2360 assert!(
2361 result.output.contains("name = \"proxy-a\""),
2362 "{}",
2363 result.output
2364 );
2365 assert!(
2366 result.output.contains("name = \"proxy-b\""),
2367 "{}",
2368 result.output
2369 );
2370 }
2371
2372 #[test]
2373 fn migrate_llm_mixed_format_errors() {
2374 let src = r#"
2376[llm]
2377provider = "ollama"
2378
2379[[llm.providers]]
2380type = "ollama"
2381"#;
2382 assert!(
2383 migrate_llm_to_providers(src).is_err(),
2384 "mixed format must return error"
2385 );
2386 }
2387
2388 #[test]
2391 fn stt_migration_no_stt_section_returns_unchanged() {
2392 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2393 let result = migrate_stt_to_provider(src).unwrap();
2394 assert_eq!(result.added_count, 0);
2395 assert_eq!(result.output, src);
2396 }
2397
2398 #[test]
2399 fn stt_migration_no_model_or_base_url_returns_unchanged() {
2400 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2401 let result = migrate_stt_to_provider(src).unwrap();
2402 assert_eq!(result.added_count, 0);
2403 }
2404
2405 #[test]
2406 fn stt_migration_moves_model_to_provider_entry() {
2407 let src = r#"
2408[llm]
2409
2410[[llm.providers]]
2411type = "openai"
2412name = "quality"
2413model = "gpt-5.4"
2414
2415[llm.stt]
2416provider = "quality"
2417model = "gpt-4o-mini-transcribe"
2418language = "en"
2419"#;
2420 let result = migrate_stt_to_provider(src).unwrap();
2421 assert_eq!(result.added_count, 1);
2422 assert!(
2424 result.output.contains("stt_model"),
2425 "stt_model must be in output"
2426 );
2427 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2430 let stt = doc
2431 .get("llm")
2432 .and_then(toml_edit::Item::as_table)
2433 .and_then(|l| l.get("stt"))
2434 .and_then(toml_edit::Item::as_table)
2435 .unwrap();
2436 assert!(
2437 stt.get("model").is_none(),
2438 "model must be removed from [llm.stt]"
2439 );
2440 assert_eq!(
2441 stt.get("provider").and_then(toml_edit::Item::as_str),
2442 Some("quality")
2443 );
2444 }
2445
2446 #[test]
2447 fn stt_migration_creates_new_provider_when_no_match() {
2448 let src = r#"
2449[llm]
2450
2451[[llm.providers]]
2452type = "ollama"
2453name = "local"
2454model = "qwen3:8b"
2455
2456[llm.stt]
2457provider = "whisper"
2458model = "whisper-1"
2459base_url = "https://api.openai.com/v1"
2460language = "en"
2461"#;
2462 let result = migrate_stt_to_provider(src).unwrap();
2463 assert!(
2464 result.output.contains("openai-stt"),
2465 "new entry name must be openai-stt"
2466 );
2467 assert!(
2468 result.output.contains("stt_model"),
2469 "stt_model must be in output"
2470 );
2471 }
2472
2473 #[test]
2474 fn stt_migration_candle_whisper_creates_candle_entry() {
2475 let src = r#"
2476[llm]
2477
2478[llm.stt]
2479provider = "candle-whisper"
2480model = "openai/whisper-tiny"
2481language = "auto"
2482"#;
2483 let result = migrate_stt_to_provider(src).unwrap();
2484 assert!(
2485 result.output.contains("local-whisper"),
2486 "candle entry name must be local-whisper"
2487 );
2488 assert!(result.output.contains("candle"), "type must be candle");
2489 }
2490
2491 #[test]
2492 fn stt_migration_w2_assigns_explicit_name() {
2493 let src = r#"
2495[llm]
2496
2497[[llm.providers]]
2498type = "openai"
2499model = "gpt-5.4"
2500
2501[llm.stt]
2502provider = "openai"
2503model = "whisper-1"
2504language = "auto"
2505"#;
2506 let result = migrate_stt_to_provider(src).unwrap();
2507 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2508 let providers = doc
2509 .get("llm")
2510 .and_then(toml_edit::Item::as_table)
2511 .and_then(|l| l.get("providers"))
2512 .and_then(toml_edit::Item::as_array_of_tables)
2513 .unwrap();
2514 let entry = providers
2515 .iter()
2516 .find(|t| t.get("stt_model").is_some())
2517 .unwrap();
2518 assert!(
2520 entry.get("name").is_some(),
2521 "migrated entry must have explicit name"
2522 );
2523 }
2524
2525 #[test]
2526 fn stt_migration_removes_base_url_from_stt_table() {
2527 let src = r#"
2529[llm]
2530
2531[[llm.providers]]
2532type = "openai"
2533name = "quality"
2534model = "gpt-5.4"
2535
2536[llm.stt]
2537provider = "quality"
2538model = "whisper-1"
2539base_url = "https://api.openai.com/v1"
2540language = "en"
2541"#;
2542 let result = migrate_stt_to_provider(src).unwrap();
2543 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2544 let stt = doc
2545 .get("llm")
2546 .and_then(toml_edit::Item::as_table)
2547 .and_then(|l| l.get("stt"))
2548 .and_then(toml_edit::Item::as_table)
2549 .unwrap();
2550 assert!(
2551 stt.get("model").is_none(),
2552 "model must be removed from [llm.stt]"
2553 );
2554 assert!(
2555 stt.get("base_url").is_none(),
2556 "base_url must be removed from [llm.stt]"
2557 );
2558 }
2559
2560 #[test]
2561 fn migrate_planner_model_to_provider_with_field() {
2562 let input = r#"
2563[orchestration]
2564enabled = true
2565planner_model = "gpt-4o"
2566max_tasks = 20
2567"#;
2568 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2569 assert_eq!(result.added_count, 1, "added_count must be 1");
2570 assert!(
2571 !result.output.contains("planner_model = "),
2572 "planner_model key must be removed from output"
2573 );
2574 assert!(
2575 result.output.contains("# planner_provider"),
2576 "commented-out planner_provider entry must be present"
2577 );
2578 assert!(
2579 result.output.contains("gpt-4o"),
2580 "old value must appear in the comment"
2581 );
2582 assert!(
2583 result.output.contains("MIGRATED"),
2584 "comment must include MIGRATED marker"
2585 );
2586 }
2587
2588 #[test]
2589 fn migrate_planner_model_to_provider_no_op() {
2590 let input = r"
2591[orchestration]
2592enabled = true
2593max_tasks = 20
2594";
2595 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2596 assert_eq!(
2597 result.added_count, 0,
2598 "added_count must be 0 when field is absent"
2599 );
2600 assert_eq!(
2601 result.output, input,
2602 "output must equal input when nothing to migrate"
2603 );
2604 }
2605
2606 #[test]
2607 fn migrate_error_invalid_structure_formats_correctly() {
2608 let err = MigrateError::InvalidStructure("test sentinel");
2613 assert!(
2614 matches!(err, MigrateError::InvalidStructure(_)),
2615 "variant must match"
2616 );
2617 let msg = err.to_string();
2618 assert!(
2619 msg.contains("invalid TOML structure"),
2620 "error message must mention 'invalid TOML structure', got: {msg}"
2621 );
2622 assert!(
2623 msg.contains("test sentinel"),
2624 "message must include reason: {msg}"
2625 );
2626 }
2627
2628 #[test]
2631 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2632 let src = r#"
2633[mcp]
2634allowed_commands = ["npx"]
2635
2636[[mcp.servers]]
2637id = "srv-a"
2638command = "npx"
2639args = ["-y", "some-mcp"]
2640
2641[[mcp.servers]]
2642id = "srv-b"
2643command = "npx"
2644args = ["-y", "other-mcp"]
2645"#;
2646 let result = migrate_mcp_trust_levels(src).expect("migrate");
2647 assert_eq!(
2648 result.added_count, 2,
2649 "both entries must get trust_level added"
2650 );
2651 assert!(
2652 result
2653 .sections_added
2654 .contains(&"mcp.servers.trust_level".to_owned()),
2655 "sections_added must report mcp.servers.trust_level"
2656 );
2657 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2659 assert_eq!(
2660 occurrences, 2,
2661 "each entry must have trust_level = \"trusted\""
2662 );
2663 }
2664
2665 #[test]
2666 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2667 let src = r#"
2668[[mcp.servers]]
2669id = "srv-a"
2670command = "npx"
2671trust_level = "sandboxed"
2672tool_allowlist = ["read_file"]
2673
2674[[mcp.servers]]
2675id = "srv-b"
2676command = "npx"
2677"#;
2678 let result = migrate_mcp_trust_levels(src).expect("migrate");
2679 assert_eq!(
2681 result.added_count, 1,
2682 "only entry without trust_level gets updated"
2683 );
2684 assert!(
2686 result.output.contains("trust_level = \"sandboxed\""),
2687 "existing trust_level must not be overwritten"
2688 );
2689 assert!(
2691 result.output.contains("trust_level = \"trusted\""),
2692 "entry without trust_level must get trusted"
2693 );
2694 }
2695
2696 #[test]
2697 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2698 let src = "[agent]\nname = \"Zeph\"\n";
2699 let result = migrate_mcp_trust_levels(src).expect("migrate");
2700 assert_eq!(result.added_count, 0);
2701 assert!(result.sections_added.is_empty());
2702 assert_eq!(result.output, src);
2703 }
2704
2705 #[test]
2706 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2707 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2708 let result = migrate_mcp_trust_levels(src).expect("migrate");
2709 assert_eq!(result.added_count, 0);
2710 assert!(result.sections_added.is_empty());
2711 assert_eq!(result.output, src);
2712 }
2713
2714 #[test]
2715 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2716 let src = r#"
2717[[mcp.servers]]
2718id = "srv-a"
2719trust_level = "trusted"
2720
2721[[mcp.servers]]
2722id = "srv-b"
2723trust_level = "untrusted"
2724"#;
2725 let result = migrate_mcp_trust_levels(src).expect("migrate");
2726 assert_eq!(result.added_count, 0);
2727 assert!(result.sections_added.is_empty());
2728 }
2729
2730 #[test]
2731 fn migrate_database_url_adds_comment_when_absent() {
2732 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2733 let result = migrate_database_url(src).expect("migrate");
2734 assert_eq!(result.added_count, 1);
2735 assert!(
2736 result
2737 .sections_added
2738 .contains(&"memory.database_url".to_owned())
2739 );
2740 assert!(result.output.contains("# database_url = \"\""));
2741 }
2742
2743 #[test]
2744 fn migrate_database_url_is_noop_when_present() {
2745 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2746 let result = migrate_database_url(src).expect("migrate");
2747 assert_eq!(result.added_count, 0);
2748 assert!(result.sections_added.is_empty());
2749 assert_eq!(result.output, src);
2750 }
2751
2752 #[test]
2753 fn migrate_database_url_creates_memory_section_when_absent() {
2754 let src = "[agent]\nname = \"Zeph\"\n";
2755 let result = migrate_database_url(src).expect("migrate");
2756 assert_eq!(result.added_count, 1);
2757 assert!(result.output.contains("# database_url = \"\""));
2758 }
2759
2760 #[test]
2763 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2764 let src = "[agent]\nname = \"Zeph\"\n";
2765 let result = migrate_agent_budget_hint(src).expect("migrate");
2766 assert_eq!(result.added_count, 1);
2767 assert!(result.output.contains("budget_hint_enabled"));
2768 assert!(
2769 result
2770 .sections_added
2771 .contains(&"agent.budget_hint_enabled".to_owned())
2772 );
2773 }
2774
2775 #[test]
2776 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2777 let src = "[llm]\nmodel = \"gpt-4o\"\n";
2778 let result = migrate_agent_budget_hint(src).expect("migrate");
2779 assert_eq!(result.added_count, 0);
2780 assert_eq!(result.output, src);
2781 }
2782
2783 #[test]
2784 fn migrate_agent_budget_hint_already_present_is_noop() {
2785 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2786 let result = migrate_agent_budget_hint(src).expect("migrate");
2787 assert_eq!(result.added_count, 0);
2788 assert_eq!(result.output, src);
2789 }
2790
2791 #[test]
2792 fn migrate_telemetry_config_empty_config_appends_comment_block() {
2793 let src = "[agent]\nname = \"Zeph\"\n";
2794 let result = migrate_telemetry_config(src).expect("migrate");
2795 assert_eq!(result.added_count, 1);
2796 assert_eq!(result.sections_added, vec!["telemetry"]);
2797 assert!(
2798 result.output.contains("# [telemetry]"),
2799 "expected commented-out [telemetry] block in output"
2800 );
2801 assert!(
2802 result.output.contains("enabled = false"),
2803 "expected enabled = false in telemetry comment block"
2804 );
2805 }
2806
2807 #[test]
2808 fn migrate_telemetry_config_existing_section_is_noop() {
2809 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
2810 let result = migrate_telemetry_config(src).expect("migrate");
2811 assert_eq!(result.added_count, 0);
2812 assert_eq!(result.output, src);
2813 }
2814
2815 #[test]
2816 fn migrate_telemetry_config_existing_comment_is_noop() {
2817 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
2819 let result = migrate_telemetry_config(src).expect("migrate");
2820 assert_eq!(result.added_count, 0);
2821 assert_eq!(result.output, src);
2822 }
2823
2824 #[test]
2827 fn migrate_otel_filter_already_present_is_noop() {
2828 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
2830 let result = migrate_otel_filter(src).expect("migrate");
2831 assert_eq!(result.added_count, 0);
2832 assert_eq!(result.output, src);
2833 }
2834
2835 #[test]
2836 fn migrate_otel_filter_commented_key_is_noop() {
2837 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
2839 let result = migrate_otel_filter(src).expect("migrate");
2840 assert_eq!(result.added_count, 0);
2841 assert_eq!(result.output, src);
2842 }
2843
2844 #[test]
2845 fn migrate_otel_filter_no_telemetry_section_is_noop() {
2846 let src = "[agent]\nname = \"Zeph\"\n";
2848 let result = migrate_otel_filter(src).expect("migrate");
2849 assert_eq!(result.added_count, 0);
2850 assert_eq!(result.output, src);
2851 assert!(!result.output.contains("otel_filter"));
2852 }
2853
2854 #[test]
2855 fn migrate_otel_filter_injects_within_telemetry_section() {
2856 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
2857 let result = migrate_otel_filter(src).expect("migrate");
2858 assert_eq!(result.added_count, 1);
2859 assert_eq!(result.sections_added, vec!["telemetry.otel_filter"]);
2860 assert!(
2861 result.output.contains("otel_filter"),
2862 "otel_filter comment must appear"
2863 );
2864 let otel_pos = result
2866 .output
2867 .find("otel_filter")
2868 .expect("otel_filter present");
2869 let agent_pos = result.output.find("[agent]").expect("[agent] present");
2870 assert!(
2871 otel_pos < agent_pos,
2872 "otel_filter comment should appear before [agent] section"
2873 );
2874 }
2875}