1use toml_edit::{Array, DocumentMut, Item, 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 "session",
44];
45
46#[derive(Debug, thiserror::Error)]
48pub enum MigrateError {
49 #[error("failed to parse input config: {0}")]
51 Parse(#[from] toml_edit::TomlError),
52 #[error("failed to parse reference config: {0}")]
54 Reference(toml_edit::TomlError),
55 #[error("migration failed: invalid TOML structure — {0}")]
58 InvalidStructure(&'static str),
59}
60
61#[derive(Debug)]
63pub struct MigrationResult {
64 pub output: String,
66 pub added_count: usize,
68 pub sections_added: Vec<String>,
70}
71
72pub struct ConfigMigrator {
77 reference_src: &'static str,
78}
79
80impl Default for ConfigMigrator {
81 fn default() -> Self {
82 Self::new()
83 }
84}
85
86impl ConfigMigrator {
87 #[must_use]
89 pub fn new() -> Self {
90 Self {
91 reference_src: include_str!("../config/default.toml"),
92 }
93 }
94
95 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
107 let reference_doc = self
108 .reference_src
109 .parse::<DocumentMut>()
110 .map_err(MigrateError::Reference)?;
111 let mut user_doc = user_toml.parse::<DocumentMut>()?;
112
113 let mut added_count = 0usize;
114 let mut sections_added: Vec<String> = Vec::new();
115 let mut pending_comments: Vec<(String, String)> = Vec::new();
118
119 for (key, ref_item) in reference_doc.as_table() {
121 if ref_item.is_table() {
122 let ref_table = ref_item.as_table().expect("is_table checked above");
123 if user_doc.contains_key(key) {
124 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
126 let (n, comments) =
127 merge_table_commented(user_table, ref_table, key, user_toml);
128 added_count += n;
129 pending_comments.extend(comments);
130 }
131 } else {
132 if user_toml.contains(&format!("# [{key}]")) {
135 continue;
136 }
137 let commented = commented_table_block(key, ref_table);
138 if !commented.is_empty() {
139 sections_added.push(key.to_owned());
140 }
141 added_count += 1;
142 }
143 } else {
144 if !user_doc.contains_key(key) {
146 let raw = format_commented_item(key, ref_item);
147 if !raw.is_empty() {
148 sections_added.push(format!("__scalar__{key}"));
149 added_count += 1;
150 }
151 }
152 }
153 }
154
155 let user_str = user_doc.to_string();
157
158 let mut output = user_str;
161 for (section_key, comment_line) in &pending_comments {
162 if !section_body(&output, section_key).contains(comment_line.trim()) {
163 output = insert_after_section(&output, section_key, comment_line);
164 }
165 }
166
167 for key in §ions_added {
169 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
170 if let Some(ref_item) = reference_doc.get(scalar_key) {
171 let raw = format_commented_item(scalar_key, ref_item);
172 if !raw.is_empty() {
173 output.push('\n');
174 output.push_str(&raw);
175 output.push('\n');
176 }
177 }
178 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
179 {
180 let block = commented_table_block(key, ref_table);
181 if !block.is_empty() {
182 output.push('\n');
183 output.push_str(&block);
184 }
185 }
186 }
187
188 output = reorder_sections(&output, CANONICAL_ORDER);
190
191 let sections_added_clean: Vec<String> = sections_added
193 .into_iter()
194 .filter(|k| !k.starts_with("__scalar__"))
195 .collect();
196
197 Ok(MigrationResult {
198 output,
199 added_count,
200 sections_added: sections_added_clean,
201 })
202 }
203}
204
205fn merge_table_commented(
211 user_table: &mut Table,
212 ref_table: &Table,
213 section_key: &str,
214 user_toml: &str,
215) -> (usize, Vec<(String, String)>) {
216 let mut count = 0usize;
217 let mut comments: Vec<(String, String)> = Vec::new();
218 for (key, ref_item) in ref_table {
219 if ref_item.is_table() {
220 if user_table.contains_key(key) {
221 let pair = (
222 user_table.get_mut(key).and_then(Item::as_table_mut),
223 ref_item.as_table(),
224 );
225 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
226 let sub_key = format!("{section_key}.{key}");
227 let (n, c) =
228 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
229 count += n;
230 comments.extend(c);
231 }
232 } else if let Some(ref_sub_table) = ref_item.as_table() {
233 let dotted = format!("{section_key}.{key}");
235 let marker = format!("# [{dotted}]");
236 if !user_toml.contains(&marker) {
237 let block = commented_table_block(&dotted, ref_sub_table);
238 if !block.is_empty() {
239 comments.push((section_key.to_owned(), format!("\n{block}")));
240 count += 1;
241 }
242 }
243 }
244 } else if ref_item.is_array_of_tables() {
245 } else {
247 if !user_table.contains_key(key) {
249 let raw_value = ref_item
250 .as_value()
251 .map(value_to_toml_string)
252 .unwrap_or_default();
253 if !raw_value.is_empty() {
254 let comment_line = format!("# {key} = {raw_value}\n");
255 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
258 comments.push((section_key.to_owned(), comment_line));
259 count += 1;
260 }
261 }
262 }
263 }
264 }
265 (count, comments)
266}
267
268fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
274 let header = format!("[{section}]");
275 let Some(section_start) = doc.find(&header) else {
276 return "";
277 };
278 let body_start = section_start + header.len();
279 let body_end = doc[body_start..]
280 .find("\n[")
281 .map_or(doc.len(), |r| body_start + r);
282 &doc[body_start..body_end]
283}
284
285fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
291 let header = format!("[{section_name}]");
292 let Some(section_start) = raw.find(&header) else {
293 return format!("{raw}{text}");
294 };
295 let search_from = section_start + header.len();
297 let insert_pos = raw[search_from..]
299 .find("\n[")
300 .map_or(raw.len(), |rel| search_from + rel + 1);
301 let mut out = String::with_capacity(raw.len() + text.len());
302 out.push_str(&raw[..insert_pos]);
303 out.push_str(text);
304 out.push_str(&raw[insert_pos..]);
305 out
306}
307
308fn format_commented_item(key: &str, item: &Item) -> String {
310 if let Some(val) = item.as_value() {
311 let raw = value_to_toml_string(val);
312 if !raw.is_empty() {
313 return format!("# {key} = {raw}\n");
314 }
315 }
316 String::new()
317}
318
319fn commented_table_block(section_name: &str, table: &Table) -> String {
324 use std::fmt::Write as _;
325
326 let mut lines = format!("# [{section_name}]\n");
327
328 for (key, item) in table {
329 if item.is_table() {
330 if let Some(sub_table) = item.as_table() {
331 let sub_name = format!("{section_name}.{key}");
332 let sub_block = commented_table_block(&sub_name, sub_table);
333 if !sub_block.is_empty() {
334 lines.push('\n');
335 lines.push_str(&sub_block);
336 }
337 }
338 } else if item.is_array_of_tables() {
339 } else if let Some(val) = item.as_value() {
341 let raw = value_to_toml_string(val);
342 if !raw.is_empty() {
343 let _ = writeln!(lines, "# {key} = {raw}");
344 }
345 }
346 }
347
348 if lines.trim() == format!("[{section_name}]") {
350 return String::new();
351 }
352 lines
353}
354
355fn value_to_toml_string(val: &Value) -> String {
357 match val {
358 Value::String(s) => {
359 let inner = s.value();
360 format!("\"{inner}\"")
361 }
362 Value::Integer(i) => i.value().to_string(),
363 Value::Float(f) => {
364 let v = f.value();
365 if v.fract() == 0.0 {
367 format!("{v:.1}")
368 } else {
369 format!("{v}")
370 }
371 }
372 Value::Boolean(b) => b.value().to_string(),
373 Value::Array(arr) => format_array(arr),
374 Value::InlineTable(t) => {
375 let pairs: Vec<String> = t
376 .iter()
377 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
378 .collect();
379 format!("{{ {} }}", pairs.join(", "))
380 }
381 Value::Datetime(dt) => dt.value().to_string(),
382 }
383}
384
385fn format_array(arr: &Array) -> String {
386 if arr.is_empty() {
387 return "[]".to_owned();
388 }
389 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
390 format!("[{}]", items.join(", "))
391}
392
393fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
399 let sections = split_into_sections(toml_str);
400 if sections.is_empty() {
401 return toml_str.to_owned();
402 }
403
404 let preamble_block = sections
406 .iter()
407 .find(|(h, _)| h.is_empty())
408 .map_or("", |(_, c)| c.as_str());
409
410 let section_map: Vec<(&str, &str)> = sections
411 .iter()
412 .filter(|(h, _)| !h.is_empty())
413 .map(|(h, c)| (h.as_str(), c.as_str()))
414 .collect();
415
416 let mut out = String::new();
417 if !preamble_block.is_empty() {
418 out.push_str(preamble_block);
419 }
420
421 let mut emitted: Vec<bool> = vec![false; section_map.len()];
422
423 for &canon in canonical_order {
424 for (idx, &(header, content)) in section_map.iter().enumerate() {
425 let section_name = extract_section_name(header);
426 let top_level = section_name
427 .split('.')
428 .next()
429 .unwrap_or("")
430 .trim_start_matches('#')
431 .trim();
432 if top_level == canon && !emitted[idx] {
433 out.push_str(content);
434 emitted[idx] = true;
435 }
436 }
437 }
438
439 for (idx, &(_, content)) in section_map.iter().enumerate() {
441 if !emitted[idx] {
442 out.push_str(content);
443 }
444 }
445
446 out
447}
448
449fn extract_section_name(header: &str) -> &str {
451 let trimmed = header.trim().trim_start_matches("# ");
453 if trimmed.starts_with('[') && trimmed.contains(']') {
455 let inner = &trimmed[1..];
456 if let Some(end) = inner.find(']') {
457 return &inner[..end];
458 }
459 }
460 trimmed
461}
462
463fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
467 let mut sections: Vec<(String, String)> = Vec::new();
468 let mut current_header = String::new();
469 let mut current_content = String::new();
470
471 for line in toml_str.lines() {
472 let trimmed = line.trim();
473 if is_top_level_section_header(trimmed) {
474 sections.push((current_header.clone(), current_content.clone()));
475 trimmed.clone_into(&mut current_header);
476 line.clone_into(&mut current_content);
477 current_content.push('\n');
478 } else {
479 current_content.push_str(line);
480 current_content.push('\n');
481 }
482 }
483
484 if !current_header.is_empty() || !current_content.is_empty() {
486 sections.push((current_header, current_content));
487 }
488
489 sections
490}
491
492fn is_top_level_section_header(line: &str) -> bool {
497 if line.starts_with('[')
498 && !line.starts_with("[[")
499 && let Some(end) = line.find(']')
500 {
501 return !line[1..end].contains('.');
502 }
503 false
504}
505
506#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
507fn migrate_ollama_provider(
508 llm: &toml_edit::Table,
509 model: &Option<String>,
510 base_url: &Option<String>,
511 embedding_model: &Option<String>,
512) -> Vec<String> {
513 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
514 if let Some(m) = model {
515 block.push_str(&format!("model = \"{m}\"\n"));
516 }
517 if let Some(em) = embedding_model {
518 block.push_str(&format!("embedding_model = \"{em}\"\n"));
519 }
520 if let Some(u) = base_url {
521 block.push_str(&format!("base_url = \"{u}\"\n"));
522 }
523 let _ = llm; vec![block]
525}
526
527#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
528fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
529 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
530 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
531 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
532 block.push_str(&format!("model = \"{m}\"\n"));
533 }
534 if let Some(t) = cloud
535 .get("max_tokens")
536 .and_then(toml_edit::Item::as_integer)
537 {
538 block.push_str(&format!("max_tokens = {t}\n"));
539 }
540 if cloud
541 .get("server_compaction")
542 .and_then(toml_edit::Item::as_bool)
543 == Some(true)
544 {
545 block.push_str("server_compaction = true\n");
546 }
547 if cloud
548 .get("enable_extended_context")
549 .and_then(toml_edit::Item::as_bool)
550 == Some(true)
551 {
552 block.push_str("enable_extended_context = true\n");
553 }
554 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
555 let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
556 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
557 }
558 if let Some(v) = cloud
559 .get("prompt_cache_ttl")
560 .and_then(toml_edit::Item::as_str)
561 {
562 if v != "ephemeral" {
563 block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
564 }
565 }
566 } else if let Some(m) = model {
567 block.push_str(&format!("model = \"{m}\"\n"));
568 }
569 vec![block]
570}
571
572#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
573fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
574 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
575 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
576 copy_str_field(openai, "model", &mut block);
577 copy_str_field(openai, "base_url", &mut block);
578 copy_int_field(openai, "max_tokens", &mut block);
579 copy_str_field(openai, "embedding_model", &mut block);
580 copy_str_field(openai, "reasoning_effort", &mut block);
581 } else if let Some(m) = model {
582 block.push_str(&format!("model = \"{m}\"\n"));
583 }
584 vec![block]
585}
586
587#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
588fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
589 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
590 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
591 copy_str_field(gemini, "model", &mut block);
592 copy_int_field(gemini, "max_tokens", &mut block);
593 copy_str_field(gemini, "base_url", &mut block);
594 copy_str_field(gemini, "embedding_model", &mut block);
595 copy_str_field(gemini, "thinking_level", &mut block);
596 copy_int_field(gemini, "thinking_budget", &mut block);
597 if let Some(v) = gemini
598 .get("include_thoughts")
599 .and_then(toml_edit::Item::as_bool)
600 {
601 block.push_str(&format!("include_thoughts = {v}\n"));
602 }
603 } else if let Some(m) = model {
604 block.push_str(&format!("model = \"{m}\"\n"));
605 }
606 vec![block]
607}
608
609#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
610fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
611 let mut blocks = Vec::new();
612 if let Some(compat_arr) = llm
613 .get("compatible")
614 .and_then(toml_edit::Item::as_array_of_tables)
615 {
616 for entry in compat_arr {
617 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
618 copy_str_field(entry, "name", &mut block);
619 copy_str_field(entry, "base_url", &mut block);
620 copy_str_field(entry, "model", &mut block);
621 copy_int_field(entry, "max_tokens", &mut block);
622 copy_str_field(entry, "embedding_model", &mut block);
623 blocks.push(block);
624 }
625 }
626 blocks
627}
628
629#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
631fn migrate_orchestrator_provider(
632 llm: &toml_edit::Table,
633 model: &Option<String>,
634 base_url: &Option<String>,
635 embedding_model: &Option<String>,
636) -> (Vec<String>, Option<String>, Option<String>) {
637 let mut blocks = Vec::new();
638 let routing = Some("task".to_owned());
639 let mut routes_block = None;
640 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
641 let default_name = orch
642 .get("default")
643 .and_then(toml_edit::Item::as_str)
644 .unwrap_or("")
645 .to_owned();
646 let embed_name = orch
647 .get("embed")
648 .and_then(toml_edit::Item::as_str)
649 .unwrap_or("")
650 .to_owned();
651 if let Some(routes) = orch.get("routes").and_then(toml_edit::Item::as_table) {
652 let mut rb = "[llm.routes]\n".to_owned();
653 for (key, val) in routes {
654 if let Some(arr) = val.as_array() {
655 let items: Vec<String> = arr
656 .iter()
657 .filter_map(toml_edit::Value::as_str)
658 .map(|s| format!("\"{s}\""))
659 .collect();
660 rb.push_str(&format!("{key} = [{}]\n", items.join(", ")));
661 }
662 }
663 routes_block = Some(rb);
664 }
665 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
666 for (name, pcfg_item) in providers {
667 let Some(pcfg) = pcfg_item.as_table() else {
668 continue;
669 };
670 let ptype = pcfg
671 .get("type")
672 .and_then(toml_edit::Item::as_str)
673 .unwrap_or("ollama");
674 let mut block =
675 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
676 if name == default_name {
677 block.push_str("default = true\n");
678 }
679 if name == embed_name {
680 block.push_str("embed = true\n");
681 }
682 copy_str_field(pcfg, "model", &mut block);
683 copy_str_field(pcfg, "base_url", &mut block);
684 copy_str_field(pcfg, "embedding_model", &mut block);
685 if ptype == "claude" && !pcfg.contains_key("model") {
686 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
687 copy_str_field(cloud, "model", &mut block);
688 copy_int_field(cloud, "max_tokens", &mut block);
689 }
690 }
691 if ptype == "openai" && !pcfg.contains_key("model") {
692 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
693 copy_str_field(openai, "model", &mut block);
694 copy_str_field(openai, "base_url", &mut block);
695 copy_int_field(openai, "max_tokens", &mut block);
696 copy_str_field(openai, "embedding_model", &mut block);
697 }
698 }
699 if ptype == "ollama" && !pcfg.contains_key("base_url") {
700 if let Some(u) = base_url {
701 block.push_str(&format!("base_url = \"{u}\"\n"));
702 }
703 }
704 if ptype == "ollama" && !pcfg.contains_key("model") {
705 if let Some(m) = model {
706 block.push_str(&format!("model = \"{m}\"\n"));
707 }
708 }
709 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
710 if let Some(em) = embedding_model {
711 block.push_str(&format!("embedding_model = \"{em}\"\n"));
712 }
713 }
714 blocks.push(block);
715 }
716 }
717 }
718 (blocks, routing, routes_block)
719}
720
721#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
723fn migrate_router_provider(
724 llm: &toml_edit::Table,
725 model: &Option<String>,
726 base_url: &Option<String>,
727 embedding_model: &Option<String>,
728) -> (Vec<String>, Option<String>) {
729 let mut blocks = Vec::new();
730 let mut routing = None;
731 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
732 let strategy = router
733 .get("strategy")
734 .and_then(toml_edit::Item::as_str)
735 .unwrap_or("ema");
736 routing = Some(strategy.to_owned());
737 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
738 for item in chain {
739 let name = item.as_str().unwrap_or_default();
740 let ptype = infer_provider_type(name, llm);
741 let mut block =
742 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
743 match ptype {
744 "claude" => {
745 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
746 copy_str_field(cloud, "model", &mut block);
747 copy_int_field(cloud, "max_tokens", &mut block);
748 }
749 }
750 "openai" => {
751 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
752 {
753 copy_str_field(openai, "model", &mut block);
754 copy_str_field(openai, "base_url", &mut block);
755 copy_int_field(openai, "max_tokens", &mut block);
756 copy_str_field(openai, "embedding_model", &mut block);
757 } else {
758 if let Some(m) = model {
759 block.push_str(&format!("model = \"{m}\"\n"));
760 }
761 if let Some(u) = base_url {
762 block.push_str(&format!("base_url = \"{u}\"\n"));
763 }
764 }
765 }
766 "ollama" => {
767 if let Some(m) = model {
768 block.push_str(&format!("model = \"{m}\"\n"));
769 }
770 if let Some(em) = embedding_model {
771 block.push_str(&format!("embedding_model = \"{em}\"\n"));
772 }
773 if let Some(u) = base_url {
774 block.push_str(&format!("base_url = \"{u}\"\n"));
775 }
776 }
777 _ => {
778 if let Some(m) = model {
779 block.push_str(&format!("model = \"{m}\"\n"));
780 }
781 }
782 }
783 blocks.push(block);
784 }
785 }
786 }
787 (blocks, routing)
788}
789
790#[allow(
801 clippy::too_many_lines,
802 clippy::format_push_string,
803 clippy::manual_let_else,
804 clippy::op_ref,
805 clippy::collapsible_if
806)]
807pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
808 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
809
810 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
812 Some(t) => t,
813 None => {
814 return Ok(MigrationResult {
816 output: toml_src.to_owned(),
817 added_count: 0,
818 sections_added: Vec::new(),
819 });
820 }
821 };
822
823 let has_provider_field = llm.contains_key("provider");
824 let has_cloud = llm.contains_key("cloud");
825 let has_openai = llm.contains_key("openai");
826 let has_gemini = llm.contains_key("gemini");
827 let has_orchestrator = llm.contains_key("orchestrator");
828 let has_router = llm.contains_key("router");
829 let has_providers = llm.contains_key("providers");
830
831 if !has_provider_field
832 && !has_cloud
833 && !has_openai
834 && !has_orchestrator
835 && !has_router
836 && !has_gemini
837 {
838 return Ok(MigrationResult {
840 output: toml_src.to_owned(),
841 added_count: 0,
842 sections_added: Vec::new(),
843 });
844 }
845
846 if has_providers {
847 return Err(MigrateError::Parse(
849 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
850 .parse::<toml_edit::DocumentMut>()
851 .unwrap_err(),
852 ));
853 }
854
855 let provider_str = llm
857 .get("provider")
858 .and_then(toml_edit::Item::as_str)
859 .unwrap_or("ollama");
860 let base_url = llm
861 .get("base_url")
862 .and_then(toml_edit::Item::as_str)
863 .map(str::to_owned);
864 let model = llm
865 .get("model")
866 .and_then(toml_edit::Item::as_str)
867 .map(str::to_owned);
868 let embedding_model = llm
869 .get("embedding_model")
870 .and_then(toml_edit::Item::as_str)
871 .map(str::to_owned);
872
873 let mut provider_blocks: Vec<String> = Vec::new();
875 let mut routing: Option<String> = None;
876 let mut routes_block: Option<String> = None;
877
878 match provider_str {
879 "ollama" => {
880 provider_blocks.extend(migrate_ollama_provider(
881 llm,
882 &model,
883 &base_url,
884 &embedding_model,
885 ));
886 }
887 "claude" => {
888 provider_blocks.extend(migrate_claude_provider(llm, &model));
889 }
890 "openai" => {
891 provider_blocks.extend(migrate_openai_provider(llm, &model));
892 }
893 "gemini" => {
894 provider_blocks.extend(migrate_gemini_provider(llm, &model));
895 }
896 "compatible" => {
897 provider_blocks.extend(migrate_compatible_provider(llm));
898 }
899 "orchestrator" => {
900 let (blocks, r, rb) =
901 migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
902 provider_blocks.extend(blocks);
903 routing = r;
904 routes_block = rb;
905 }
906 "router" => {
907 let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
908 provider_blocks.extend(blocks);
909 routing = r;
910 }
911 other => {
912 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
913 if let Some(ref m) = model {
914 block.push_str(&format!("model = \"{m}\"\n"));
915 }
916 provider_blocks.push(block);
917 }
918 }
919
920 if provider_blocks.is_empty() {
921 return Ok(MigrationResult {
923 output: toml_src.to_owned(),
924 added_count: 0,
925 sections_added: Vec::new(),
926 });
927 }
928
929 let mut new_llm = "[llm]\n".to_owned();
931 if let Some(ref r) = routing {
932 new_llm.push_str(&format!("routing = \"{r}\"\n"));
933 }
934 for key in &[
936 "response_cache_enabled",
937 "response_cache_ttl_secs",
938 "semantic_cache_enabled",
939 "semantic_cache_threshold",
940 "semantic_cache_max_candidates",
941 "summary_model",
942 "instruction_file",
943 ] {
944 if let Some(val) = llm.get(key) {
945 if let Some(v) = val.as_value() {
946 let raw = value_to_toml_string(v);
947 if !raw.is_empty() {
948 new_llm.push_str(&format!("{key} = {raw}\n"));
949 }
950 }
951 }
952 }
953 new_llm.push('\n');
954
955 if let Some(rb) = routes_block {
956 new_llm.push_str(&rb);
957 new_llm.push('\n');
958 }
959
960 for block in &provider_blocks {
961 new_llm.push_str(block);
962 new_llm.push('\n');
963 }
964
965 let output = replace_llm_section(toml_src, &new_llm);
968
969 Ok(MigrationResult {
970 output,
971 added_count: provider_blocks.len(),
972 sections_added: vec!["llm.providers".to_owned()],
973 })
974}
975
976fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
978 match name {
979 "claude" => "claude",
980 "openai" => "openai",
981 "gemini" => "gemini",
982 "ollama" => "ollama",
983 "candle" => "candle",
984 _ => {
985 if llm.contains_key("compatible") {
987 "compatible"
988 } else if llm.contains_key("openai") {
989 "openai"
990 } else {
991 "ollama"
992 }
993 }
994 }
995}
996
997fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
998 use std::fmt::Write as _;
999 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
1000 let _ = writeln!(out, "{key} = \"{v}\"");
1001 }
1002}
1003
1004fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1005 use std::fmt::Write as _;
1006 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
1007 let _ = writeln!(out, "{key} = {v}");
1008 }
1009}
1010
1011fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
1014 let mut out = String::new();
1015 let mut in_llm = false;
1016 let mut skip_until_next_top = false;
1017
1018 for line in toml_str.lines() {
1019 let trimmed = line.trim();
1020
1021 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
1023 && trimmed.ends_with(']')
1024 && !trimmed[1..trimmed.len() - 1].contains('.');
1025 let is_top_aot = trimmed.starts_with("[[")
1026 && trimmed.ends_with("]]")
1027 && !trimmed[2..trimmed.len() - 2].contains('.');
1028 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
1029 && (trimmed.contains(']'));
1030
1031 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
1032 in_llm = true;
1033 skip_until_next_top = true;
1034 continue;
1035 }
1036
1037 if is_top_section || is_top_aot {
1038 if skip_until_next_top {
1039 out.push_str(new_llm_section);
1041 skip_until_next_top = false;
1042 }
1043 in_llm = false;
1044 }
1045
1046 if !skip_until_next_top {
1047 out.push_str(line);
1048 out.push('\n');
1049 }
1050 }
1051
1052 if skip_until_next_top {
1054 out.push_str(new_llm_section);
1055 }
1056
1057 out
1058}
1059
1060#[allow(clippy::too_many_lines)]
1079pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1080 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1081
1082 let stt_model = doc
1084 .get("llm")
1085 .and_then(toml_edit::Item::as_table)
1086 .and_then(|llm| llm.get("stt"))
1087 .and_then(toml_edit::Item::as_table)
1088 .and_then(|stt| stt.get("model"))
1089 .and_then(toml_edit::Item::as_str)
1090 .map(ToOwned::to_owned);
1091
1092 let stt_base_url = doc
1093 .get("llm")
1094 .and_then(toml_edit::Item::as_table)
1095 .and_then(|llm| llm.get("stt"))
1096 .and_then(toml_edit::Item::as_table)
1097 .and_then(|stt| stt.get("base_url"))
1098 .and_then(toml_edit::Item::as_str)
1099 .map(ToOwned::to_owned);
1100
1101 let stt_provider_hint = doc
1102 .get("llm")
1103 .and_then(toml_edit::Item::as_table)
1104 .and_then(|llm| llm.get("stt"))
1105 .and_then(toml_edit::Item::as_table)
1106 .and_then(|stt| stt.get("provider"))
1107 .and_then(toml_edit::Item::as_str)
1108 .map(ToOwned::to_owned)
1109 .unwrap_or_default();
1110
1111 if stt_model.is_none() && stt_base_url.is_none() {
1113 return Ok(MigrationResult {
1114 output: toml_src.to_owned(),
1115 added_count: 0,
1116 sections_added: Vec::new(),
1117 });
1118 }
1119
1120 let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1121
1122 let target_type = match stt_provider_hint.as_str() {
1124 "candle-whisper" | "candle" => "candle",
1125 _ => "openai",
1126 };
1127
1128 let providers = doc
1131 .get("llm")
1132 .and_then(toml_edit::Item::as_table)
1133 .and_then(|llm| llm.get("providers"))
1134 .and_then(toml_edit::Item::as_array_of_tables);
1135
1136 let matching_idx = providers.and_then(|arr| {
1137 arr.iter().enumerate().find_map(|(i, t)| {
1138 let name = t
1139 .get("name")
1140 .and_then(toml_edit::Item::as_str)
1141 .unwrap_or("");
1142 let ptype = t
1143 .get("type")
1144 .and_then(toml_edit::Item::as_str)
1145 .unwrap_or("");
1146 let name_match = !stt_provider_hint.is_empty()
1148 && (name == stt_provider_hint || ptype == stt_provider_hint);
1149 let type_match = ptype == target_type;
1150 if name_match || type_match {
1151 Some(i)
1152 } else {
1153 None
1154 }
1155 })
1156 });
1157
1158 let resolved_provider_name: String;
1160
1161 if let Some(idx) = matching_idx {
1162 let llm_mut = doc
1164 .get_mut("llm")
1165 .and_then(toml_edit::Item::as_table_mut)
1166 .ok_or(MigrateError::InvalidStructure(
1167 "[llm] table not accessible for mutation",
1168 ))?;
1169 let providers_mut = llm_mut
1170 .get_mut("providers")
1171 .and_then(toml_edit::Item::as_array_of_tables_mut)
1172 .ok_or(MigrateError::InvalidStructure(
1173 "[[llm.providers]] array not accessible for mutation",
1174 ))?;
1175 let entry = providers_mut
1176 .iter_mut()
1177 .nth(idx)
1178 .ok_or(MigrateError::InvalidStructure(
1179 "[[llm.providers]] entry index out of range during mutation",
1180 ))?;
1181
1182 let existing_name = entry
1184 .get("name")
1185 .and_then(toml_edit::Item::as_str)
1186 .map(ToOwned::to_owned);
1187 let entry_name = existing_name.unwrap_or_else(|| {
1188 let t = entry
1189 .get("type")
1190 .and_then(toml_edit::Item::as_str)
1191 .unwrap_or("openai");
1192 format!("{t}-stt")
1193 });
1194 entry.insert("name", toml_edit::value(entry_name.clone()));
1195 entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1196 if stt_base_url.is_some() && entry.get("base_url").is_none() {
1197 entry.insert(
1198 "base_url",
1199 toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1200 );
1201 }
1202 resolved_provider_name = entry_name;
1203 } else {
1204 let new_name = if target_type == "candle" {
1206 "local-whisper".to_owned()
1207 } else {
1208 "openai-stt".to_owned()
1209 };
1210 let mut new_entry = toml_edit::Table::new();
1211 new_entry.insert("name", toml_edit::value(new_name.clone()));
1212 new_entry.insert("type", toml_edit::value(target_type));
1213 new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1214 if let Some(ref url) = stt_base_url {
1215 new_entry.insert("base_url", toml_edit::value(url.clone()));
1216 }
1217 let llm_mut = doc
1219 .get_mut("llm")
1220 .and_then(toml_edit::Item::as_table_mut)
1221 .ok_or(MigrateError::InvalidStructure(
1222 "[llm] table not accessible for mutation",
1223 ))?;
1224 if let Some(item) = llm_mut.get_mut("providers") {
1225 if let Some(arr) = item.as_array_of_tables_mut() {
1226 arr.push(new_entry);
1227 }
1228 } else {
1229 let mut arr = toml_edit::ArrayOfTables::new();
1230 arr.push(new_entry);
1231 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1232 }
1233 resolved_provider_name = new_name;
1234 }
1235
1236 if let Some(stt_table) = doc
1238 .get_mut("llm")
1239 .and_then(toml_edit::Item::as_table_mut)
1240 .and_then(|llm| llm.get_mut("stt"))
1241 .and_then(toml_edit::Item::as_table_mut)
1242 {
1243 stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1244 stt_table.remove("model");
1245 stt_table.remove("base_url");
1246 }
1247
1248 Ok(MigrationResult {
1249 output: doc.to_string(),
1250 added_count: 1,
1251 sections_added: vec!["llm.providers.stt_model".to_owned()],
1252 })
1253}
1254
1255pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1268 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1269
1270 let old_value = doc
1271 .get("orchestration")
1272 .and_then(toml_edit::Item::as_table)
1273 .and_then(|t| t.get("planner_model"))
1274 .and_then(toml_edit::Item::as_value)
1275 .and_then(toml_edit::Value::as_str)
1276 .map(ToOwned::to_owned);
1277
1278 let Some(old_model) = old_value else {
1279 return Ok(MigrationResult {
1280 output: toml_src.to_owned(),
1281 added_count: 0,
1282 sections_added: Vec::new(),
1283 });
1284 };
1285
1286 let commented_out = format!(
1290 "# planner_provider = \"{old_model}\" \
1291 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1292 );
1293
1294 let orch_table = doc
1295 .get_mut("orchestration")
1296 .and_then(toml_edit::Item::as_table_mut)
1297 .ok_or(MigrateError::InvalidStructure(
1298 "[orchestration] is not a table",
1299 ))?;
1300 orch_table.remove("planner_model");
1301 let decor = orch_table.decor_mut();
1302 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1303 let new_suffix = if existing_suffix.trim().is_empty() {
1305 format!("\n{commented_out}\n")
1306 } else {
1307 format!("{existing_suffix}\n{commented_out}\n")
1308 };
1309 decor.set_suffix(new_suffix);
1310
1311 eprintln!(
1312 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1313 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1314 `name` field, not a raw model name. Update or remove the commented line."
1315 );
1316
1317 Ok(MigrationResult {
1318 output: doc.to_string(),
1319 added_count: 1,
1320 sections_added: vec!["orchestration.planner_provider".to_owned()],
1321 })
1322}
1323
1324pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1338 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1339 let mut added = 0usize;
1340
1341 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1342 return Ok(MigrationResult {
1343 output: toml_src.to_owned(),
1344 added_count: 0,
1345 sections_added: Vec::new(),
1346 });
1347 };
1348
1349 let Some(servers) = mcp
1350 .get_mut("servers")
1351 .and_then(toml_edit::Item::as_array_of_tables_mut)
1352 else {
1353 return Ok(MigrationResult {
1354 output: toml_src.to_owned(),
1355 added_count: 0,
1356 sections_added: Vec::new(),
1357 });
1358 };
1359
1360 for entry in servers.iter_mut() {
1361 if !entry.contains_key("trust_level") {
1362 entry.insert(
1363 "trust_level",
1364 toml_edit::value(toml_edit::Value::from("trusted")),
1365 );
1366 added += 1;
1367 }
1368 }
1369
1370 if added > 0 {
1371 eprintln!(
1372 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1373 entr{} (preserving previous SSRF-skip behavior). \
1374 Review and adjust trust levels as needed.",
1375 if added == 1 { "y" } else { "ies" }
1376 );
1377 }
1378
1379 Ok(MigrationResult {
1380 output: doc.to_string(),
1381 added_count: added,
1382 sections_added: if added > 0 {
1383 vec!["mcp.servers.trust_level".to_owned()]
1384 } else {
1385 Vec::new()
1386 },
1387 })
1388}
1389
1390pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1401 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1402
1403 let max_retries = doc
1404 .get("agent")
1405 .and_then(toml_edit::Item::as_table)
1406 .and_then(|t| t.get("max_tool_retries"))
1407 .and_then(toml_edit::Item::as_value)
1408 .and_then(toml_edit::Value::as_integer)
1409 .map(i64::cast_unsigned);
1410
1411 let budget_secs = doc
1412 .get("agent")
1413 .and_then(toml_edit::Item::as_table)
1414 .and_then(|t| t.get("max_retry_duration_secs"))
1415 .and_then(toml_edit::Item::as_value)
1416 .and_then(toml_edit::Value::as_integer)
1417 .map(i64::cast_unsigned);
1418
1419 if max_retries.is_none() && budget_secs.is_none() {
1420 return Ok(MigrationResult {
1421 output: toml_src.to_owned(),
1422 added_count: 0,
1423 sections_added: Vec::new(),
1424 });
1425 }
1426
1427 if !doc.contains_key("tools") {
1429 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1430 }
1431 let tools_table = doc
1432 .get_mut("tools")
1433 .and_then(toml_edit::Item::as_table_mut)
1434 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1435
1436 if !tools_table.contains_key("retry") {
1437 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1438 }
1439 let retry_table = tools_table
1440 .get_mut("retry")
1441 .and_then(toml_edit::Item::as_table_mut)
1442 .ok_or(MigrateError::InvalidStructure(
1443 "[tools.retry] is not a table",
1444 ))?;
1445
1446 let mut added_count = 0usize;
1447
1448 if let Some(retries) = max_retries
1449 && !retry_table.contains_key("max_attempts")
1450 {
1451 retry_table.insert(
1452 "max_attempts",
1453 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1454 );
1455 added_count += 1;
1456 }
1457
1458 if let Some(secs) = budget_secs
1459 && !retry_table.contains_key("budget_secs")
1460 {
1461 retry_table.insert(
1462 "budget_secs",
1463 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1464 );
1465 added_count += 1;
1466 }
1467
1468 if added_count > 0 {
1469 eprintln!(
1470 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1471 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1472 );
1473 }
1474
1475 Ok(MigrationResult {
1476 output: doc.to_string(),
1477 added_count,
1478 sections_added: if added_count > 0 {
1479 vec!["tools.retry".to_owned()]
1480 } else {
1481 Vec::new()
1482 },
1483 })
1484}
1485
1486pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1495 if toml_src.contains("database_url") {
1497 return Ok(MigrationResult {
1498 output: toml_src.to_owned(),
1499 added_count: 0,
1500 sections_added: Vec::new(),
1501 });
1502 }
1503
1504 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1505
1506 if !doc.contains_key("memory") {
1508 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1509 }
1510
1511 let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1512 # Leave empty and store the actual URL in the vault:\n\
1513 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1514 # database_url = \"\"\n";
1515 let raw = doc.to_string();
1516 let output = format!("{raw}{comment}");
1517
1518 Ok(MigrationResult {
1519 output,
1520 added_count: 1,
1521 sections_added: vec!["memory.database_url".to_owned()],
1522 })
1523}
1524
1525pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1534 if toml_src.contains("transactional") {
1536 return Ok(MigrationResult {
1537 output: toml_src.to_owned(),
1538 added_count: 0,
1539 sections_added: Vec::new(),
1540 });
1541 }
1542
1543 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1544
1545 let tools_shell_exists = doc
1546 .get("tools")
1547 .and_then(toml_edit::Item::as_table)
1548 .is_some_and(|t| t.contains_key("shell"));
1549 if !tools_shell_exists {
1550 return Ok(MigrationResult {
1552 output: toml_src.to_owned(),
1553 added_count: 0,
1554 sections_added: Vec::new(),
1555 });
1556 }
1557
1558 let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1559 # transactional = false\n\
1560 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1561 # auto_rollback = false # rollback when exit code >= 2\n\
1562 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1563 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1564 let raw = doc.to_string();
1565 let output = format!("{raw}{comment}");
1566
1567 Ok(MigrationResult {
1568 output,
1569 added_count: 1,
1570 sections_added: vec!["tools.shell.transactional".to_owned()],
1571 })
1572}
1573
1574pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1580 if toml_src.contains("budget_hint_enabled") {
1582 return Ok(MigrationResult {
1583 output: toml_src.to_owned(),
1584 added_count: 0,
1585 sections_added: Vec::new(),
1586 });
1587 }
1588
1589 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1590 if !doc.contains_key("agent") {
1591 return Ok(MigrationResult {
1592 output: toml_src.to_owned(),
1593 added_count: 0,
1594 sections_added: Vec::new(),
1595 });
1596 }
1597
1598 let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1599 # budget_hint_enabled = true\n";
1600 let raw = doc.to_string();
1601 let output = format!("{raw}{comment}");
1602
1603 Ok(MigrationResult {
1604 output,
1605 added_count: 1,
1606 sections_added: vec!["agent.budget_hint_enabled".to_owned()],
1607 })
1608}
1609
1610pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1619 if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1621 return Ok(MigrationResult {
1622 output: toml_src.to_owned(),
1623 added_count: 0,
1624 sections_added: Vec::new(),
1625 });
1626 }
1627
1628 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1629 if !doc.contains_key("memory") {
1630 return Ok(MigrationResult {
1631 output: toml_src.to_owned(),
1632 added_count: 0,
1633 sections_added: Vec::new(),
1634 });
1635 }
1636
1637 let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1638 # [memory.forgetting]\n\
1639 # enabled = false\n\
1640 # decay_rate = 0.1 # per-sweep importance decay\n\
1641 # forgetting_floor = 0.05 # prune below this score\n\
1642 # sweep_interval_secs = 7200 # run every 2 hours\n\
1643 # sweep_batch_size = 500\n\
1644 # protect_recent_hours = 24\n\
1645 # protect_min_access_count = 3\n";
1646 let raw = doc.to_string();
1647 let output = format!("{raw}{comment}");
1648
1649 Ok(MigrationResult {
1650 output,
1651 added_count: 1,
1652 sections_added: vec!["memory.forgetting".to_owned()],
1653 })
1654}
1655
1656pub fn migrate_compression_predictor_config(
1664 toml_src: &str,
1665) -> Result<MigrationResult, MigrateError> {
1666 if toml_src.contains("[memory.compression.predictor]")
1668 || toml_src.contains("# [memory.compression.predictor]")
1669 {
1670 return Ok(MigrationResult {
1671 output: toml_src.to_owned(),
1672 added_count: 0,
1673 sections_added: Vec::new(),
1674 });
1675 }
1676
1677 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1678 if !doc.contains_key("memory") {
1679 return Ok(MigrationResult {
1680 output: toml_src.to_owned(),
1681 added_count: 0,
1682 sections_added: Vec::new(),
1683 });
1684 }
1685
1686 let comment = "\n# Performance-floor compression ratio predictor (#2460). Disabled by default.\n\
1687 # [memory.compression.predictor]\n\
1688 # enabled = false\n\
1689 # min_samples = 10 # cold-start threshold\n\
1690 # candidate_ratios = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]\n\
1691 # retrain_interval = 5\n\
1692 # max_training_samples = 200\n";
1693 let raw = doc.to_string();
1694 let output = format!("{raw}{comment}");
1695
1696 Ok(MigrationResult {
1697 output,
1698 added_count: 1,
1699 sections_added: vec!["memory.compression.predictor".to_owned()],
1700 })
1701}
1702
1703pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1709 if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1711 return Ok(MigrationResult {
1712 output: toml_src.to_owned(),
1713 added_count: 0,
1714 sections_added: Vec::new(),
1715 });
1716 }
1717
1718 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1719 if !doc.contains_key("memory") {
1720 return Ok(MigrationResult {
1721 output: toml_src.to_owned(),
1722 added_count: 0,
1723 sections_added: Vec::new(),
1724 });
1725 }
1726
1727 let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1728 # [memory.microcompact]\n\
1729 # enabled = false\n\
1730 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1731 # keep_recent = 3 # always keep this many recent outputs intact\n";
1732 let raw = doc.to_string();
1733 let output = format!("{raw}{comment}");
1734
1735 Ok(MigrationResult {
1736 output,
1737 added_count: 1,
1738 sections_added: vec!["memory.microcompact".to_owned()],
1739 })
1740}
1741
1742pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1748 if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1750 return Ok(MigrationResult {
1751 output: toml_src.to_owned(),
1752 added_count: 0,
1753 sections_added: Vec::new(),
1754 });
1755 }
1756
1757 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1758 if !doc.contains_key("memory") {
1759 return Ok(MigrationResult {
1760 output: toml_src.to_owned(),
1761 added_count: 0,
1762 sections_added: Vec::new(),
1763 });
1764 }
1765
1766 let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1767 # [memory.autodream]\n\
1768 # enabled = false\n\
1769 # min_sessions = 5 # sessions since last consolidation\n\
1770 # min_hours = 8 # hours since last consolidation\n\
1771 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1772 # max_iterations = 5\n";
1773 let raw = doc.to_string();
1774 let output = format!("{raw}{comment}");
1775
1776 Ok(MigrationResult {
1777 output,
1778 added_count: 1,
1779 sections_added: vec!["memory.autodream".to_owned()],
1780 })
1781}
1782
1783pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1789 use toml_edit::{Item, Table};
1790
1791 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1792
1793 if doc.contains_key("magic_docs") {
1794 return Ok(MigrationResult {
1795 output: toml_src.to_owned(),
1796 added_count: 0,
1797 sections_added: Vec::new(),
1798 });
1799 }
1800
1801 doc.insert("magic_docs", Item::Table(Table::new()));
1802 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1803 # [magic_docs]\n\
1804 # enabled = false\n\
1805 # min_turns_between_updates = 10\n\
1806 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1807 # max_iterations = 3\n";
1808 doc.remove("magic_docs");
1810 let raw = doc.to_string();
1812 let output = format!("{raw}\n{comment}");
1813
1814 Ok(MigrationResult {
1815 output,
1816 added_count: 1,
1817 sections_added: vec!["magic_docs".to_owned()],
1818 })
1819}
1820
1821pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1830 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1831
1832 if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1833 return Ok(MigrationResult {
1834 output: toml_src.to_owned(),
1835 added_count: 0,
1836 sections_added: Vec::new(),
1837 });
1838 }
1839
1840 let comment = "\n\
1841 # Profiling and distributed tracing (requires --features profiling). All\n\
1842 # instrumentation points are zero-overhead when the feature is absent.\n\
1843 # [telemetry]\n\
1844 # enabled = false\n\
1845 # backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1846 # trace_dir = \".local/traces\"\n\
1847 # include_args = false\n\
1848 # service_name = \"zeph-agent\"\n\
1849 # sample_rate = 1.0\n\
1850 # otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1851
1852 let raw = doc.to_string();
1853 let output = format!("{raw}{comment}");
1854
1855 Ok(MigrationResult {
1856 output,
1857 added_count: 1,
1858 sections_added: vec!["telemetry".to_owned()],
1859 })
1860}
1861
1862pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1871 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1873 return Ok(MigrationResult {
1874 output: toml_src.to_owned(),
1875 added_count: 0,
1876 sections_added: Vec::new(),
1877 });
1878 }
1879
1880 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1881
1882 if !doc.contains_key("agent") {
1885 return Ok(MigrationResult {
1886 output: toml_src.to_owned(),
1887 added_count: 0,
1888 sections_added: Vec::new(),
1889 });
1890 }
1891
1892 let comment = "\n\
1893 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1894 # [agent.supervisor]\n\
1895 # enrichment_limit = 4\n\
1896 # telemetry_limit = 8\n\
1897 # abort_enrichment_on_turn = false\n";
1898
1899 let raw = doc.to_string();
1900 let output = format!("{raw}{comment}");
1901
1902 Ok(MigrationResult {
1903 output,
1904 added_count: 1,
1905 sections_added: vec!["agent.supervisor".to_owned()],
1906 })
1907}
1908
1909pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1919 if toml_src.contains("otel_filter") {
1921 return Ok(MigrationResult {
1922 output: toml_src.to_owned(),
1923 added_count: 0,
1924 sections_added: Vec::new(),
1925 });
1926 }
1927
1928 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1929
1930 if !doc.contains_key("telemetry") {
1933 return Ok(MigrationResult {
1934 output: toml_src.to_owned(),
1935 added_count: 0,
1936 sections_added: Vec::new(),
1937 });
1938 }
1939
1940 let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
1941 (tonic=warn etc.) are always appended (#2997).\n\
1942 # otel_filter = \"info\"\n";
1943 let raw = doc.to_string();
1944 let output = insert_after_section(&raw, "telemetry", comment);
1946
1947 Ok(MigrationResult {
1948 output,
1949 added_count: 1,
1950 sections_added: vec!["telemetry.otel_filter".to_owned()],
1951 })
1952}
1953
1954pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1960 if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
1961 return Ok(MigrationResult {
1962 output: toml_src.to_owned(),
1963 added_count: 0,
1964 sections_added: Vec::new(),
1965 });
1966 }
1967
1968 let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
1969 # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
1970 # [tools.egress]\n\
1971 # enabled = true # set to false to disable all egress event recording\n\
1972 # log_blocked = true # record scheme/domain/SSRF-blocked requests\n\
1973 # log_response_bytes = true\n\
1974 # log_hosts_to_tui = true\n";
1975
1976 let mut output = toml_src.to_owned();
1977 output.push_str(comment);
1978 Ok(MigrationResult {
1979 output,
1980 added_count: 1,
1981 sections_added: vec!["tools.egress".to_owned()],
1982 })
1983}
1984
1985pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1991 if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
1992 return Ok(MigrationResult {
1993 output: toml_src.to_owned(),
1994 added_count: 0,
1995 sections_added: Vec::new(),
1996 });
1997 }
1998
1999 let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2000 # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2001 # [security.vigil]\n\
2002 # enabled = true # master switch; false bypasses VIGIL entirely\n\
2003 # strict_mode = false # true: block (replace with sentinel); false: truncate+annotate\n\
2004 # sanitize_max_chars = 2048\n\
2005 # extra_patterns = [] # operator-supplied additional injection patterns (max 64)\n\
2006 # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2007
2008 let mut output = toml_src.to_owned();
2009 output.push_str(comment);
2010 Ok(MigrationResult {
2011 output,
2012 added_count: 1,
2013 sections_added: vec!["security.vigil".to_owned()],
2014 })
2015}
2016
2017pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2029 let doc: DocumentMut = toml_src.parse()?;
2030 let already_present = doc
2031 .get("tools")
2032 .and_then(|t| t.as_table())
2033 .and_then(|t| t.get("sandbox"))
2034 .is_some();
2035 if already_present || toml_src.contains("# [tools.sandbox]") {
2038 return Ok(MigrationResult {
2039 output: toml_src.to_owned(),
2040 added_count: 0,
2041 sections_added: Vec::new(),
2042 });
2043 }
2044
2045 let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2046 # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2047 # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2048 # [tools.sandbox]\n\
2049 # enabled = false # set to true to wrap shell commands\n\
2050 # profile = \"workspace\" # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2051 # backend = \"auto\" # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2052 # strict = true # fail startup if sandbox init fails (fail-closed)\n\
2053 # allow_read = [] # additional read-allowed absolute paths\n\
2054 # allow_write = [] # additional write-allowed absolute paths\n";
2055
2056 let mut output = toml_src.to_owned();
2057 output.push_str(comment);
2058 Ok(MigrationResult {
2059 output,
2060 added_count: 1,
2061 sections_added: vec!["tools.sandbox".to_owned()],
2062 })
2063}
2064
2065pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2074 if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2076 return Ok(MigrationResult {
2077 output: toml_src.to_owned(),
2078 added_count: 0,
2079 sections_added: Vec::new(),
2080 });
2081 }
2082
2083 if !toml_src.contains("[orchestration]") {
2085 return Ok(MigrationResult {
2086 output: toml_src.to_owned(),
2087 added_count: 0,
2088 sections_added: Vec::new(),
2089 });
2090 }
2091
2092 let comment = "# persistence_enabled = true \
2094 # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2095 let output = toml_src.replacen(
2096 "[orchestration]\n",
2097 &format!("[orchestration]\n{comment}"),
2098 1,
2099 );
2100 Ok(MigrationResult {
2101 output,
2102 added_count: 1,
2103 sections_added: vec!["orchestration.persistence_enabled".to_owned()],
2104 })
2105}
2106
2107pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2115 if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2117 return Ok(MigrationResult {
2118 output: toml_src.to_owned(),
2119 added_count: 0,
2120 sections_added: Vec::new(),
2121 });
2122 }
2123
2124 let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2125 # [session.recap]\n\
2126 # on_resume = true\n\
2127 # max_tokens = 200\n\
2128 # provider = \"\"\n\
2129 # max_input_messages = 20\n";
2130 let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2131 let output = format!("{raw}{comment}");
2132
2133 Ok(MigrationResult {
2134 output,
2135 added_count: 1,
2136 sections_added: vec!["session.recap".to_owned()],
2137 })
2138}
2139
2140pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2148 if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2150 return Ok(MigrationResult {
2151 output: toml_src.to_owned(),
2152 added_count: 0,
2153 sections_added: Vec::new(),
2154 });
2155 }
2156
2157 if !toml_src.contains("[mcp]") {
2159 return Ok(MigrationResult {
2160 output: toml_src.to_owned(),
2161 added_count: 0,
2162 sections_added: Vec::new(),
2163 });
2164 }
2165
2166 if !toml_src.contains("[mcp]\n") {
2168 return Ok(MigrationResult {
2169 output: toml_src.to_owned(),
2170 added_count: 0,
2171 sections_added: Vec::new(),
2172 });
2173 }
2174
2175 let comment = "# elicitation_enabled = false \
2176 # opt-in: servers may request user input mid-task (#3141)\n\
2177 # elicitation_timeout = 120 # seconds to wait for user response\n\
2178 # elicitation_queue_capacity = 16 # beyond this limit requests are auto-declined\n\
2179 # elicitation_warn_sensitive_fields = true # warn before prompting for password/token/etc.\n";
2180 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2181
2182 Ok(MigrationResult {
2183 output,
2184 added_count: 1,
2185 sections_added: vec!["mcp.elicitation".to_owned()],
2186 })
2187}
2188
2189pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2200 if toml_src
2202 .lines()
2203 .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2204 {
2205 return Ok(MigrationResult {
2206 output: toml_src.to_owned(),
2207 added_count: 0,
2208 sections_added: Vec::new(),
2209 });
2210 }
2211
2212 let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2213 # [quality]\n\
2214 # self_check = false # enable post-response self-check\n\
2215 # trigger = \"has_retrieval\" # has_retrieval | always | manual\n\
2216 # latency_budget_ms = 4000 # hard ceiling for the whole pipeline\n\
2217 # proposer_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2218 # checker_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2219 # min_evidence = 0.6 # 0.0..1.0; below → flag assertion\n\
2220 # async_run = false # true = fire-and-forget (non-blocking)\n\
2221 # per_call_timeout_ms = 2000 # per-LLM-call timeout\n\
2222 # max_assertions = 12 # maximum assertions extracted from one response\n\
2223 # max_response_chars = 8000 # skip pipeline when response exceeds this\n\
2224 # cache_disabled_for_checker = true # suppress prompt-cache on Checker provider\n\
2225 # flag_marker = \"[verify]\" # marker appended when assertions are flagged\n";
2226 let output = format!("{toml_src}{comment}");
2227
2228 Ok(MigrationResult {
2229 output,
2230 added_count: 1,
2231 sections_added: vec!["quality".to_owned()],
2232 })
2233}
2234
2235#[cfg(test)]
2237fn make_formatted_str(s: &str) -> Value {
2238 use toml_edit::Formatted;
2239 Value::String(Formatted::new(s.to_owned()))
2240}
2241
2242#[cfg(test)]
2243mod tests {
2244 use super::*;
2245
2246 #[test]
2247 fn empty_config_gets_sections_as_comments() {
2248 let migrator = ConfigMigrator::new();
2249 let result = migrator.migrate("").expect("migrate empty");
2250 assert!(result.added_count > 0 || !result.sections_added.is_empty());
2252 assert!(
2254 result.output.contains("[agent]") || result.output.contains("# [agent]"),
2255 "expected agent section in output, got:\n{}",
2256 result.output
2257 );
2258 }
2259
2260 #[test]
2261 fn existing_values_not_overwritten() {
2262 let user = r#"
2263[agent]
2264name = "MyAgent"
2265max_tool_iterations = 5
2266"#;
2267 let migrator = ConfigMigrator::new();
2268 let result = migrator.migrate(user).expect("migrate");
2269 assert!(
2271 result.output.contains("name = \"MyAgent\""),
2272 "user value should be preserved"
2273 );
2274 assert!(
2275 result.output.contains("max_tool_iterations = 5"),
2276 "user value should be preserved"
2277 );
2278 assert!(
2280 !result.output.contains("# max_tool_iterations = 10"),
2281 "already-set key should not appear as comment"
2282 );
2283 }
2284
2285 #[test]
2286 fn missing_nested_key_added_as_comment() {
2287 let user = r#"
2289[memory]
2290sqlite_path = ".zeph/data/zeph.db"
2291"#;
2292 let migrator = ConfigMigrator::new();
2293 let result = migrator.migrate(user).expect("migrate");
2294 assert!(
2296 result.output.contains("# history_limit"),
2297 "missing key should be added as comment, got:\n{}",
2298 result.output
2299 );
2300 }
2301
2302 #[test]
2303 fn unknown_user_keys_preserved() {
2304 let user = r#"
2305[agent]
2306name = "Test"
2307my_custom_key = "preserved"
2308"#;
2309 let migrator = ConfigMigrator::new();
2310 let result = migrator.migrate(user).expect("migrate");
2311 assert!(
2312 result.output.contains("my_custom_key = \"preserved\""),
2313 "custom user keys must not be removed"
2314 );
2315 }
2316
2317 #[test]
2318 fn idempotent() {
2319 let migrator = ConfigMigrator::new();
2320 let first = migrator
2321 .migrate("[agent]\nname = \"Zeph\"\n")
2322 .expect("first migrate");
2323 let second = migrator.migrate(&first.output).expect("second migrate");
2324 assert_eq!(
2325 first.output, second.output,
2326 "idempotent: full output must be identical on second run"
2327 );
2328 }
2329
2330 #[test]
2331 fn malformed_input_returns_error() {
2332 let migrator = ConfigMigrator::new();
2333 let err = migrator
2334 .migrate("[[invalid toml [[[")
2335 .expect_err("should error");
2336 assert!(
2337 matches!(err, MigrateError::Parse(_)),
2338 "expected Parse error"
2339 );
2340 }
2341
2342 #[test]
2343 fn array_of_tables_preserved() {
2344 let user = r#"
2345[mcp]
2346allowed_commands = ["npx"]
2347
2348[[mcp.servers]]
2349id = "my-server"
2350command = "npx"
2351args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2352"#;
2353 let migrator = ConfigMigrator::new();
2354 let result = migrator.migrate(user).expect("migrate");
2355 assert!(
2357 result.output.contains("[[mcp.servers]]"),
2358 "array-of-tables entries must be preserved"
2359 );
2360 assert!(result.output.contains("id = \"my-server\""));
2361 }
2362
2363 #[test]
2364 fn canonical_ordering_applied() {
2365 let user = r#"
2367[memory]
2368sqlite_path = ".zeph/data/zeph.db"
2369
2370[agent]
2371name = "Test"
2372"#;
2373 let migrator = ConfigMigrator::new();
2374 let result = migrator.migrate(user).expect("migrate");
2375 let agent_pos = result.output.find("[agent]");
2377 let memory_pos = result.output.find("[memory]");
2378 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2379 assert!(a < m, "agent section should precede memory section");
2380 }
2381 }
2382
2383 #[test]
2384 fn value_to_toml_string_formats_correctly() {
2385 use toml_edit::Formatted;
2386
2387 let s = make_formatted_str("hello");
2388 assert_eq!(value_to_toml_string(&s), "\"hello\"");
2389
2390 let i = Value::Integer(Formatted::new(42_i64));
2391 assert_eq!(value_to_toml_string(&i), "42");
2392
2393 let b = Value::Boolean(Formatted::new(true));
2394 assert_eq!(value_to_toml_string(&b), "true");
2395
2396 let f = Value::Float(Formatted::new(1.0_f64));
2397 assert_eq!(value_to_toml_string(&f), "1.0");
2398
2399 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2400 assert_eq!(value_to_toml_string(&f2), "3.14");
2401
2402 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2403 let arr_val = Value::Array(arr);
2404 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2405
2406 let empty_arr = Value::Array(Array::new());
2407 assert_eq!(value_to_toml_string(&empty_arr), "[]");
2408 }
2409
2410 #[test]
2411 fn idempotent_full_output_unchanged() {
2412 let migrator = ConfigMigrator::new();
2414 let first = migrator
2415 .migrate("[agent]\nname = \"Zeph\"\n")
2416 .expect("first migrate");
2417 let second = migrator.migrate(&first.output).expect("second migrate");
2418 assert_eq!(
2419 first.output, second.output,
2420 "full output string must be identical after second migration pass"
2421 );
2422 }
2423
2424 #[test]
2425 fn full_config_produces_zero_additions() {
2426 let reference = include_str!("../config/default.toml");
2428 let migrator = ConfigMigrator::new();
2429 let result = migrator.migrate(reference).expect("migrate reference");
2430 assert_eq!(
2431 result.added_count, 0,
2432 "migrating the canonical reference should add nothing (added_count = {})",
2433 result.added_count
2434 );
2435 assert!(
2436 result.sections_added.is_empty(),
2437 "migrating the canonical reference should report no sections_added: {:?}",
2438 result.sections_added
2439 );
2440 }
2441
2442 #[test]
2443 fn empty_config_added_count_is_positive() {
2444 let migrator = ConfigMigrator::new();
2446 let result = migrator.migrate("").expect("migrate empty");
2447 assert!(
2448 result.added_count > 0,
2449 "empty config must report added_count > 0"
2450 );
2451 }
2452
2453 #[test]
2456 fn security_without_guardrail_gets_guardrail_commented() {
2457 let user = "[security]\nredact_secrets = true\n";
2458 let migrator = ConfigMigrator::new();
2459 let result = migrator.migrate(user).expect("migrate");
2460 assert!(
2462 result.output.contains("guardrail"),
2463 "migration must add guardrail keys for configs without [security.guardrail]: \
2464 got:\n{}",
2465 result.output
2466 );
2467 }
2468
2469 #[test]
2470 fn migrate_reference_contains_tools_policy() {
2471 let reference = include_str!("../config/default.toml");
2476 assert!(
2477 reference.contains("[tools.policy]"),
2478 "default.toml must contain [tools.policy] section so migrate-config can surface it"
2479 );
2480 assert!(
2481 reference.contains("enabled = false"),
2482 "tools.policy section must include enabled = false default"
2483 );
2484 }
2485
2486 #[test]
2487 fn migrate_reference_contains_probe_section() {
2488 let reference = include_str!("../config/default.toml");
2491 assert!(
2492 reference.contains("[memory.compression.probe]"),
2493 "default.toml must contain [memory.compression.probe] section comment"
2494 );
2495 assert!(
2496 reference.contains("hard_fail_threshold"),
2497 "probe section must include hard_fail_threshold default"
2498 );
2499 }
2500
2501 #[test]
2504 fn migrate_llm_no_llm_section_is_noop() {
2505 let src = "[agent]\nname = \"Zeph\"\n";
2506 let result = migrate_llm_to_providers(src).expect("migrate");
2507 assert_eq!(result.added_count, 0);
2508 assert_eq!(result.output, src);
2509 }
2510
2511 #[test]
2512 fn migrate_llm_already_new_format_is_noop() {
2513 let src = r#"
2514[llm]
2515[[llm.providers]]
2516type = "ollama"
2517model = "qwen3:8b"
2518"#;
2519 let result = migrate_llm_to_providers(src).expect("migrate");
2520 assert_eq!(result.added_count, 0);
2521 }
2522
2523 #[test]
2524 fn migrate_llm_ollama_produces_providers_block() {
2525 let src = r#"
2526[llm]
2527provider = "ollama"
2528model = "qwen3:8b"
2529base_url = "http://localhost:11434"
2530embedding_model = "nomic-embed-text"
2531"#;
2532 let result = migrate_llm_to_providers(src).expect("migrate");
2533 assert!(
2534 result.output.contains("[[llm.providers]]"),
2535 "should contain [[llm.providers]]:\n{}",
2536 result.output
2537 );
2538 assert!(
2539 result.output.contains("type = \"ollama\""),
2540 "{}",
2541 result.output
2542 );
2543 assert!(
2544 result.output.contains("model = \"qwen3:8b\""),
2545 "{}",
2546 result.output
2547 );
2548 }
2549
2550 #[test]
2551 fn migrate_llm_claude_produces_providers_block() {
2552 let src = r#"
2553[llm]
2554provider = "claude"
2555
2556[llm.cloud]
2557model = "claude-sonnet-4-6"
2558max_tokens = 8192
2559server_compaction = true
2560"#;
2561 let result = migrate_llm_to_providers(src).expect("migrate");
2562 assert!(
2563 result.output.contains("[[llm.providers]]"),
2564 "{}",
2565 result.output
2566 );
2567 assert!(
2568 result.output.contains("type = \"claude\""),
2569 "{}",
2570 result.output
2571 );
2572 assert!(
2573 result.output.contains("model = \"claude-sonnet-4-6\""),
2574 "{}",
2575 result.output
2576 );
2577 assert!(
2578 result.output.contains("server_compaction = true"),
2579 "{}",
2580 result.output
2581 );
2582 }
2583
2584 #[test]
2585 fn migrate_llm_openai_copies_fields() {
2586 let src = r#"
2587[llm]
2588provider = "openai"
2589
2590[llm.openai]
2591base_url = "https://api.openai.com/v1"
2592model = "gpt-4o"
2593max_tokens = 4096
2594"#;
2595 let result = migrate_llm_to_providers(src).expect("migrate");
2596 assert!(
2597 result.output.contains("type = \"openai\""),
2598 "{}",
2599 result.output
2600 );
2601 assert!(
2602 result
2603 .output
2604 .contains("base_url = \"https://api.openai.com/v1\""),
2605 "{}",
2606 result.output
2607 );
2608 }
2609
2610 #[test]
2611 fn migrate_llm_gemini_copies_fields() {
2612 let src = r#"
2613[llm]
2614provider = "gemini"
2615
2616[llm.gemini]
2617model = "gemini-2.0-flash"
2618max_tokens = 8192
2619base_url = "https://generativelanguage.googleapis.com"
2620"#;
2621 let result = migrate_llm_to_providers(src).expect("migrate");
2622 assert!(
2623 result.output.contains("type = \"gemini\""),
2624 "{}",
2625 result.output
2626 );
2627 assert!(
2628 result.output.contains("model = \"gemini-2.0-flash\""),
2629 "{}",
2630 result.output
2631 );
2632 }
2633
2634 #[test]
2635 fn migrate_llm_compatible_copies_multiple_entries() {
2636 let src = r#"
2637[llm]
2638provider = "compatible"
2639
2640[[llm.compatible]]
2641name = "proxy-a"
2642base_url = "http://proxy-a:8080/v1"
2643model = "llama3"
2644max_tokens = 4096
2645
2646[[llm.compatible]]
2647name = "proxy-b"
2648base_url = "http://proxy-b:8080/v1"
2649model = "mistral"
2650max_tokens = 2048
2651"#;
2652 let result = migrate_llm_to_providers(src).expect("migrate");
2653 let count = result.output.matches("[[llm.providers]]").count();
2655 assert_eq!(
2656 count, 2,
2657 "expected 2 [[llm.providers]] blocks:\n{}",
2658 result.output
2659 );
2660 assert!(
2661 result.output.contains("name = \"proxy-a\""),
2662 "{}",
2663 result.output
2664 );
2665 assert!(
2666 result.output.contains("name = \"proxy-b\""),
2667 "{}",
2668 result.output
2669 );
2670 }
2671
2672 #[test]
2673 fn migrate_llm_mixed_format_errors() {
2674 let src = r#"
2676[llm]
2677provider = "ollama"
2678
2679[[llm.providers]]
2680type = "ollama"
2681"#;
2682 assert!(
2683 migrate_llm_to_providers(src).is_err(),
2684 "mixed format must return error"
2685 );
2686 }
2687
2688 #[test]
2691 fn stt_migration_no_stt_section_returns_unchanged() {
2692 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2693 let result = migrate_stt_to_provider(src).unwrap();
2694 assert_eq!(result.added_count, 0);
2695 assert_eq!(result.output, src);
2696 }
2697
2698 #[test]
2699 fn stt_migration_no_model_or_base_url_returns_unchanged() {
2700 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2701 let result = migrate_stt_to_provider(src).unwrap();
2702 assert_eq!(result.added_count, 0);
2703 }
2704
2705 #[test]
2706 fn stt_migration_moves_model_to_provider_entry() {
2707 let src = r#"
2708[llm]
2709
2710[[llm.providers]]
2711type = "openai"
2712name = "quality"
2713model = "gpt-5.4"
2714
2715[llm.stt]
2716provider = "quality"
2717model = "gpt-4o-mini-transcribe"
2718language = "en"
2719"#;
2720 let result = migrate_stt_to_provider(src).unwrap();
2721 assert_eq!(result.added_count, 1);
2722 assert!(
2724 result.output.contains("stt_model"),
2725 "stt_model must be in output"
2726 );
2727 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2730 let stt = doc
2731 .get("llm")
2732 .and_then(toml_edit::Item::as_table)
2733 .and_then(|l| l.get("stt"))
2734 .and_then(toml_edit::Item::as_table)
2735 .unwrap();
2736 assert!(
2737 stt.get("model").is_none(),
2738 "model must be removed from [llm.stt]"
2739 );
2740 assert_eq!(
2741 stt.get("provider").and_then(toml_edit::Item::as_str),
2742 Some("quality")
2743 );
2744 }
2745
2746 #[test]
2747 fn stt_migration_creates_new_provider_when_no_match() {
2748 let src = r#"
2749[llm]
2750
2751[[llm.providers]]
2752type = "ollama"
2753name = "local"
2754model = "qwen3:8b"
2755
2756[llm.stt]
2757provider = "whisper"
2758model = "whisper-1"
2759base_url = "https://api.openai.com/v1"
2760language = "en"
2761"#;
2762 let result = migrate_stt_to_provider(src).unwrap();
2763 assert!(
2764 result.output.contains("openai-stt"),
2765 "new entry name must be openai-stt"
2766 );
2767 assert!(
2768 result.output.contains("stt_model"),
2769 "stt_model must be in output"
2770 );
2771 }
2772
2773 #[test]
2774 fn stt_migration_candle_whisper_creates_candle_entry() {
2775 let src = r#"
2776[llm]
2777
2778[llm.stt]
2779provider = "candle-whisper"
2780model = "openai/whisper-tiny"
2781language = "auto"
2782"#;
2783 let result = migrate_stt_to_provider(src).unwrap();
2784 assert!(
2785 result.output.contains("local-whisper"),
2786 "candle entry name must be local-whisper"
2787 );
2788 assert!(result.output.contains("candle"), "type must be candle");
2789 }
2790
2791 #[test]
2792 fn stt_migration_w2_assigns_explicit_name() {
2793 let src = r#"
2795[llm]
2796
2797[[llm.providers]]
2798type = "openai"
2799model = "gpt-5.4"
2800
2801[llm.stt]
2802provider = "openai"
2803model = "whisper-1"
2804language = "auto"
2805"#;
2806 let result = migrate_stt_to_provider(src).unwrap();
2807 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2808 let providers = doc
2809 .get("llm")
2810 .and_then(toml_edit::Item::as_table)
2811 .and_then(|l| l.get("providers"))
2812 .and_then(toml_edit::Item::as_array_of_tables)
2813 .unwrap();
2814 let entry = providers
2815 .iter()
2816 .find(|t| t.get("stt_model").is_some())
2817 .unwrap();
2818 assert!(
2820 entry.get("name").is_some(),
2821 "migrated entry must have explicit name"
2822 );
2823 }
2824
2825 #[test]
2826 fn stt_migration_removes_base_url_from_stt_table() {
2827 let src = r#"
2829[llm]
2830
2831[[llm.providers]]
2832type = "openai"
2833name = "quality"
2834model = "gpt-5.4"
2835
2836[llm.stt]
2837provider = "quality"
2838model = "whisper-1"
2839base_url = "https://api.openai.com/v1"
2840language = "en"
2841"#;
2842 let result = migrate_stt_to_provider(src).unwrap();
2843 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2844 let stt = doc
2845 .get("llm")
2846 .and_then(toml_edit::Item::as_table)
2847 .and_then(|l| l.get("stt"))
2848 .and_then(toml_edit::Item::as_table)
2849 .unwrap();
2850 assert!(
2851 stt.get("model").is_none(),
2852 "model must be removed from [llm.stt]"
2853 );
2854 assert!(
2855 stt.get("base_url").is_none(),
2856 "base_url must be removed from [llm.stt]"
2857 );
2858 }
2859
2860 #[test]
2861 fn migrate_planner_model_to_provider_with_field() {
2862 let input = r#"
2863[orchestration]
2864enabled = true
2865planner_model = "gpt-4o"
2866max_tasks = 20
2867"#;
2868 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2869 assert_eq!(result.added_count, 1, "added_count must be 1");
2870 assert!(
2871 !result.output.contains("planner_model = "),
2872 "planner_model key must be removed from output"
2873 );
2874 assert!(
2875 result.output.contains("# planner_provider"),
2876 "commented-out planner_provider entry must be present"
2877 );
2878 assert!(
2879 result.output.contains("gpt-4o"),
2880 "old value must appear in the comment"
2881 );
2882 assert!(
2883 result.output.contains("MIGRATED"),
2884 "comment must include MIGRATED marker"
2885 );
2886 }
2887
2888 #[test]
2889 fn migrate_planner_model_to_provider_no_op() {
2890 let input = r"
2891[orchestration]
2892enabled = true
2893max_tasks = 20
2894";
2895 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2896 assert_eq!(
2897 result.added_count, 0,
2898 "added_count must be 0 when field is absent"
2899 );
2900 assert_eq!(
2901 result.output, input,
2902 "output must equal input when nothing to migrate"
2903 );
2904 }
2905
2906 #[test]
2907 fn migrate_error_invalid_structure_formats_correctly() {
2908 let err = MigrateError::InvalidStructure("test sentinel");
2913 assert!(
2914 matches!(err, MigrateError::InvalidStructure(_)),
2915 "variant must match"
2916 );
2917 let msg = err.to_string();
2918 assert!(
2919 msg.contains("invalid TOML structure"),
2920 "error message must mention 'invalid TOML structure', got: {msg}"
2921 );
2922 assert!(
2923 msg.contains("test sentinel"),
2924 "message must include reason: {msg}"
2925 );
2926 }
2927
2928 #[test]
2931 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2932 let src = r#"
2933[mcp]
2934allowed_commands = ["npx"]
2935
2936[[mcp.servers]]
2937id = "srv-a"
2938command = "npx"
2939args = ["-y", "some-mcp"]
2940
2941[[mcp.servers]]
2942id = "srv-b"
2943command = "npx"
2944args = ["-y", "other-mcp"]
2945"#;
2946 let result = migrate_mcp_trust_levels(src).expect("migrate");
2947 assert_eq!(
2948 result.added_count, 2,
2949 "both entries must get trust_level added"
2950 );
2951 assert!(
2952 result
2953 .sections_added
2954 .contains(&"mcp.servers.trust_level".to_owned()),
2955 "sections_added must report mcp.servers.trust_level"
2956 );
2957 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2959 assert_eq!(
2960 occurrences, 2,
2961 "each entry must have trust_level = \"trusted\""
2962 );
2963 }
2964
2965 #[test]
2966 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2967 let src = r#"
2968[[mcp.servers]]
2969id = "srv-a"
2970command = "npx"
2971trust_level = "sandboxed"
2972tool_allowlist = ["read_file"]
2973
2974[[mcp.servers]]
2975id = "srv-b"
2976command = "npx"
2977"#;
2978 let result = migrate_mcp_trust_levels(src).expect("migrate");
2979 assert_eq!(
2981 result.added_count, 1,
2982 "only entry without trust_level gets updated"
2983 );
2984 assert!(
2986 result.output.contains("trust_level = \"sandboxed\""),
2987 "existing trust_level must not be overwritten"
2988 );
2989 assert!(
2991 result.output.contains("trust_level = \"trusted\""),
2992 "entry without trust_level must get trusted"
2993 );
2994 }
2995
2996 #[test]
2997 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2998 let src = "[agent]\nname = \"Zeph\"\n";
2999 let result = migrate_mcp_trust_levels(src).expect("migrate");
3000 assert_eq!(result.added_count, 0);
3001 assert!(result.sections_added.is_empty());
3002 assert_eq!(result.output, src);
3003 }
3004
3005 #[test]
3006 fn migrate_mcp_trust_levels_no_servers_is_noop() {
3007 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
3008 let result = migrate_mcp_trust_levels(src).expect("migrate");
3009 assert_eq!(result.added_count, 0);
3010 assert!(result.sections_added.is_empty());
3011 assert_eq!(result.output, src);
3012 }
3013
3014 #[test]
3015 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
3016 let src = r#"
3017[[mcp.servers]]
3018id = "srv-a"
3019trust_level = "trusted"
3020
3021[[mcp.servers]]
3022id = "srv-b"
3023trust_level = "untrusted"
3024"#;
3025 let result = migrate_mcp_trust_levels(src).expect("migrate");
3026 assert_eq!(result.added_count, 0);
3027 assert!(result.sections_added.is_empty());
3028 }
3029
3030 #[test]
3031 fn migrate_database_url_adds_comment_when_absent() {
3032 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
3033 let result = migrate_database_url(src).expect("migrate");
3034 assert_eq!(result.added_count, 1);
3035 assert!(
3036 result
3037 .sections_added
3038 .contains(&"memory.database_url".to_owned())
3039 );
3040 assert!(result.output.contains("# database_url = \"\""));
3041 }
3042
3043 #[test]
3044 fn migrate_database_url_is_noop_when_present() {
3045 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
3046 let result = migrate_database_url(src).expect("migrate");
3047 assert_eq!(result.added_count, 0);
3048 assert!(result.sections_added.is_empty());
3049 assert_eq!(result.output, src);
3050 }
3051
3052 #[test]
3053 fn migrate_database_url_creates_memory_section_when_absent() {
3054 let src = "[agent]\nname = \"Zeph\"\n";
3055 let result = migrate_database_url(src).expect("migrate");
3056 assert_eq!(result.added_count, 1);
3057 assert!(result.output.contains("# database_url = \"\""));
3058 }
3059
3060 #[test]
3063 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
3064 let src = "[agent]\nname = \"Zeph\"\n";
3065 let result = migrate_agent_budget_hint(src).expect("migrate");
3066 assert_eq!(result.added_count, 1);
3067 assert!(result.output.contains("budget_hint_enabled"));
3068 assert!(
3069 result
3070 .sections_added
3071 .contains(&"agent.budget_hint_enabled".to_owned())
3072 );
3073 }
3074
3075 #[test]
3076 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
3077 let src = "[llm]\nmodel = \"gpt-4o\"\n";
3078 let result = migrate_agent_budget_hint(src).expect("migrate");
3079 assert_eq!(result.added_count, 0);
3080 assert_eq!(result.output, src);
3081 }
3082
3083 #[test]
3084 fn migrate_agent_budget_hint_already_present_is_noop() {
3085 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
3086 let result = migrate_agent_budget_hint(src).expect("migrate");
3087 assert_eq!(result.added_count, 0);
3088 assert_eq!(result.output, src);
3089 }
3090
3091 #[test]
3092 fn migrate_telemetry_config_empty_config_appends_comment_block() {
3093 let src = "[agent]\nname = \"Zeph\"\n";
3094 let result = migrate_telemetry_config(src).expect("migrate");
3095 assert_eq!(result.added_count, 1);
3096 assert_eq!(result.sections_added, vec!["telemetry"]);
3097 assert!(
3098 result.output.contains("# [telemetry]"),
3099 "expected commented-out [telemetry] block in output"
3100 );
3101 assert!(
3102 result.output.contains("enabled = false"),
3103 "expected enabled = false in telemetry comment block"
3104 );
3105 }
3106
3107 #[test]
3108 fn migrate_telemetry_config_existing_section_is_noop() {
3109 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
3110 let result = migrate_telemetry_config(src).expect("migrate");
3111 assert_eq!(result.added_count, 0);
3112 assert_eq!(result.output, src);
3113 }
3114
3115 #[test]
3116 fn migrate_telemetry_config_existing_comment_is_noop() {
3117 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
3119 let result = migrate_telemetry_config(src).expect("migrate");
3120 assert_eq!(result.added_count, 0);
3121 assert_eq!(result.output, src);
3122 }
3123
3124 #[test]
3127 fn migrate_otel_filter_already_present_is_noop() {
3128 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
3130 let result = migrate_otel_filter(src).expect("migrate");
3131 assert_eq!(result.added_count, 0);
3132 assert_eq!(result.output, src);
3133 }
3134
3135 #[test]
3136 fn migrate_otel_filter_commented_key_is_noop() {
3137 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
3139 let result = migrate_otel_filter(src).expect("migrate");
3140 assert_eq!(result.added_count, 0);
3141 assert_eq!(result.output, src);
3142 }
3143
3144 #[test]
3145 fn migrate_otel_filter_no_telemetry_section_is_noop() {
3146 let src = "[agent]\nname = \"Zeph\"\n";
3148 let result = migrate_otel_filter(src).expect("migrate");
3149 assert_eq!(result.added_count, 0);
3150 assert_eq!(result.output, src);
3151 assert!(!result.output.contains("otel_filter"));
3152 }
3153
3154 #[test]
3155 fn migrate_otel_filter_injects_within_telemetry_section() {
3156 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
3157 let result = migrate_otel_filter(src).expect("migrate");
3158 assert_eq!(result.added_count, 1);
3159 assert_eq!(result.sections_added, vec!["telemetry.otel_filter"]);
3160 assert!(
3161 result.output.contains("otel_filter"),
3162 "otel_filter comment must appear"
3163 );
3164 let otel_pos = result
3166 .output
3167 .find("otel_filter")
3168 .expect("otel_filter present");
3169 let agent_pos = result.output.find("[agent]").expect("[agent] present");
3170 assert!(
3171 otel_pos < agent_pos,
3172 "otel_filter comment should appear before [agent] section"
3173 );
3174 }
3175
3176 #[test]
3177 fn sandbox_migration_adds_commented_section_when_absent() {
3178 let src = "[agent]\nname = \"Z\"\n";
3179 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3180 assert_eq!(result.added_count, 1);
3181 assert!(result.output.contains("# [tools.sandbox]"));
3182 assert!(result.output.contains("# profile = \"workspace\""));
3183 }
3184
3185 #[test]
3186 fn sandbox_migration_noop_when_section_present() {
3187 let src = "[tools.sandbox]\nenabled = true\n";
3188 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3189 assert_eq!(result.added_count, 0);
3190 }
3191
3192 #[test]
3193 fn sandbox_migration_noop_when_dotted_key_present() {
3194 let src = "[tools]\nsandbox = { enabled = true }\n";
3195 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3196 assert_eq!(result.added_count, 0);
3197 }
3198
3199 #[test]
3200 fn sandbox_migration_false_positive_comment_does_not_block() {
3201 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
3203 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3204 assert_eq!(result.added_count, 1);
3205 }
3206
3207 #[test]
3208 fn embedded_default_mentions_tools_sandbox() {
3209 let default_src = include_str!("../config/default.toml");
3210 assert!(
3211 default_src.contains("tools.sandbox"),
3212 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
3213 );
3214 }
3215
3216 #[test]
3217 fn sandbox_migration_idempotent_on_own_output() {
3218 let base = "[agent]\nmodel = \"test\"\n";
3219 let first = migrate_sandbox_config(base).unwrap();
3220 assert_eq!(first.added_count, 1);
3221 let second = migrate_sandbox_config(&first.output).unwrap();
3222 assert_eq!(second.added_count, 0, "second run must not double-append");
3223 assert_eq!(second.output, first.output);
3224 }
3225
3226 #[test]
3227 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
3228 let base = "[agent]\nname = \"Zeph\"\n";
3229 let first = migrate_agent_budget_hint(base).unwrap();
3230 assert_eq!(first.added_count, 1);
3231 let second = migrate_agent_budget_hint(&first.output).unwrap();
3232 assert_eq!(second.added_count, 0, "second run must not double-append");
3233 assert_eq!(second.output, first.output);
3234 }
3235
3236 #[test]
3237 fn migrate_forgetting_config_idempotent_on_commented_output() {
3238 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3239 let first = migrate_forgetting_config(base).unwrap();
3240 assert_eq!(first.added_count, 1);
3241 let second = migrate_forgetting_config(&first.output).unwrap();
3242 assert_eq!(second.added_count, 0, "second run must not double-append");
3243 assert_eq!(second.output, first.output);
3244 }
3245
3246 #[test]
3247 fn migrate_microcompact_config_idempotent_on_commented_output() {
3248 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3249 let first = migrate_microcompact_config(base).unwrap();
3250 assert_eq!(first.added_count, 1);
3251 let second = migrate_microcompact_config(&first.output).unwrap();
3252 assert_eq!(second.added_count, 0, "second run must not double-append");
3253 assert_eq!(second.output, first.output);
3254 }
3255
3256 #[test]
3257 fn migrate_autodream_config_idempotent_on_commented_output() {
3258 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3259 let first = migrate_autodream_config(base).unwrap();
3260 assert_eq!(first.added_count, 1);
3261 let second = migrate_autodream_config(&first.output).unwrap();
3262 assert_eq!(second.added_count, 0, "second run must not double-append");
3263 assert_eq!(second.output, first.output);
3264 }
3265
3266 #[test]
3267 fn migrate_compression_predictor_idempotent_on_commented_output() {
3268 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3269 let first = migrate_compression_predictor_config(base).unwrap();
3270 assert_eq!(first.added_count, 1);
3271 let second = migrate_compression_predictor_config(&first.output).unwrap();
3272 assert_eq!(second.added_count, 0, "second run must not double-append");
3273 assert_eq!(second.output, first.output);
3274 }
3275
3276 #[test]
3277 fn migrate_database_url_idempotent_on_commented_output() {
3278 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3279 let first = migrate_database_url(base).unwrap();
3280 assert_eq!(first.added_count, 1);
3281 let second = migrate_database_url(&first.output).unwrap();
3282 assert_eq!(second.added_count, 0, "second run must not double-append");
3283 assert_eq!(second.output, first.output);
3284 }
3285
3286 #[test]
3287 fn migrate_shell_transactional_idempotent_on_commented_output() {
3288 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
3289 let first = migrate_shell_transactional(base).unwrap();
3290 assert_eq!(first.added_count, 1);
3291 let second = migrate_shell_transactional(&first.output).unwrap();
3292 assert_eq!(second.added_count, 0, "second run must not double-append");
3293 assert_eq!(second.output, first.output);
3294 }
3295
3296 #[test]
3297 fn migrate_otel_filter_idempotent_on_commented_output() {
3298 let base = "[telemetry]\nenabled = true\n";
3299 let first = migrate_otel_filter(base).unwrap();
3300 assert_eq!(first.added_count, 1);
3301 let second = migrate_otel_filter(&first.output).unwrap();
3302 assert_eq!(second.added_count, 0, "second run must not double-append");
3303 assert_eq!(second.output, first.output);
3304 }
3305
3306 #[test]
3307 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
3308 let migrator = ConfigMigrator::new();
3309 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
3310 let result = migrator.migrate(src).expect("migrate");
3311 let sec_body_start = result
3312 .output
3313 .find("[security.content_isolation]")
3314 .unwrap_or(0);
3315 let sec_body = &result.output[sec_body_start..];
3316 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
3317 let sec_slice = &sec_body[..next_header];
3318 assert!(
3319 sec_slice.contains("# enabled"),
3320 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
3321 );
3322 }
3323
3324 #[test]
3325 fn config_migrator_idempotent_on_realistic_config() {
3326 let base = r#"
3327[agent]
3328name = "Zeph"
3329
3330[memory]
3331db_path = "~/.zeph/memory.db"
3332soft_compaction_threshold = 0.6
3333
3334[index]
3335max_chunks = 12
3336
3337[tools]
3338[tools.shell]
3339allow_list = []
3340
3341[telemetry]
3342enabled = false
3343
3344[security]
3345[security.content_isolation]
3346enabled = true
3347"#;
3348 let migrator = ConfigMigrator::new();
3349 let first = migrator.migrate(base).expect("first migrate");
3350 let second = migrator.migrate(&first.output).expect("second migrate");
3351 assert_eq!(
3352 second.added_count, 0,
3353 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
3354 second.added_count
3355 );
3356 assert_eq!(
3357 first.output, second.output,
3358 "output must be identical on second run"
3359 );
3360 for line in first.output.lines() {
3361 if line.starts_with('[') && !line.starts_with("[[") {
3362 assert!(
3363 !line.contains('#'),
3364 "section header must not have inline comment: {line:?}"
3365 );
3366 }
3367 }
3368 }
3369
3370 #[test]
3371 fn migrate_claude_prompt_cache_ttl_1h_survives() {
3372 let src = r#"
3373[llm]
3374provider = "claude"
3375
3376[llm.cloud]
3377model = "claude-sonnet-4-6"
3378prompt_cache_ttl = "1h"
3379"#;
3380 let result = migrate_llm_to_providers(src).expect("migrate");
3381 assert!(
3382 result.output.contains("prompt_cache_ttl = \"1h\""),
3383 "1h TTL must be preserved in migrated output:\n{}",
3384 result.output
3385 );
3386 }
3387
3388 #[test]
3389 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
3390 let src = r#"
3391[llm]
3392provider = "claude"
3393
3394[llm.cloud]
3395model = "claude-sonnet-4-6"
3396prompt_cache_ttl = "ephemeral"
3397"#;
3398 let result = migrate_llm_to_providers(src).expect("migrate");
3399 assert!(
3400 !result.output.contains("prompt_cache_ttl"),
3401 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
3402 result.output
3403 );
3404 }
3405
3406 #[test]
3407 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
3408 let src = r#"
3409[[llm.providers]]
3410type = "claude"
3411model = "claude-sonnet-4-6"
3412prompt_cache_ttl = "1h"
3413"#;
3414 let migrator = ConfigMigrator::new();
3415 let first = migrator.migrate(src).expect("first migrate");
3416 let second = migrator.migrate(&first.output).expect("second migrate");
3417 assert_eq!(
3418 first.output, second.output,
3419 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
3420 );
3421 }
3422
3423 #[test]
3426 fn migrate_session_recap_adds_block_when_absent() {
3427 let src = "[agent]\nname = \"Zeph\"\n";
3428 let result = migrate_session_recap_config(src).expect("migrate");
3429 assert_eq!(result.added_count, 1);
3430 assert!(result.sections_added.contains(&"session.recap".to_owned()));
3431 assert!(result.output.contains("# [session.recap]"));
3432 assert!(result.output.contains("on_resume = true"));
3433 }
3434
3435 #[test]
3436 fn migrate_session_recap_idempotent_on_commented_block() {
3437 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
3438 let result = migrate_session_recap_config(src).expect("migrate");
3439 assert_eq!(result.added_count, 0);
3440 assert_eq!(result.output, src);
3441 }
3442
3443 #[test]
3444 fn migrate_session_recap_idempotent_on_active_section() {
3445 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
3446 let result = migrate_session_recap_config(src).expect("migrate");
3447 assert_eq!(result.added_count, 0);
3448 assert_eq!(result.output, src);
3449 }
3450
3451 #[test]
3454 fn migrate_mcp_elicitation_adds_keys_when_absent() {
3455 let src = "[mcp]\nallowed_commands = []\n";
3456 let result = migrate_mcp_elicitation_config(src).expect("migrate");
3457 assert_eq!(result.added_count, 1);
3458 assert!(
3459 result
3460 .sections_added
3461 .contains(&"mcp.elicitation".to_owned())
3462 );
3463 assert!(result.output.contains("# elicitation_enabled = false"));
3464 assert!(result.output.contains("# elicitation_timeout = 120"));
3465 }
3466
3467 #[test]
3468 fn migrate_mcp_elicitation_idempotent_when_key_present() {
3469 let src = "[mcp]\nelicitation_enabled = true\n";
3470 let result = migrate_mcp_elicitation_config(src).expect("migrate");
3471 assert_eq!(result.added_count, 0);
3472 assert_eq!(result.output, src);
3473 }
3474
3475 #[test]
3476 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
3477 let src = "[agent]\nname = \"Zeph\"\n";
3478 let result = migrate_mcp_elicitation_config(src).expect("migrate");
3479 assert_eq!(result.added_count, 0);
3480 assert_eq!(result.output, src);
3481 }
3482
3483 #[test]
3484 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
3485 let src = "[mcp]";
3487 let result = migrate_mcp_elicitation_config(src).expect("migrate");
3488 assert_eq!(result.added_count, 0);
3489 assert_eq!(result.output, src);
3490 }
3491
3492 #[test]
3495 fn migrate_quality_adds_block_when_absent() {
3496 let src = "[agent]\nname = \"Zeph\"\n";
3497 let result = migrate_quality_config(src).expect("migrate");
3498 assert_eq!(result.added_count, 1);
3499 assert!(result.sections_added.contains(&"quality".to_owned()));
3500 assert!(result.output.contains("# [quality]"));
3501 assert!(result.output.contains("self_check = false"));
3502 assert!(result.output.contains("trigger = \"has_retrieval\""));
3503 }
3504
3505 #[test]
3506 fn migrate_quality_idempotent_on_commented_block() {
3507 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
3508 let result = migrate_quality_config(src).expect("migrate");
3509 assert_eq!(result.added_count, 0);
3510 assert_eq!(result.output, src);
3511 }
3512
3513 #[test]
3514 fn migrate_quality_idempotent_on_active_section() {
3515 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
3516 let result = migrate_quality_config(src).expect("migrate");
3517 assert_eq!(result.added_count, 0);
3518 assert_eq!(result.output, src);
3519 }
3520}