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
2107#[cfg(test)]
2109fn make_formatted_str(s: &str) -> Value {
2110 use toml_edit::Formatted;
2111 Value::String(Formatted::new(s.to_owned()))
2112}
2113
2114#[cfg(test)]
2115mod tests {
2116 use super::*;
2117
2118 #[test]
2119 fn empty_config_gets_sections_as_comments() {
2120 let migrator = ConfigMigrator::new();
2121 let result = migrator.migrate("").expect("migrate empty");
2122 assert!(result.added_count > 0 || !result.sections_added.is_empty());
2124 assert!(
2126 result.output.contains("[agent]") || result.output.contains("# [agent]"),
2127 "expected agent section in output, got:\n{}",
2128 result.output
2129 );
2130 }
2131
2132 #[test]
2133 fn existing_values_not_overwritten() {
2134 let user = r#"
2135[agent]
2136name = "MyAgent"
2137max_tool_iterations = 5
2138"#;
2139 let migrator = ConfigMigrator::new();
2140 let result = migrator.migrate(user).expect("migrate");
2141 assert!(
2143 result.output.contains("name = \"MyAgent\""),
2144 "user value should be preserved"
2145 );
2146 assert!(
2147 result.output.contains("max_tool_iterations = 5"),
2148 "user value should be preserved"
2149 );
2150 assert!(
2152 !result.output.contains("# max_tool_iterations = 10"),
2153 "already-set key should not appear as comment"
2154 );
2155 }
2156
2157 #[test]
2158 fn missing_nested_key_added_as_comment() {
2159 let user = r#"
2161[memory]
2162sqlite_path = ".zeph/data/zeph.db"
2163"#;
2164 let migrator = ConfigMigrator::new();
2165 let result = migrator.migrate(user).expect("migrate");
2166 assert!(
2168 result.output.contains("# history_limit"),
2169 "missing key should be added as comment, got:\n{}",
2170 result.output
2171 );
2172 }
2173
2174 #[test]
2175 fn unknown_user_keys_preserved() {
2176 let user = r#"
2177[agent]
2178name = "Test"
2179my_custom_key = "preserved"
2180"#;
2181 let migrator = ConfigMigrator::new();
2182 let result = migrator.migrate(user).expect("migrate");
2183 assert!(
2184 result.output.contains("my_custom_key = \"preserved\""),
2185 "custom user keys must not be removed"
2186 );
2187 }
2188
2189 #[test]
2190 fn idempotent() {
2191 let migrator = ConfigMigrator::new();
2192 let first = migrator
2193 .migrate("[agent]\nname = \"Zeph\"\n")
2194 .expect("first migrate");
2195 let second = migrator.migrate(&first.output).expect("second migrate");
2196 assert_eq!(
2197 first.output, second.output,
2198 "idempotent: full output must be identical on second run"
2199 );
2200 }
2201
2202 #[test]
2203 fn malformed_input_returns_error() {
2204 let migrator = ConfigMigrator::new();
2205 let err = migrator
2206 .migrate("[[invalid toml [[[")
2207 .expect_err("should error");
2208 assert!(
2209 matches!(err, MigrateError::Parse(_)),
2210 "expected Parse error"
2211 );
2212 }
2213
2214 #[test]
2215 fn array_of_tables_preserved() {
2216 let user = r#"
2217[mcp]
2218allowed_commands = ["npx"]
2219
2220[[mcp.servers]]
2221id = "my-server"
2222command = "npx"
2223args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
2224"#;
2225 let migrator = ConfigMigrator::new();
2226 let result = migrator.migrate(user).expect("migrate");
2227 assert!(
2229 result.output.contains("[[mcp.servers]]"),
2230 "array-of-tables entries must be preserved"
2231 );
2232 assert!(result.output.contains("id = \"my-server\""));
2233 }
2234
2235 #[test]
2236 fn canonical_ordering_applied() {
2237 let user = r#"
2239[memory]
2240sqlite_path = ".zeph/data/zeph.db"
2241
2242[agent]
2243name = "Test"
2244"#;
2245 let migrator = ConfigMigrator::new();
2246 let result = migrator.migrate(user).expect("migrate");
2247 let agent_pos = result.output.find("[agent]");
2249 let memory_pos = result.output.find("[memory]");
2250 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
2251 assert!(a < m, "agent section should precede memory section");
2252 }
2253 }
2254
2255 #[test]
2256 fn value_to_toml_string_formats_correctly() {
2257 use toml_edit::Formatted;
2258
2259 let s = make_formatted_str("hello");
2260 assert_eq!(value_to_toml_string(&s), "\"hello\"");
2261
2262 let i = Value::Integer(Formatted::new(42_i64));
2263 assert_eq!(value_to_toml_string(&i), "42");
2264
2265 let b = Value::Boolean(Formatted::new(true));
2266 assert_eq!(value_to_toml_string(&b), "true");
2267
2268 let f = Value::Float(Formatted::new(1.0_f64));
2269 assert_eq!(value_to_toml_string(&f), "1.0");
2270
2271 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
2272 assert_eq!(value_to_toml_string(&f2), "3.14");
2273
2274 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
2275 let arr_val = Value::Array(arr);
2276 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
2277
2278 let empty_arr = Value::Array(Array::new());
2279 assert_eq!(value_to_toml_string(&empty_arr), "[]");
2280 }
2281
2282 #[test]
2283 fn idempotent_full_output_unchanged() {
2284 let migrator = ConfigMigrator::new();
2286 let first = migrator
2287 .migrate("[agent]\nname = \"Zeph\"\n")
2288 .expect("first migrate");
2289 let second = migrator.migrate(&first.output).expect("second migrate");
2290 assert_eq!(
2291 first.output, second.output,
2292 "full output string must be identical after second migration pass"
2293 );
2294 }
2295
2296 #[test]
2297 fn full_config_produces_zero_additions() {
2298 let reference = include_str!("../config/default.toml");
2300 let migrator = ConfigMigrator::new();
2301 let result = migrator.migrate(reference).expect("migrate reference");
2302 assert_eq!(
2303 result.added_count, 0,
2304 "migrating the canonical reference should add nothing (added_count = {})",
2305 result.added_count
2306 );
2307 assert!(
2308 result.sections_added.is_empty(),
2309 "migrating the canonical reference should report no sections_added: {:?}",
2310 result.sections_added
2311 );
2312 }
2313
2314 #[test]
2315 fn empty_config_added_count_is_positive() {
2316 let migrator = ConfigMigrator::new();
2318 let result = migrator.migrate("").expect("migrate empty");
2319 assert!(
2320 result.added_count > 0,
2321 "empty config must report added_count > 0"
2322 );
2323 }
2324
2325 #[test]
2328 fn security_without_guardrail_gets_guardrail_commented() {
2329 let user = "[security]\nredact_secrets = true\n";
2330 let migrator = ConfigMigrator::new();
2331 let result = migrator.migrate(user).expect("migrate");
2332 assert!(
2334 result.output.contains("guardrail"),
2335 "migration must add guardrail keys for configs without [security.guardrail]: \
2336 got:\n{}",
2337 result.output
2338 );
2339 }
2340
2341 #[test]
2342 fn migrate_reference_contains_tools_policy() {
2343 let reference = include_str!("../config/default.toml");
2348 assert!(
2349 reference.contains("[tools.policy]"),
2350 "default.toml must contain [tools.policy] section so migrate-config can surface it"
2351 );
2352 assert!(
2353 reference.contains("enabled = false"),
2354 "tools.policy section must include enabled = false default"
2355 );
2356 }
2357
2358 #[test]
2359 fn migrate_reference_contains_probe_section() {
2360 let reference = include_str!("../config/default.toml");
2363 assert!(
2364 reference.contains("[memory.compression.probe]"),
2365 "default.toml must contain [memory.compression.probe] section comment"
2366 );
2367 assert!(
2368 reference.contains("hard_fail_threshold"),
2369 "probe section must include hard_fail_threshold default"
2370 );
2371 }
2372
2373 #[test]
2376 fn migrate_llm_no_llm_section_is_noop() {
2377 let src = "[agent]\nname = \"Zeph\"\n";
2378 let result = migrate_llm_to_providers(src).expect("migrate");
2379 assert_eq!(result.added_count, 0);
2380 assert_eq!(result.output, src);
2381 }
2382
2383 #[test]
2384 fn migrate_llm_already_new_format_is_noop() {
2385 let src = r#"
2386[llm]
2387[[llm.providers]]
2388type = "ollama"
2389model = "qwen3:8b"
2390"#;
2391 let result = migrate_llm_to_providers(src).expect("migrate");
2392 assert_eq!(result.added_count, 0);
2393 }
2394
2395 #[test]
2396 fn migrate_llm_ollama_produces_providers_block() {
2397 let src = r#"
2398[llm]
2399provider = "ollama"
2400model = "qwen3:8b"
2401base_url = "http://localhost:11434"
2402embedding_model = "nomic-embed-text"
2403"#;
2404 let result = migrate_llm_to_providers(src).expect("migrate");
2405 assert!(
2406 result.output.contains("[[llm.providers]]"),
2407 "should contain [[llm.providers]]:\n{}",
2408 result.output
2409 );
2410 assert!(
2411 result.output.contains("type = \"ollama\""),
2412 "{}",
2413 result.output
2414 );
2415 assert!(
2416 result.output.contains("model = \"qwen3:8b\""),
2417 "{}",
2418 result.output
2419 );
2420 }
2421
2422 #[test]
2423 fn migrate_llm_claude_produces_providers_block() {
2424 let src = r#"
2425[llm]
2426provider = "claude"
2427
2428[llm.cloud]
2429model = "claude-sonnet-4-6"
2430max_tokens = 8192
2431server_compaction = true
2432"#;
2433 let result = migrate_llm_to_providers(src).expect("migrate");
2434 assert!(
2435 result.output.contains("[[llm.providers]]"),
2436 "{}",
2437 result.output
2438 );
2439 assert!(
2440 result.output.contains("type = \"claude\""),
2441 "{}",
2442 result.output
2443 );
2444 assert!(
2445 result.output.contains("model = \"claude-sonnet-4-6\""),
2446 "{}",
2447 result.output
2448 );
2449 assert!(
2450 result.output.contains("server_compaction = true"),
2451 "{}",
2452 result.output
2453 );
2454 }
2455
2456 #[test]
2457 fn migrate_llm_openai_copies_fields() {
2458 let src = r#"
2459[llm]
2460provider = "openai"
2461
2462[llm.openai]
2463base_url = "https://api.openai.com/v1"
2464model = "gpt-4o"
2465max_tokens = 4096
2466"#;
2467 let result = migrate_llm_to_providers(src).expect("migrate");
2468 assert!(
2469 result.output.contains("type = \"openai\""),
2470 "{}",
2471 result.output
2472 );
2473 assert!(
2474 result
2475 .output
2476 .contains("base_url = \"https://api.openai.com/v1\""),
2477 "{}",
2478 result.output
2479 );
2480 }
2481
2482 #[test]
2483 fn migrate_llm_gemini_copies_fields() {
2484 let src = r#"
2485[llm]
2486provider = "gemini"
2487
2488[llm.gemini]
2489model = "gemini-2.0-flash"
2490max_tokens = 8192
2491base_url = "https://generativelanguage.googleapis.com"
2492"#;
2493 let result = migrate_llm_to_providers(src).expect("migrate");
2494 assert!(
2495 result.output.contains("type = \"gemini\""),
2496 "{}",
2497 result.output
2498 );
2499 assert!(
2500 result.output.contains("model = \"gemini-2.0-flash\""),
2501 "{}",
2502 result.output
2503 );
2504 }
2505
2506 #[test]
2507 fn migrate_llm_compatible_copies_multiple_entries() {
2508 let src = r#"
2509[llm]
2510provider = "compatible"
2511
2512[[llm.compatible]]
2513name = "proxy-a"
2514base_url = "http://proxy-a:8080/v1"
2515model = "llama3"
2516max_tokens = 4096
2517
2518[[llm.compatible]]
2519name = "proxy-b"
2520base_url = "http://proxy-b:8080/v1"
2521model = "mistral"
2522max_tokens = 2048
2523"#;
2524 let result = migrate_llm_to_providers(src).expect("migrate");
2525 let count = result.output.matches("[[llm.providers]]").count();
2527 assert_eq!(
2528 count, 2,
2529 "expected 2 [[llm.providers]] blocks:\n{}",
2530 result.output
2531 );
2532 assert!(
2533 result.output.contains("name = \"proxy-a\""),
2534 "{}",
2535 result.output
2536 );
2537 assert!(
2538 result.output.contains("name = \"proxy-b\""),
2539 "{}",
2540 result.output
2541 );
2542 }
2543
2544 #[test]
2545 fn migrate_llm_mixed_format_errors() {
2546 let src = r#"
2548[llm]
2549provider = "ollama"
2550
2551[[llm.providers]]
2552type = "ollama"
2553"#;
2554 assert!(
2555 migrate_llm_to_providers(src).is_err(),
2556 "mixed format must return error"
2557 );
2558 }
2559
2560 #[test]
2563 fn stt_migration_no_stt_section_returns_unchanged() {
2564 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
2565 let result = migrate_stt_to_provider(src).unwrap();
2566 assert_eq!(result.added_count, 0);
2567 assert_eq!(result.output, src);
2568 }
2569
2570 #[test]
2571 fn stt_migration_no_model_or_base_url_returns_unchanged() {
2572 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
2573 let result = migrate_stt_to_provider(src).unwrap();
2574 assert_eq!(result.added_count, 0);
2575 }
2576
2577 #[test]
2578 fn stt_migration_moves_model_to_provider_entry() {
2579 let src = r#"
2580[llm]
2581
2582[[llm.providers]]
2583type = "openai"
2584name = "quality"
2585model = "gpt-5.4"
2586
2587[llm.stt]
2588provider = "quality"
2589model = "gpt-4o-mini-transcribe"
2590language = "en"
2591"#;
2592 let result = migrate_stt_to_provider(src).unwrap();
2593 assert_eq!(result.added_count, 1);
2594 assert!(
2596 result.output.contains("stt_model"),
2597 "stt_model must be in output"
2598 );
2599 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2602 let stt = doc
2603 .get("llm")
2604 .and_then(toml_edit::Item::as_table)
2605 .and_then(|l| l.get("stt"))
2606 .and_then(toml_edit::Item::as_table)
2607 .unwrap();
2608 assert!(
2609 stt.get("model").is_none(),
2610 "model must be removed from [llm.stt]"
2611 );
2612 assert_eq!(
2613 stt.get("provider").and_then(toml_edit::Item::as_str),
2614 Some("quality")
2615 );
2616 }
2617
2618 #[test]
2619 fn stt_migration_creates_new_provider_when_no_match() {
2620 let src = r#"
2621[llm]
2622
2623[[llm.providers]]
2624type = "ollama"
2625name = "local"
2626model = "qwen3:8b"
2627
2628[llm.stt]
2629provider = "whisper"
2630model = "whisper-1"
2631base_url = "https://api.openai.com/v1"
2632language = "en"
2633"#;
2634 let result = migrate_stt_to_provider(src).unwrap();
2635 assert!(
2636 result.output.contains("openai-stt"),
2637 "new entry name must be openai-stt"
2638 );
2639 assert!(
2640 result.output.contains("stt_model"),
2641 "stt_model must be in output"
2642 );
2643 }
2644
2645 #[test]
2646 fn stt_migration_candle_whisper_creates_candle_entry() {
2647 let src = r#"
2648[llm]
2649
2650[llm.stt]
2651provider = "candle-whisper"
2652model = "openai/whisper-tiny"
2653language = "auto"
2654"#;
2655 let result = migrate_stt_to_provider(src).unwrap();
2656 assert!(
2657 result.output.contains("local-whisper"),
2658 "candle entry name must be local-whisper"
2659 );
2660 assert!(result.output.contains("candle"), "type must be candle");
2661 }
2662
2663 #[test]
2664 fn stt_migration_w2_assigns_explicit_name() {
2665 let src = r#"
2667[llm]
2668
2669[[llm.providers]]
2670type = "openai"
2671model = "gpt-5.4"
2672
2673[llm.stt]
2674provider = "openai"
2675model = "whisper-1"
2676language = "auto"
2677"#;
2678 let result = migrate_stt_to_provider(src).unwrap();
2679 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2680 let providers = doc
2681 .get("llm")
2682 .and_then(toml_edit::Item::as_table)
2683 .and_then(|l| l.get("providers"))
2684 .and_then(toml_edit::Item::as_array_of_tables)
2685 .unwrap();
2686 let entry = providers
2687 .iter()
2688 .find(|t| t.get("stt_model").is_some())
2689 .unwrap();
2690 assert!(
2692 entry.get("name").is_some(),
2693 "migrated entry must have explicit name"
2694 );
2695 }
2696
2697 #[test]
2698 fn stt_migration_removes_base_url_from_stt_table() {
2699 let src = r#"
2701[llm]
2702
2703[[llm.providers]]
2704type = "openai"
2705name = "quality"
2706model = "gpt-5.4"
2707
2708[llm.stt]
2709provider = "quality"
2710model = "whisper-1"
2711base_url = "https://api.openai.com/v1"
2712language = "en"
2713"#;
2714 let result = migrate_stt_to_provider(src).unwrap();
2715 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
2716 let stt = doc
2717 .get("llm")
2718 .and_then(toml_edit::Item::as_table)
2719 .and_then(|l| l.get("stt"))
2720 .and_then(toml_edit::Item::as_table)
2721 .unwrap();
2722 assert!(
2723 stt.get("model").is_none(),
2724 "model must be removed from [llm.stt]"
2725 );
2726 assert!(
2727 stt.get("base_url").is_none(),
2728 "base_url must be removed from [llm.stt]"
2729 );
2730 }
2731
2732 #[test]
2733 fn migrate_planner_model_to_provider_with_field() {
2734 let input = r#"
2735[orchestration]
2736enabled = true
2737planner_model = "gpt-4o"
2738max_tasks = 20
2739"#;
2740 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2741 assert_eq!(result.added_count, 1, "added_count must be 1");
2742 assert!(
2743 !result.output.contains("planner_model = "),
2744 "planner_model key must be removed from output"
2745 );
2746 assert!(
2747 result.output.contains("# planner_provider"),
2748 "commented-out planner_provider entry must be present"
2749 );
2750 assert!(
2751 result.output.contains("gpt-4o"),
2752 "old value must appear in the comment"
2753 );
2754 assert!(
2755 result.output.contains("MIGRATED"),
2756 "comment must include MIGRATED marker"
2757 );
2758 }
2759
2760 #[test]
2761 fn migrate_planner_model_to_provider_no_op() {
2762 let input = r"
2763[orchestration]
2764enabled = true
2765max_tasks = 20
2766";
2767 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
2768 assert_eq!(
2769 result.added_count, 0,
2770 "added_count must be 0 when field is absent"
2771 );
2772 assert_eq!(
2773 result.output, input,
2774 "output must equal input when nothing to migrate"
2775 );
2776 }
2777
2778 #[test]
2779 fn migrate_error_invalid_structure_formats_correctly() {
2780 let err = MigrateError::InvalidStructure("test sentinel");
2785 assert!(
2786 matches!(err, MigrateError::InvalidStructure(_)),
2787 "variant must match"
2788 );
2789 let msg = err.to_string();
2790 assert!(
2791 msg.contains("invalid TOML structure"),
2792 "error message must mention 'invalid TOML structure', got: {msg}"
2793 );
2794 assert!(
2795 msg.contains("test sentinel"),
2796 "message must include reason: {msg}"
2797 );
2798 }
2799
2800 #[test]
2803 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
2804 let src = r#"
2805[mcp]
2806allowed_commands = ["npx"]
2807
2808[[mcp.servers]]
2809id = "srv-a"
2810command = "npx"
2811args = ["-y", "some-mcp"]
2812
2813[[mcp.servers]]
2814id = "srv-b"
2815command = "npx"
2816args = ["-y", "other-mcp"]
2817"#;
2818 let result = migrate_mcp_trust_levels(src).expect("migrate");
2819 assert_eq!(
2820 result.added_count, 2,
2821 "both entries must get trust_level added"
2822 );
2823 assert!(
2824 result
2825 .sections_added
2826 .contains(&"mcp.servers.trust_level".to_owned()),
2827 "sections_added must report mcp.servers.trust_level"
2828 );
2829 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
2831 assert_eq!(
2832 occurrences, 2,
2833 "each entry must have trust_level = \"trusted\""
2834 );
2835 }
2836
2837 #[test]
2838 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
2839 let src = r#"
2840[[mcp.servers]]
2841id = "srv-a"
2842command = "npx"
2843trust_level = "sandboxed"
2844tool_allowlist = ["read_file"]
2845
2846[[mcp.servers]]
2847id = "srv-b"
2848command = "npx"
2849"#;
2850 let result = migrate_mcp_trust_levels(src).expect("migrate");
2851 assert_eq!(
2853 result.added_count, 1,
2854 "only entry without trust_level gets updated"
2855 );
2856 assert!(
2858 result.output.contains("trust_level = \"sandboxed\""),
2859 "existing trust_level must not be overwritten"
2860 );
2861 assert!(
2863 result.output.contains("trust_level = \"trusted\""),
2864 "entry without trust_level must get trusted"
2865 );
2866 }
2867
2868 #[test]
2869 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
2870 let src = "[agent]\nname = \"Zeph\"\n";
2871 let result = migrate_mcp_trust_levels(src).expect("migrate");
2872 assert_eq!(result.added_count, 0);
2873 assert!(result.sections_added.is_empty());
2874 assert_eq!(result.output, src);
2875 }
2876
2877 #[test]
2878 fn migrate_mcp_trust_levels_no_servers_is_noop() {
2879 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
2880 let result = migrate_mcp_trust_levels(src).expect("migrate");
2881 assert_eq!(result.added_count, 0);
2882 assert!(result.sections_added.is_empty());
2883 assert_eq!(result.output, src);
2884 }
2885
2886 #[test]
2887 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
2888 let src = r#"
2889[[mcp.servers]]
2890id = "srv-a"
2891trust_level = "trusted"
2892
2893[[mcp.servers]]
2894id = "srv-b"
2895trust_level = "untrusted"
2896"#;
2897 let result = migrate_mcp_trust_levels(src).expect("migrate");
2898 assert_eq!(result.added_count, 0);
2899 assert!(result.sections_added.is_empty());
2900 }
2901
2902 #[test]
2903 fn migrate_database_url_adds_comment_when_absent() {
2904 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
2905 let result = migrate_database_url(src).expect("migrate");
2906 assert_eq!(result.added_count, 1);
2907 assert!(
2908 result
2909 .sections_added
2910 .contains(&"memory.database_url".to_owned())
2911 );
2912 assert!(result.output.contains("# database_url = \"\""));
2913 }
2914
2915 #[test]
2916 fn migrate_database_url_is_noop_when_present() {
2917 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
2918 let result = migrate_database_url(src).expect("migrate");
2919 assert_eq!(result.added_count, 0);
2920 assert!(result.sections_added.is_empty());
2921 assert_eq!(result.output, src);
2922 }
2923
2924 #[test]
2925 fn migrate_database_url_creates_memory_section_when_absent() {
2926 let src = "[agent]\nname = \"Zeph\"\n";
2927 let result = migrate_database_url(src).expect("migrate");
2928 assert_eq!(result.added_count, 1);
2929 assert!(result.output.contains("# database_url = \"\""));
2930 }
2931
2932 #[test]
2935 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
2936 let src = "[agent]\nname = \"Zeph\"\n";
2937 let result = migrate_agent_budget_hint(src).expect("migrate");
2938 assert_eq!(result.added_count, 1);
2939 assert!(result.output.contains("budget_hint_enabled"));
2940 assert!(
2941 result
2942 .sections_added
2943 .contains(&"agent.budget_hint_enabled".to_owned())
2944 );
2945 }
2946
2947 #[test]
2948 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
2949 let src = "[llm]\nmodel = \"gpt-4o\"\n";
2950 let result = migrate_agent_budget_hint(src).expect("migrate");
2951 assert_eq!(result.added_count, 0);
2952 assert_eq!(result.output, src);
2953 }
2954
2955 #[test]
2956 fn migrate_agent_budget_hint_already_present_is_noop() {
2957 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
2958 let result = migrate_agent_budget_hint(src).expect("migrate");
2959 assert_eq!(result.added_count, 0);
2960 assert_eq!(result.output, src);
2961 }
2962
2963 #[test]
2964 fn migrate_telemetry_config_empty_config_appends_comment_block() {
2965 let src = "[agent]\nname = \"Zeph\"\n";
2966 let result = migrate_telemetry_config(src).expect("migrate");
2967 assert_eq!(result.added_count, 1);
2968 assert_eq!(result.sections_added, vec!["telemetry"]);
2969 assert!(
2970 result.output.contains("# [telemetry]"),
2971 "expected commented-out [telemetry] block in output"
2972 );
2973 assert!(
2974 result.output.contains("enabled = false"),
2975 "expected enabled = false in telemetry comment block"
2976 );
2977 }
2978
2979 #[test]
2980 fn migrate_telemetry_config_existing_section_is_noop() {
2981 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
2982 let result = migrate_telemetry_config(src).expect("migrate");
2983 assert_eq!(result.added_count, 0);
2984 assert_eq!(result.output, src);
2985 }
2986
2987 #[test]
2988 fn migrate_telemetry_config_existing_comment_is_noop() {
2989 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
2991 let result = migrate_telemetry_config(src).expect("migrate");
2992 assert_eq!(result.added_count, 0);
2993 assert_eq!(result.output, src);
2994 }
2995
2996 #[test]
2999 fn migrate_otel_filter_already_present_is_noop() {
3000 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
3002 let result = migrate_otel_filter(src).expect("migrate");
3003 assert_eq!(result.added_count, 0);
3004 assert_eq!(result.output, src);
3005 }
3006
3007 #[test]
3008 fn migrate_otel_filter_commented_key_is_noop() {
3009 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
3011 let result = migrate_otel_filter(src).expect("migrate");
3012 assert_eq!(result.added_count, 0);
3013 assert_eq!(result.output, src);
3014 }
3015
3016 #[test]
3017 fn migrate_otel_filter_no_telemetry_section_is_noop() {
3018 let src = "[agent]\nname = \"Zeph\"\n";
3020 let result = migrate_otel_filter(src).expect("migrate");
3021 assert_eq!(result.added_count, 0);
3022 assert_eq!(result.output, src);
3023 assert!(!result.output.contains("otel_filter"));
3024 }
3025
3026 #[test]
3027 fn migrate_otel_filter_injects_within_telemetry_section() {
3028 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
3029 let result = migrate_otel_filter(src).expect("migrate");
3030 assert_eq!(result.added_count, 1);
3031 assert_eq!(result.sections_added, vec!["telemetry.otel_filter"]);
3032 assert!(
3033 result.output.contains("otel_filter"),
3034 "otel_filter comment must appear"
3035 );
3036 let otel_pos = result
3038 .output
3039 .find("otel_filter")
3040 .expect("otel_filter present");
3041 let agent_pos = result.output.find("[agent]").expect("[agent] present");
3042 assert!(
3043 otel_pos < agent_pos,
3044 "otel_filter comment should appear before [agent] section"
3045 );
3046 }
3047
3048 #[test]
3049 fn sandbox_migration_adds_commented_section_when_absent() {
3050 let src = "[agent]\nname = \"Z\"\n";
3051 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3052 assert_eq!(result.added_count, 1);
3053 assert!(result.output.contains("# [tools.sandbox]"));
3054 assert!(result.output.contains("# profile = \"workspace\""));
3055 }
3056
3057 #[test]
3058 fn sandbox_migration_noop_when_section_present() {
3059 let src = "[tools.sandbox]\nenabled = true\n";
3060 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3061 assert_eq!(result.added_count, 0);
3062 }
3063
3064 #[test]
3065 fn sandbox_migration_noop_when_dotted_key_present() {
3066 let src = "[tools]\nsandbox = { enabled = true }\n";
3067 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3068 assert_eq!(result.added_count, 0);
3069 }
3070
3071 #[test]
3072 fn sandbox_migration_false_positive_comment_does_not_block() {
3073 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
3075 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3076 assert_eq!(result.added_count, 1);
3077 }
3078
3079 #[test]
3080 fn embedded_default_mentions_tools_sandbox() {
3081 let default_src = include_str!("../config/default.toml");
3082 assert!(
3083 default_src.contains("tools.sandbox"),
3084 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
3085 );
3086 }
3087
3088 #[test]
3089 fn sandbox_migration_idempotent_on_own_output() {
3090 let base = "[agent]\nmodel = \"test\"\n";
3091 let first = migrate_sandbox_config(base).unwrap();
3092 assert_eq!(first.added_count, 1);
3093 let second = migrate_sandbox_config(&first.output).unwrap();
3094 assert_eq!(second.added_count, 0, "second run must not double-append");
3095 assert_eq!(second.output, first.output);
3096 }
3097
3098 #[test]
3099 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
3100 let base = "[agent]\nname = \"Zeph\"\n";
3101 let first = migrate_agent_budget_hint(base).unwrap();
3102 assert_eq!(first.added_count, 1);
3103 let second = migrate_agent_budget_hint(&first.output).unwrap();
3104 assert_eq!(second.added_count, 0, "second run must not double-append");
3105 assert_eq!(second.output, first.output);
3106 }
3107
3108 #[test]
3109 fn migrate_forgetting_config_idempotent_on_commented_output() {
3110 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3111 let first = migrate_forgetting_config(base).unwrap();
3112 assert_eq!(first.added_count, 1);
3113 let second = migrate_forgetting_config(&first.output).unwrap();
3114 assert_eq!(second.added_count, 0, "second run must not double-append");
3115 assert_eq!(second.output, first.output);
3116 }
3117
3118 #[test]
3119 fn migrate_microcompact_config_idempotent_on_commented_output() {
3120 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3121 let first = migrate_microcompact_config(base).unwrap();
3122 assert_eq!(first.added_count, 1);
3123 let second = migrate_microcompact_config(&first.output).unwrap();
3124 assert_eq!(second.added_count, 0, "second run must not double-append");
3125 assert_eq!(second.output, first.output);
3126 }
3127
3128 #[test]
3129 fn migrate_autodream_config_idempotent_on_commented_output() {
3130 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3131 let first = migrate_autodream_config(base).unwrap();
3132 assert_eq!(first.added_count, 1);
3133 let second = migrate_autodream_config(&first.output).unwrap();
3134 assert_eq!(second.added_count, 0, "second run must not double-append");
3135 assert_eq!(second.output, first.output);
3136 }
3137
3138 #[test]
3139 fn migrate_compression_predictor_idempotent_on_commented_output() {
3140 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3141 let first = migrate_compression_predictor_config(base).unwrap();
3142 assert_eq!(first.added_count, 1);
3143 let second = migrate_compression_predictor_config(&first.output).unwrap();
3144 assert_eq!(second.added_count, 0, "second run must not double-append");
3145 assert_eq!(second.output, first.output);
3146 }
3147
3148 #[test]
3149 fn migrate_database_url_idempotent_on_commented_output() {
3150 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3151 let first = migrate_database_url(base).unwrap();
3152 assert_eq!(first.added_count, 1);
3153 let second = migrate_database_url(&first.output).unwrap();
3154 assert_eq!(second.added_count, 0, "second run must not double-append");
3155 assert_eq!(second.output, first.output);
3156 }
3157
3158 #[test]
3159 fn migrate_shell_transactional_idempotent_on_commented_output() {
3160 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
3161 let first = migrate_shell_transactional(base).unwrap();
3162 assert_eq!(first.added_count, 1);
3163 let second = migrate_shell_transactional(&first.output).unwrap();
3164 assert_eq!(second.added_count, 0, "second run must not double-append");
3165 assert_eq!(second.output, first.output);
3166 }
3167
3168 #[test]
3169 fn migrate_otel_filter_idempotent_on_commented_output() {
3170 let base = "[telemetry]\nenabled = true\n";
3171 let first = migrate_otel_filter(base).unwrap();
3172 assert_eq!(first.added_count, 1);
3173 let second = migrate_otel_filter(&first.output).unwrap();
3174 assert_eq!(second.added_count, 0, "second run must not double-append");
3175 assert_eq!(second.output, first.output);
3176 }
3177
3178 #[test]
3179 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
3180 let migrator = ConfigMigrator::new();
3181 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
3182 let result = migrator.migrate(src).expect("migrate");
3183 let sec_body_start = result
3184 .output
3185 .find("[security.content_isolation]")
3186 .unwrap_or(0);
3187 let sec_body = &result.output[sec_body_start..];
3188 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
3189 let sec_slice = &sec_body[..next_header];
3190 assert!(
3191 sec_slice.contains("# enabled"),
3192 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
3193 );
3194 }
3195
3196 #[test]
3197 fn config_migrator_idempotent_on_realistic_config() {
3198 let base = r#"
3199[agent]
3200name = "Zeph"
3201
3202[memory]
3203db_path = "~/.zeph/memory.db"
3204soft_compaction_threshold = 0.6
3205
3206[index]
3207max_chunks = 12
3208
3209[tools]
3210[tools.shell]
3211allow_list = []
3212
3213[telemetry]
3214enabled = false
3215
3216[security]
3217[security.content_isolation]
3218enabled = true
3219"#;
3220 let migrator = ConfigMigrator::new();
3221 let first = migrator.migrate(base).expect("first migrate");
3222 let second = migrator.migrate(&first.output).expect("second migrate");
3223 assert_eq!(
3224 second.added_count, 0,
3225 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
3226 second.added_count
3227 );
3228 assert_eq!(
3229 first.output, second.output,
3230 "output must be identical on second run"
3231 );
3232 for line in first.output.lines() {
3233 if line.starts_with('[') && !line.starts_with("[[") {
3234 assert!(
3235 !line.contains('#'),
3236 "section header must not have inline comment: {line:?}"
3237 );
3238 }
3239 }
3240 }
3241
3242 #[test]
3243 fn migrate_claude_prompt_cache_ttl_1h_survives() {
3244 let src = r#"
3245[llm]
3246provider = "claude"
3247
3248[llm.cloud]
3249model = "claude-sonnet-4-6"
3250prompt_cache_ttl = "1h"
3251"#;
3252 let result = migrate_llm_to_providers(src).expect("migrate");
3253 assert!(
3254 result.output.contains("prompt_cache_ttl = \"1h\""),
3255 "1h TTL must be preserved in migrated output:\n{}",
3256 result.output
3257 );
3258 }
3259
3260 #[test]
3261 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
3262 let src = r#"
3263[llm]
3264provider = "claude"
3265
3266[llm.cloud]
3267model = "claude-sonnet-4-6"
3268prompt_cache_ttl = "ephemeral"
3269"#;
3270 let result = migrate_llm_to_providers(src).expect("migrate");
3271 assert!(
3272 !result.output.contains("prompt_cache_ttl"),
3273 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
3274 result.output
3275 );
3276 }
3277
3278 #[test]
3279 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
3280 let src = r#"
3281[[llm.providers]]
3282type = "claude"
3283model = "claude-sonnet-4-6"
3284prompt_cache_ttl = "1h"
3285"#;
3286 let migrator = ConfigMigrator::new();
3287 let first = migrator.migrate(src).expect("first migrate");
3288 let second = migrator.migrate(&first.output).expect("second migrate");
3289 assert_eq!(
3290 first.output, second.output,
3291 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
3292 );
3293 }
3294}