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 "notifications",
39 "tui",
40 "agents",
41 "experiments",
42 "lsp",
43 "telemetry",
44 "session",
45];
46
47#[derive(Debug, thiserror::Error)]
49#[non_exhaustive]
50pub enum MigrateError {
51 #[error("failed to parse input config: {0}")]
53 Parse(#[from] toml_edit::TomlError),
54 #[error("failed to parse reference config: {0}")]
56 Reference(toml_edit::TomlError),
57 #[error("migration failed: invalid TOML structure — {0}")]
60 InvalidStructure(&'static str),
61}
62
63#[derive(Debug)]
65pub struct MigrationResult {
66 pub output: String,
68 pub changed_count: usize,
70 pub sections_changed: Vec<String>,
72}
73
74pub struct ConfigMigrator {
79 reference_src: &'static str,
80}
81
82impl Default for ConfigMigrator {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88impl ConfigMigrator {
89 #[must_use]
91 pub fn new() -> Self {
92 Self {
93 reference_src: include_str!("../../config/default.toml"),
94 }
95 }
96
97 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
109 let reference_doc = self
110 .reference_src
111 .parse::<DocumentMut>()
112 .map_err(MigrateError::Reference)?;
113 let mut user_doc = user_toml.parse::<DocumentMut>()?;
114
115 let mut changed_count = 0usize;
116 let mut sections_changed: Vec<String> = Vec::new();
117 let mut pending_comments: Vec<(String, String)> = Vec::new();
120
121 for (key, ref_item) in reference_doc.as_table() {
123 if ref_item.is_table() {
124 let ref_table = ref_item.as_table().expect("is_table checked above");
125 if user_doc.contains_key(key) {
126 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
128 let (n, comments) =
129 merge_table_commented(user_table, ref_table, key, user_toml);
130 changed_count += n;
131 pending_comments.extend(comments);
132 }
133 } else {
134 if user_toml.contains(&format!("# [{key}]")) {
137 continue;
138 }
139 let commented = commented_table_block(key, ref_table);
140 if !commented.is_empty() {
141 sections_changed.push(key.to_owned());
142 }
143 changed_count += 1;
144 }
145 } else {
146 if !user_doc.contains_key(key) {
148 let raw = format_commented_item(key, ref_item);
149 if !raw.is_empty() {
150 sections_changed.push(format!("__scalar__{key}"));
151 changed_count += 1;
152 }
153 }
154 }
155 }
156
157 let user_str = user_doc.to_string();
159
160 let mut output = user_str;
163 for (section_key, comment_line) in &pending_comments {
164 if !section_body(&output, section_key).contains(comment_line.trim()) {
165 output = insert_after_section(&output, section_key, comment_line);
166 }
167 }
168
169 for key in §ions_changed {
171 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
172 if let Some(ref_item) = reference_doc.get(scalar_key) {
173 let raw = format_commented_item(scalar_key, ref_item);
174 if !raw.is_empty() {
175 output.push('\n');
176 output.push_str(&raw);
177 output.push('\n');
178 }
179 }
180 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
181 {
182 let block = commented_table_block(key, ref_table);
183 if !block.is_empty() {
184 output.push('\n');
185 output.push_str(&block);
186 }
187 }
188 }
189
190 output = reorder_sections(&output, CANONICAL_ORDER);
192
193 let sections_changed_clean: Vec<String> = sections_changed
195 .into_iter()
196 .filter(|k| !k.starts_with("__scalar__"))
197 .collect();
198
199 Ok(MigrationResult {
200 output,
201 changed_count,
202 sections_changed: sections_changed_clean,
203 })
204 }
205}
206
207fn merge_table_commented(
213 user_table: &mut Table,
214 ref_table: &Table,
215 section_key: &str,
216 user_toml: &str,
217) -> (usize, Vec<(String, String)>) {
218 let mut count = 0usize;
219 let mut comments: Vec<(String, String)> = Vec::new();
220 for (key, ref_item) in ref_table {
221 if ref_item.is_table() {
222 if user_table.contains_key(key) {
223 let pair = (
224 user_table.get_mut(key).and_then(Item::as_table_mut),
225 ref_item.as_table(),
226 );
227 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
228 let sub_key = format!("{section_key}.{key}");
229 let (n, c) =
230 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
231 count += n;
232 comments.extend(c);
233 }
234 } else if let Some(ref_sub_table) = ref_item.as_table() {
235 let dotted = format!("{section_key}.{key}");
237 let marker = format!("# [{dotted}]");
238 if !user_toml.contains(&marker) {
239 let block = commented_table_block(&dotted, ref_sub_table);
240 if !block.is_empty() {
241 comments.push((section_key.to_owned(), format!("\n{block}")));
242 count += 1;
243 }
244 }
245 }
246 } else if ref_item.is_array_of_tables() {
247 } else {
249 if !user_table.contains_key(key) {
251 let raw_value = ref_item
252 .as_value()
253 .map(value_to_toml_string)
254 .unwrap_or_default();
255 if !raw_value.is_empty() {
256 let comment_line = format!("# {key} = {raw_value}\n");
257 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
260 comments.push((section_key.to_owned(), comment_line));
261 count += 1;
262 }
263 }
264 }
265 }
266 }
267 (count, comments)
268}
269
270fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
276 let header = format!("[{section}]");
277 let Some(section_start) = doc.find(&header) else {
278 return "";
279 };
280 let body_start = section_start + header.len();
281 let body_end = doc[body_start..]
282 .find("\n[")
283 .map_or(doc.len(), |r| body_start + r);
284 &doc[body_start..body_end]
285}
286
287fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
293 let header = format!("[{section_name}]");
294 let Some(section_start) = raw.find(&header) else {
295 return format!("{raw}{text}");
296 };
297 let search_from = section_start + header.len();
299 let insert_pos = raw[search_from..]
301 .find("\n[")
302 .map_or(raw.len(), |rel| search_from + rel + 1);
303 let mut out = String::with_capacity(raw.len() + text.len());
304 out.push_str(&raw[..insert_pos]);
305 out.push_str(text);
306 out.push_str(&raw[insert_pos..]);
307 out
308}
309
310fn format_commented_item(key: &str, item: &Item) -> String {
312 if let Some(val) = item.as_value() {
313 let raw = value_to_toml_string(val);
314 if !raw.is_empty() {
315 return format!("# {key} = {raw}\n");
316 }
317 }
318 String::new()
319}
320
321fn commented_table_block(section_name: &str, table: &Table) -> String {
326 use std::fmt::Write as _;
327
328 let mut lines = format!("# [{section_name}]\n");
329
330 for (key, item) in table {
331 if item.is_table() {
332 if let Some(sub_table) = item.as_table() {
333 let sub_name = format!("{section_name}.{key}");
334 let sub_block = commented_table_block(&sub_name, sub_table);
335 if !sub_block.is_empty() {
336 lines.push('\n');
337 lines.push_str(&sub_block);
338 }
339 }
340 } else if item.is_array_of_tables() {
341 } else if let Some(val) = item.as_value() {
343 let raw = value_to_toml_string(val);
344 if !raw.is_empty() {
345 let _ = writeln!(lines, "# {key} = {raw}");
346 }
347 }
348 }
349
350 if lines.trim() == format!("[{section_name}]") {
352 return String::new();
353 }
354 lines
355}
356
357fn value_to_toml_string(val: &Value) -> String {
359 match val {
360 Value::String(s) => {
361 let inner = s.value();
362 format!("\"{inner}\"")
363 }
364 Value::Integer(i) => i.value().to_string(),
365 Value::Float(f) => {
366 let v = f.value();
367 if v.fract() == 0.0 {
369 format!("{v:.1}")
370 } else {
371 format!("{v}")
372 }
373 }
374 Value::Boolean(b) => b.value().to_string(),
375 Value::Array(arr) => format_array(arr),
376 Value::InlineTable(t) => {
377 let pairs: Vec<String> = t
378 .iter()
379 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
380 .collect();
381 format!("{{ {} }}", pairs.join(", "))
382 }
383 Value::Datetime(dt) => dt.value().to_string(),
384 }
385}
386
387fn format_array(arr: &Array) -> String {
388 if arr.is_empty() {
389 return "[]".to_owned();
390 }
391 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
392 format!("[{}]", items.join(", "))
393}
394
395fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
401 let sections = split_into_sections(toml_str);
402 if sections.is_empty() {
403 return toml_str.to_owned();
404 }
405
406 let preamble_block = sections
408 .iter()
409 .find(|(h, _)| h.is_empty())
410 .map_or("", |(_, c)| c.as_str());
411
412 let section_map: Vec<(&str, &str)> = sections
413 .iter()
414 .filter(|(h, _)| !h.is_empty())
415 .map(|(h, c)| (h.as_str(), c.as_str()))
416 .collect();
417
418 let mut out = String::new();
419 if !preamble_block.is_empty() {
420 out.push_str(preamble_block);
421 }
422
423 let mut emitted: Vec<bool> = vec![false; section_map.len()];
424
425 for &canon in canonical_order {
426 for (idx, &(header, content)) in section_map.iter().enumerate() {
427 let section_name = extract_section_name(header);
428 let top_level = section_name
429 .split('.')
430 .next()
431 .unwrap_or("")
432 .trim_start_matches('#')
433 .trim();
434 if top_level == canon && !emitted[idx] {
435 out.push_str(content);
436 emitted[idx] = true;
437 }
438 }
439 }
440
441 for (idx, &(_, content)) in section_map.iter().enumerate() {
443 if !emitted[idx] {
444 out.push_str(content);
445 }
446 }
447
448 out
449}
450
451fn extract_section_name(header: &str) -> &str {
453 let trimmed = header.trim().trim_start_matches("# ");
455 if trimmed.starts_with('[') && trimmed.contains(']') {
457 let inner = &trimmed[1..];
458 if let Some(end) = inner.find(']') {
459 return &inner[..end];
460 }
461 }
462 trimmed
463}
464
465fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
469 let mut sections: Vec<(String, String)> = Vec::new();
470 let mut current_header = String::new();
471 let mut current_content = String::new();
472
473 for line in toml_str.lines() {
474 let trimmed = line.trim();
475 if is_top_level_section_header(trimmed) {
476 sections.push((current_header.clone(), current_content.clone()));
477 trimmed.clone_into(&mut current_header);
478 line.clone_into(&mut current_content);
479 current_content.push('\n');
480 } else {
481 current_content.push_str(line);
482 current_content.push('\n');
483 }
484 }
485
486 if !current_header.is_empty() || !current_content.is_empty() {
488 sections.push((current_header, current_content));
489 }
490
491 sections
492}
493
494fn is_top_level_section_header(line: &str) -> bool {
499 if line.starts_with('[')
500 && !line.starts_with("[[")
501 && let Some(end) = line.find(']')
502 {
503 return !line[1..end].contains('.');
504 }
505 false
506}
507
508#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
509fn migrate_ollama_provider(
510 llm: &toml_edit::Table,
511 model: &Option<String>,
512 base_url: &Option<String>,
513 embedding_model: &Option<String>,
514) -> Vec<String> {
515 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
516 if let Some(m) = model {
517 block.push_str(&format!("model = \"{m}\"\n"));
518 }
519 if let Some(em) = embedding_model {
520 block.push_str(&format!("embedding_model = \"{em}\"\n"));
521 }
522 if let Some(u) = base_url {
523 block.push_str(&format!("base_url = \"{u}\"\n"));
524 }
525 let _ = llm; vec![block]
527}
528
529#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
530fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
531 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
532 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
533 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
534 block.push_str(&format!("model = \"{m}\"\n"));
535 }
536 if let Some(t) = cloud
537 .get("max_tokens")
538 .and_then(toml_edit::Item::as_integer)
539 {
540 block.push_str(&format!("max_tokens = {t}\n"));
541 }
542 if cloud
543 .get("server_compaction")
544 .and_then(toml_edit::Item::as_bool)
545 == Some(true)
546 {
547 block.push_str("server_compaction = true\n");
548 }
549 if cloud
550 .get("enable_extended_context")
551 .and_then(toml_edit::Item::as_bool)
552 == Some(true)
553 {
554 block.push_str("enable_extended_context = true\n");
555 }
556 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
557 let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
558 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
559 }
560 if let Some(v) = cloud
561 .get("prompt_cache_ttl")
562 .and_then(toml_edit::Item::as_str)
563 {
564 if v != "ephemeral" {
565 block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
566 }
567 }
568 } else if let Some(m) = model {
569 block.push_str(&format!("model = \"{m}\"\n"));
570 }
571 vec![block]
572}
573
574#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
575fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
576 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
577 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
578 copy_str_field(openai, "model", &mut block);
579 copy_str_field(openai, "base_url", &mut block);
580 copy_int_field(openai, "max_tokens", &mut block);
581 copy_str_field(openai, "embedding_model", &mut block);
582 copy_str_field(openai, "reasoning_effort", &mut block);
583 } else if let Some(m) = model {
584 block.push_str(&format!("model = \"{m}\"\n"));
585 }
586 vec![block]
587}
588
589#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
590fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
591 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
592 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
593 copy_str_field(gemini, "model", &mut block);
594 copy_int_field(gemini, "max_tokens", &mut block);
595 copy_str_field(gemini, "base_url", &mut block);
596 copy_str_field(gemini, "embedding_model", &mut block);
597 copy_str_field(gemini, "thinking_level", &mut block);
598 copy_int_field(gemini, "thinking_budget", &mut block);
599 if let Some(v) = gemini
600 .get("include_thoughts")
601 .and_then(toml_edit::Item::as_bool)
602 {
603 block.push_str(&format!("include_thoughts = {v}\n"));
604 }
605 } else if let Some(m) = model {
606 block.push_str(&format!("model = \"{m}\"\n"));
607 }
608 vec![block]
609}
610
611#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
612fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
613 let mut blocks = Vec::new();
614 if let Some(compat_arr) = llm
615 .get("compatible")
616 .and_then(toml_edit::Item::as_array_of_tables)
617 {
618 for entry in compat_arr {
619 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
620 copy_str_field(entry, "name", &mut block);
621 copy_str_field(entry, "base_url", &mut block);
622 copy_str_field(entry, "model", &mut block);
623 copy_int_field(entry, "max_tokens", &mut block);
624 copy_str_field(entry, "embedding_model", &mut block);
625 blocks.push(block);
626 }
627 }
628 blocks
629}
630
631#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
633fn migrate_orchestrator_provider(
634 llm: &toml_edit::Table,
635 model: &Option<String>,
636 base_url: &Option<String>,
637 embedding_model: &Option<String>,
638) -> (Vec<String>, Option<String>) {
639 let mut blocks = Vec::new();
640 let routing = None;
641 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
642 let default_name = orch
643 .get("default")
644 .and_then(toml_edit::Item::as_str)
645 .unwrap_or("")
646 .to_owned();
647 let embed_name = orch
648 .get("embed")
649 .and_then(toml_edit::Item::as_str)
650 .unwrap_or("")
651 .to_owned();
652 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
653 for (name, pcfg_item) in providers {
654 let Some(pcfg) = pcfg_item.as_table() else {
655 continue;
656 };
657 let ptype = pcfg
658 .get("type")
659 .and_then(toml_edit::Item::as_str)
660 .unwrap_or("ollama");
661 let mut block =
662 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
663 if name == default_name {
664 block.push_str("default = true\n");
665 }
666 if name == embed_name {
667 block.push_str("embed = true\n");
668 }
669 copy_str_field(pcfg, "model", &mut block);
670 copy_str_field(pcfg, "base_url", &mut block);
671 copy_str_field(pcfg, "embedding_model", &mut block);
672 if ptype == "claude" && !pcfg.contains_key("model") {
673 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
674 copy_str_field(cloud, "model", &mut block);
675 copy_int_field(cloud, "max_tokens", &mut block);
676 }
677 }
678 if ptype == "openai" && !pcfg.contains_key("model") {
679 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
680 copy_str_field(openai, "model", &mut block);
681 copy_str_field(openai, "base_url", &mut block);
682 copy_int_field(openai, "max_tokens", &mut block);
683 copy_str_field(openai, "embedding_model", &mut block);
684 }
685 }
686 if ptype == "ollama" && !pcfg.contains_key("base_url") {
687 if let Some(u) = base_url {
688 block.push_str(&format!("base_url = \"{u}\"\n"));
689 }
690 }
691 if ptype == "ollama" && !pcfg.contains_key("model") {
692 if let Some(m) = model {
693 block.push_str(&format!("model = \"{m}\"\n"));
694 }
695 }
696 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
697 if let Some(em) = embedding_model {
698 block.push_str(&format!("embedding_model = \"{em}\"\n"));
699 }
700 }
701 blocks.push(block);
702 }
703 }
704 }
705 (blocks, routing)
706}
707
708#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
710fn migrate_router_provider(
711 llm: &toml_edit::Table,
712 model: &Option<String>,
713 base_url: &Option<String>,
714 embedding_model: &Option<String>,
715) -> (Vec<String>, Option<String>) {
716 let mut blocks = Vec::new();
717 let mut routing = None;
718 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
719 let strategy = router
720 .get("strategy")
721 .and_then(toml_edit::Item::as_str)
722 .unwrap_or("ema");
723 routing = Some(strategy.to_owned());
724 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
725 for item in chain {
726 let name = item.as_str().unwrap_or_default();
727 let ptype = infer_provider_type(name, llm);
728 let mut block =
729 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
730 match ptype {
731 "claude" => {
732 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
733 copy_str_field(cloud, "model", &mut block);
734 copy_int_field(cloud, "max_tokens", &mut block);
735 }
736 }
737 "openai" => {
738 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
739 {
740 copy_str_field(openai, "model", &mut block);
741 copy_str_field(openai, "base_url", &mut block);
742 copy_int_field(openai, "max_tokens", &mut block);
743 copy_str_field(openai, "embedding_model", &mut block);
744 } else {
745 if let Some(m) = model {
746 block.push_str(&format!("model = \"{m}\"\n"));
747 }
748 if let Some(u) = base_url {
749 block.push_str(&format!("base_url = \"{u}\"\n"));
750 }
751 }
752 }
753 "ollama" => {
754 if let Some(m) = model {
755 block.push_str(&format!("model = \"{m}\"\n"));
756 }
757 if let Some(em) = embedding_model {
758 block.push_str(&format!("embedding_model = \"{em}\"\n"));
759 }
760 if let Some(u) = base_url {
761 block.push_str(&format!("base_url = \"{u}\"\n"));
762 }
763 }
764 _ => {
765 if let Some(m) = model {
766 block.push_str(&format!("model = \"{m}\"\n"));
767 }
768 }
769 }
770 blocks.push(block);
771 }
772 }
773 }
774 (blocks, routing)
775}
776
777fn strip_task_routing_keys(toml_src: &str) -> String {
786 let mut in_routes_block = false;
787 let mut out = Vec::new();
788 for line in toml_src.lines() {
789 let trimmed = line.trim();
790 if trimmed == "[llm.routes]" {
791 in_routes_block = true;
792 continue;
793 }
794 if in_routes_block {
795 if trimmed.starts_with('[') {
797 in_routes_block = false;
798 } else {
799 continue;
800 }
801 }
802 if trimmed.starts_with("routing") && trimmed.contains("\"task\"") {
804 continue;
805 }
806 out.push(line);
807 }
808 out.join("\n")
809}
810
811#[allow(
817 clippy::too_many_lines,
818 clippy::format_push_string,
819 clippy::manual_let_else,
820 clippy::op_ref,
821 clippy::collapsible_if
822)]
823pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
824 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
825
826 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
828 Some(t) => t,
829 None => {
830 return Ok(MigrationResult {
832 output: toml_src.to_owned(),
833 changed_count: 0,
834 sections_changed: Vec::new(),
835 });
836 }
837 };
838
839 if llm.get("routing").and_then(toml_edit::Item::as_str) == Some("task") {
842 let routes_count = llm
843 .get("routes")
844 .and_then(toml_edit::Item::as_table)
845 .map_or(0, toml_edit::Table::len);
846 let msg = format!(
847 "routing = \"task\" is no longer supported and has been removed (#3248). \
848 {routes_count} route(s) in [llm.routes] will be dropped. \
849 Falling back to default single-provider routing."
850 );
851 tracing::warn!("{msg}");
852 eprintln!("WARNING: {msg}");
853 let cleaned = strip_task_routing_keys(toml_src);
855 return migrate_llm_to_providers(&cleaned);
856 }
857
858 let has_provider_field = llm.contains_key("provider");
859 let has_cloud = llm.contains_key("cloud");
860 let has_openai = llm.contains_key("openai");
861 let has_gemini = llm.contains_key("gemini");
862 let has_orchestrator = llm.contains_key("orchestrator");
863 let has_router = llm.contains_key("router");
864 let has_providers = llm.contains_key("providers");
865
866 if !has_provider_field
867 && !has_cloud
868 && !has_openai
869 && !has_orchestrator
870 && !has_router
871 && !has_gemini
872 {
873 return Ok(MigrationResult {
875 output: toml_src.to_owned(),
876 changed_count: 0,
877 sections_changed: Vec::new(),
878 });
879 }
880
881 if has_providers {
882 return Err(MigrateError::Parse(
884 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
885 .parse::<toml_edit::DocumentMut>()
886 .unwrap_err(),
887 ));
888 }
889
890 let provider_str = llm
892 .get("provider")
893 .and_then(toml_edit::Item::as_str)
894 .unwrap_or("ollama");
895 let base_url = llm
896 .get("base_url")
897 .and_then(toml_edit::Item::as_str)
898 .map(str::to_owned);
899 let model = llm
900 .get("model")
901 .and_then(toml_edit::Item::as_str)
902 .map(str::to_owned);
903 let embedding_model = llm
904 .get("embedding_model")
905 .and_then(toml_edit::Item::as_str)
906 .map(str::to_owned);
907
908 let mut provider_blocks: Vec<String> = Vec::new();
910 let mut routing: Option<String> = None;
911
912 match provider_str {
913 "ollama" => {
914 provider_blocks.extend(migrate_ollama_provider(
915 llm,
916 &model,
917 &base_url,
918 &embedding_model,
919 ));
920 }
921 "claude" => {
922 provider_blocks.extend(migrate_claude_provider(llm, &model));
923 }
924 "openai" => {
925 provider_blocks.extend(migrate_openai_provider(llm, &model));
926 }
927 "gemini" => {
928 provider_blocks.extend(migrate_gemini_provider(llm, &model));
929 }
930 "compatible" => {
931 provider_blocks.extend(migrate_compatible_provider(llm));
932 }
933 "orchestrator" => {
934 let (blocks, r) =
935 migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
936 provider_blocks.extend(blocks);
937 routing = r;
938 }
939 "router" => {
940 let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
941 provider_blocks.extend(blocks);
942 routing = r;
943 }
944 other => {
945 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
946 if let Some(ref m) = model {
947 block.push_str(&format!("model = \"{m}\"\n"));
948 }
949 provider_blocks.push(block);
950 }
951 }
952
953 if provider_blocks.is_empty() {
954 return Ok(MigrationResult {
956 output: toml_src.to_owned(),
957 changed_count: 0,
958 sections_changed: Vec::new(),
959 });
960 }
961
962 let mut new_llm = "[llm]\n".to_owned();
964 if let Some(ref r) = routing {
965 new_llm.push_str(&format!("routing = \"{r}\"\n"));
966 }
967 for key in &[
969 "response_cache_enabled",
970 "response_cache_ttl_secs",
971 "semantic_cache_enabled",
972 "semantic_cache_threshold",
973 "semantic_cache_max_candidates",
974 "summary_model",
975 "instruction_file",
976 ] {
977 if let Some(val) = llm.get(key) {
978 if let Some(v) = val.as_value() {
979 let raw = value_to_toml_string(v);
980 if !raw.is_empty() {
981 new_llm.push_str(&format!("{key} = {raw}\n"));
982 }
983 }
984 }
985 }
986 new_llm.push('\n');
987
988 for block in &provider_blocks {
989 new_llm.push_str(block);
990 new_llm.push('\n');
991 }
992
993 let output = replace_llm_section(toml_src, &new_llm);
996
997 Ok(MigrationResult {
998 output,
999 changed_count: provider_blocks.len(),
1000 sections_changed: vec!["llm.providers".to_owned()],
1001 })
1002}
1003
1004fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
1006 match name {
1007 "claude" => "claude",
1008 "openai" => "openai",
1009 "gemini" => "gemini",
1010 "ollama" => "ollama",
1011 "candle" => "candle",
1012 _ => {
1013 if llm.contains_key("compatible") {
1015 "compatible"
1016 } else if llm.contains_key("openai") {
1017 "openai"
1018 } else {
1019 "ollama"
1020 }
1021 }
1022 }
1023}
1024
1025fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1026 use std::fmt::Write as _;
1027 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
1028 let _ = writeln!(out, "{key} = \"{v}\"");
1029 }
1030}
1031
1032fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1033 use std::fmt::Write as _;
1034 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
1035 let _ = writeln!(out, "{key} = {v}");
1036 }
1037}
1038
1039fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
1042 let mut out = String::new();
1043 let mut in_llm = false;
1044 let mut skip_until_next_top = false;
1045
1046 for line in toml_str.lines() {
1047 let trimmed = line.trim();
1048
1049 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
1051 && trimmed.ends_with(']')
1052 && !trimmed[1..trimmed.len() - 1].contains('.');
1053 let is_top_aot = trimmed.starts_with("[[")
1054 && trimmed.ends_with("]]")
1055 && !trimmed[2..trimmed.len() - 2].contains('.');
1056 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
1057 && (trimmed.contains(']'));
1058
1059 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
1060 in_llm = true;
1061 skip_until_next_top = true;
1062 continue;
1063 }
1064
1065 if is_top_section || is_top_aot {
1066 if skip_until_next_top {
1067 out.push_str(new_llm_section);
1069 skip_until_next_top = false;
1070 }
1071 in_llm = false;
1072 }
1073
1074 if !skip_until_next_top {
1075 out.push_str(line);
1076 out.push('\n');
1077 }
1078 }
1079
1080 if skip_until_next_top {
1082 out.push_str(new_llm_section);
1083 }
1084
1085 out
1086}
1087
1088struct SttFields {
1090 model: Option<String>,
1091 base_url: Option<String>,
1092 provider_hint: String,
1093}
1094
1095fn extract_stt_fields(doc: &toml_edit::DocumentMut) -> SttFields {
1097 let stt_table = doc
1098 .get("llm")
1099 .and_then(toml_edit::Item::as_table)
1100 .and_then(|llm| llm.get("stt"))
1101 .and_then(toml_edit::Item::as_table);
1102
1103 let model = stt_table
1104 .and_then(|stt| stt.get("model"))
1105 .and_then(toml_edit::Item::as_str)
1106 .map(ToOwned::to_owned);
1107
1108 let base_url = stt_table
1109 .and_then(|stt| stt.get("base_url"))
1110 .and_then(toml_edit::Item::as_str)
1111 .map(ToOwned::to_owned);
1112
1113 let provider_hint = stt_table
1114 .and_then(|stt| stt.get("provider"))
1115 .and_then(toml_edit::Item::as_str)
1116 .map(ToOwned::to_owned)
1117 .unwrap_or_default();
1118
1119 SttFields {
1120 model,
1121 base_url,
1122 provider_hint,
1123 }
1124}
1125
1126fn find_matching_provider_index(
1129 doc: &toml_edit::DocumentMut,
1130 target_type: &str,
1131 provider_hint: &str,
1132) -> Option<usize> {
1133 let providers = doc
1134 .get("llm")
1135 .and_then(toml_edit::Item::as_table)
1136 .and_then(|llm| llm.get("providers"))
1137 .and_then(toml_edit::Item::as_array_of_tables)?;
1138
1139 providers.iter().enumerate().find_map(|(i, t)| {
1140 let name = t
1141 .get("name")
1142 .and_then(toml_edit::Item::as_str)
1143 .unwrap_or("");
1144 let ptype = t
1145 .get("type")
1146 .and_then(toml_edit::Item::as_str)
1147 .unwrap_or("");
1148 let name_match =
1150 !provider_hint.is_empty() && (name == provider_hint || ptype == provider_hint);
1151 let type_match = ptype == target_type;
1152 if name_match || type_match {
1153 Some(i)
1154 } else {
1155 None
1156 }
1157 })
1158}
1159
1160fn attach_stt_to_existing_provider(
1163 doc: &mut toml_edit::DocumentMut,
1164 idx: usize,
1165 stt_model: &str,
1166 stt_base_url: Option<&str>,
1167) -> Result<String, MigrateError> {
1168 let llm_mut = doc
1169 .get_mut("llm")
1170 .and_then(toml_edit::Item::as_table_mut)
1171 .ok_or(MigrateError::InvalidStructure(
1172 "[llm] table not accessible for mutation",
1173 ))?;
1174 let providers_mut = llm_mut
1175 .get_mut("providers")
1176 .and_then(toml_edit::Item::as_array_of_tables_mut)
1177 .ok_or(MigrateError::InvalidStructure(
1178 "[[llm.providers]] array not accessible for mutation",
1179 ))?;
1180 let entry = providers_mut
1181 .iter_mut()
1182 .nth(idx)
1183 .ok_or(MigrateError::InvalidStructure(
1184 "[[llm.providers]] entry index out of range during mutation",
1185 ))?;
1186
1187 let existing_name = entry
1189 .get("name")
1190 .and_then(toml_edit::Item::as_str)
1191 .map(ToOwned::to_owned);
1192 let entry_name = existing_name.unwrap_or_else(|| {
1193 let t = entry
1194 .get("type")
1195 .and_then(toml_edit::Item::as_str)
1196 .unwrap_or("openai");
1197 format!("{t}-stt")
1198 });
1199 entry.insert("name", toml_edit::value(entry_name.clone()));
1200 entry.insert("stt_model", toml_edit::value(stt_model));
1201 if let Some(url) = stt_base_url
1202 && entry.get("base_url").is_none()
1203 {
1204 entry.insert("base_url", toml_edit::value(url));
1205 }
1206 Ok(entry_name)
1207}
1208
1209fn append_new_stt_provider(
1212 doc: &mut toml_edit::DocumentMut,
1213 target_type: &str,
1214 stt_model: &str,
1215 stt_base_url: Option<&str>,
1216) -> Result<String, MigrateError> {
1217 let new_name = if target_type == "candle" {
1218 "local-whisper".to_owned()
1219 } else {
1220 "openai-stt".to_owned()
1221 };
1222 let mut new_entry = toml_edit::Table::new();
1223 new_entry.insert("name", toml_edit::value(new_name.clone()));
1224 new_entry.insert("type", toml_edit::value(target_type));
1225 new_entry.insert("stt_model", toml_edit::value(stt_model));
1226 if let Some(url) = stt_base_url {
1227 new_entry.insert("base_url", toml_edit::value(url));
1228 }
1229 let llm_mut = doc
1230 .get_mut("llm")
1231 .and_then(toml_edit::Item::as_table_mut)
1232 .ok_or(MigrateError::InvalidStructure(
1233 "[llm] table not accessible for mutation",
1234 ))?;
1235 if let Some(item) = llm_mut.get_mut("providers") {
1236 if let Some(arr) = item.as_array_of_tables_mut() {
1237 arr.push(new_entry);
1238 }
1239 } else {
1240 let mut arr = toml_edit::ArrayOfTables::new();
1241 arr.push(new_entry);
1242 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1243 }
1244 Ok(new_name)
1245}
1246
1247fn rewrite_stt_section(doc: &mut toml_edit::DocumentMut, resolved_provider_name: &str) {
1249 if let Some(stt_table) = doc
1250 .get_mut("llm")
1251 .and_then(toml_edit::Item::as_table_mut)
1252 .and_then(|llm| llm.get_mut("stt"))
1253 .and_then(toml_edit::Item::as_table_mut)
1254 {
1255 stt_table.insert("provider", toml_edit::value(resolved_provider_name));
1256 stt_table.remove("model");
1257 stt_table.remove("base_url");
1258 }
1259}
1260
1261pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1280 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1281 let stt = extract_stt_fields(&doc);
1282
1283 if stt.model.is_none() && stt.base_url.is_none() {
1285 return Ok(MigrationResult {
1286 output: toml_src.to_owned(),
1287 changed_count: 0,
1288 sections_changed: Vec::new(),
1289 });
1290 }
1291
1292 let stt_model = stt.model.unwrap_or_else(|| "whisper-1".to_owned());
1293
1294 let target_type = match stt.provider_hint.as_str() {
1296 "candle-whisper" | "candle" => "candle",
1297 _ => "openai",
1298 };
1299
1300 let resolved_name = match find_matching_provider_index(&doc, target_type, &stt.provider_hint) {
1301 Some(idx) => {
1302 attach_stt_to_existing_provider(&mut doc, idx, &stt_model, stt.base_url.as_deref())?
1303 }
1304 None => {
1305 append_new_stt_provider(&mut doc, target_type, &stt_model, stt.base_url.as_deref())?
1306 }
1307 };
1308
1309 rewrite_stt_section(&mut doc, &resolved_name);
1310
1311 Ok(MigrationResult {
1312 output: doc.to_string(),
1313 changed_count: 1,
1314 sections_changed: vec!["llm.providers.stt_model".to_owned()],
1315 })
1316}
1317
1318pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1331 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1332
1333 let old_value = doc
1334 .get("orchestration")
1335 .and_then(toml_edit::Item::as_table)
1336 .and_then(|t| t.get("planner_model"))
1337 .and_then(toml_edit::Item::as_value)
1338 .and_then(toml_edit::Value::as_str)
1339 .map(ToOwned::to_owned);
1340
1341 let Some(old_model) = old_value else {
1342 return Ok(MigrationResult {
1343 output: toml_src.to_owned(),
1344 changed_count: 0,
1345 sections_changed: Vec::new(),
1346 });
1347 };
1348
1349 let commented_out = format!(
1353 "# planner_provider = \"{old_model}\" \
1354 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1355 );
1356
1357 let orch_table = doc
1358 .get_mut("orchestration")
1359 .and_then(toml_edit::Item::as_table_mut)
1360 .ok_or(MigrateError::InvalidStructure(
1361 "[orchestration] is not a table",
1362 ))?;
1363 orch_table.remove("planner_model");
1364 let decor = orch_table.decor_mut();
1365 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1366 let new_suffix = if existing_suffix.trim().is_empty() {
1368 format!("\n{commented_out}\n")
1369 } else {
1370 format!("{existing_suffix}\n{commented_out}\n")
1371 };
1372 decor.set_suffix(new_suffix);
1373
1374 eprintln!(
1375 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1376 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1377 `name` field, not a raw model name. Update or remove the commented line."
1378 );
1379
1380 Ok(MigrationResult {
1381 output: doc.to_string(),
1382 changed_count: 1,
1383 sections_changed: vec!["orchestration.planner_provider".to_owned()],
1384 })
1385}
1386
1387pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1401 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1402 let mut added = 0usize;
1403
1404 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1405 return Ok(MigrationResult {
1406 output: toml_src.to_owned(),
1407 changed_count: 0,
1408 sections_changed: Vec::new(),
1409 });
1410 };
1411
1412 let Some(servers) = mcp
1413 .get_mut("servers")
1414 .and_then(toml_edit::Item::as_array_of_tables_mut)
1415 else {
1416 return Ok(MigrationResult {
1417 output: toml_src.to_owned(),
1418 changed_count: 0,
1419 sections_changed: Vec::new(),
1420 });
1421 };
1422
1423 for entry in servers.iter_mut() {
1424 if !entry.contains_key("trust_level") {
1425 entry.insert(
1426 "trust_level",
1427 toml_edit::value(toml_edit::Value::from("trusted")),
1428 );
1429 added += 1;
1430 }
1431 }
1432
1433 if added > 0 {
1434 eprintln!(
1435 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1436 entr{} (preserving previous SSRF-skip behavior). \
1437 Review and adjust trust levels as needed.",
1438 if added == 1 { "y" } else { "ies" }
1439 );
1440 }
1441
1442 Ok(MigrationResult {
1443 output: doc.to_string(),
1444 changed_count: added,
1445 sections_changed: if added > 0 {
1446 vec!["mcp.servers.trust_level".to_owned()]
1447 } else {
1448 Vec::new()
1449 },
1450 })
1451}
1452
1453pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1464 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1465
1466 let max_retries = doc
1467 .get("agent")
1468 .and_then(toml_edit::Item::as_table)
1469 .and_then(|t| t.get("max_tool_retries"))
1470 .and_then(toml_edit::Item::as_value)
1471 .and_then(toml_edit::Value::as_integer)
1472 .map(i64::cast_unsigned);
1473
1474 let budget_secs = doc
1475 .get("agent")
1476 .and_then(toml_edit::Item::as_table)
1477 .and_then(|t| t.get("max_retry_duration_secs"))
1478 .and_then(toml_edit::Item::as_value)
1479 .and_then(toml_edit::Value::as_integer)
1480 .map(i64::cast_unsigned);
1481
1482 if max_retries.is_none() && budget_secs.is_none() {
1483 return Ok(MigrationResult {
1484 output: toml_src.to_owned(),
1485 changed_count: 0,
1486 sections_changed: Vec::new(),
1487 });
1488 }
1489
1490 if !doc.contains_key("tools") {
1492 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1493 }
1494 let tools_table = doc
1495 .get_mut("tools")
1496 .and_then(toml_edit::Item::as_table_mut)
1497 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1498
1499 if !tools_table.contains_key("retry") {
1500 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1501 }
1502 let retry_table = tools_table
1503 .get_mut("retry")
1504 .and_then(toml_edit::Item::as_table_mut)
1505 .ok_or(MigrateError::InvalidStructure(
1506 "[tools.retry] is not a table",
1507 ))?;
1508
1509 let mut changed_count = 0usize;
1510
1511 if let Some(retries) = max_retries
1512 && !retry_table.contains_key("max_attempts")
1513 {
1514 retry_table.insert(
1515 "max_attempts",
1516 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1517 );
1518 changed_count += 1;
1519 }
1520
1521 if let Some(secs) = budget_secs
1522 && !retry_table.contains_key("budget_secs")
1523 {
1524 retry_table.insert(
1525 "budget_secs",
1526 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1527 );
1528 changed_count += 1;
1529 }
1530
1531 if changed_count > 0 {
1532 eprintln!(
1533 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1534 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1535 );
1536 }
1537
1538 Ok(MigrationResult {
1539 output: doc.to_string(),
1540 changed_count,
1541 sections_changed: if changed_count > 0 {
1542 vec!["tools.retry".to_owned()]
1543 } else {
1544 Vec::new()
1545 },
1546 })
1547}
1548
1549pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1558 if toml_src.contains("database_url") {
1560 return Ok(MigrationResult {
1561 output: toml_src.to_owned(),
1562 changed_count: 0,
1563 sections_changed: Vec::new(),
1564 });
1565 }
1566
1567 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1568
1569 if !doc.contains_key("memory") {
1571 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1572 }
1573
1574 let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1575 # Leave empty and store the actual URL in the vault:\n\
1576 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1577 # database_url = \"\"\n";
1578 let raw = doc.to_string();
1579 let output = format!("{raw}{comment}");
1580
1581 Ok(MigrationResult {
1582 output,
1583 changed_count: 1,
1584 sections_changed: vec!["memory.database_url".to_owned()],
1585 })
1586}
1587
1588pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1597 if toml_src.contains("transactional") {
1599 return Ok(MigrationResult {
1600 output: toml_src.to_owned(),
1601 changed_count: 0,
1602 sections_changed: Vec::new(),
1603 });
1604 }
1605
1606 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1607
1608 let tools_shell_exists = doc
1609 .get("tools")
1610 .and_then(toml_edit::Item::as_table)
1611 .is_some_and(|t| t.contains_key("shell"));
1612 if !tools_shell_exists {
1613 return Ok(MigrationResult {
1615 output: toml_src.to_owned(),
1616 changed_count: 0,
1617 sections_changed: Vec::new(),
1618 });
1619 }
1620
1621 let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1622 # transactional = false\n\
1623 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1624 # auto_rollback = false # rollback when exit code >= 2\n\
1625 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1626 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1627 let raw = doc.to_string();
1628 let output = format!("{raw}{comment}");
1629
1630 Ok(MigrationResult {
1631 output,
1632 changed_count: 1,
1633 sections_changed: vec!["tools.shell.transactional".to_owned()],
1634 })
1635}
1636
1637pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1643 if toml_src.contains("budget_hint_enabled") {
1645 return Ok(MigrationResult {
1646 output: toml_src.to_owned(),
1647 changed_count: 0,
1648 sections_changed: Vec::new(),
1649 });
1650 }
1651
1652 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1653 if !doc.contains_key("agent") {
1654 return Ok(MigrationResult {
1655 output: toml_src.to_owned(),
1656 changed_count: 0,
1657 sections_changed: Vec::new(),
1658 });
1659 }
1660
1661 let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1662 # budget_hint_enabled = true\n";
1663 let raw = doc.to_string();
1664 let output = format!("{raw}{comment}");
1665
1666 Ok(MigrationResult {
1667 output,
1668 changed_count: 1,
1669 sections_changed: vec!["agent.budget_hint_enabled".to_owned()],
1670 })
1671}
1672
1673pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1682 if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1684 return Ok(MigrationResult {
1685 output: toml_src.to_owned(),
1686 changed_count: 0,
1687 sections_changed: Vec::new(),
1688 });
1689 }
1690
1691 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1692 if !doc.contains_key("memory") {
1693 return Ok(MigrationResult {
1694 output: toml_src.to_owned(),
1695 changed_count: 0,
1696 sections_changed: Vec::new(),
1697 });
1698 }
1699
1700 let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1701 # [memory.forgetting]\n\
1702 # enabled = false\n\
1703 # decay_rate = 0.1 # per-sweep importance decay\n\
1704 # forgetting_floor = 0.05 # prune below this score\n\
1705 # sweep_interval_secs = 7200 # run every 2 hours\n\
1706 # sweep_batch_size = 500\n\
1707 # protect_recent_hours = 24\n\
1708 # protect_min_access_count = 3\n";
1709 let raw = doc.to_string();
1710 let output = format!("{raw}{comment}");
1711
1712 Ok(MigrationResult {
1713 output,
1714 changed_count: 1,
1715 sections_changed: vec!["memory.forgetting".to_owned()],
1716 })
1717}
1718
1719pub fn migrate_compression_predictor_config(
1728 toml_src: &str,
1729) -> Result<MigrationResult, MigrateError> {
1730 let has_active = toml_src.contains("[memory.compression.predictor]");
1733 let has_commented = toml_src.contains("# [memory.compression.predictor]");
1734 if !has_active && !has_commented {
1735 return Ok(MigrationResult {
1736 output: toml_src.to_owned(),
1737 changed_count: 0,
1738 sections_changed: Vec::new(),
1739 });
1740 }
1741
1742 let mut output_lines: Vec<&str> = Vec::new();
1746 let mut in_predictor = false;
1747 for line in toml_src.lines() {
1748 let trimmed = line.trim();
1749 if trimmed == "[memory.compression.predictor]"
1751 || trimmed == "# [memory.compression.predictor]"
1752 {
1753 in_predictor = true;
1754 continue;
1755 }
1756 if in_predictor && trimmed.starts_with('[') && !trimmed.starts_with("# [") {
1758 in_predictor = false;
1759 }
1760 if !in_predictor {
1761 output_lines.push(line);
1762 }
1763 }
1764 let mut output = output_lines.join("\n");
1766 if toml_src.ends_with('\n') {
1767 output.push('\n');
1768 }
1769
1770 Ok(MigrationResult {
1771 output,
1772 changed_count: 1,
1773 sections_changed: vec!["memory.compression.predictor".to_owned()],
1774 })
1775}
1776
1777pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1783 if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1785 return Ok(MigrationResult {
1786 output: toml_src.to_owned(),
1787 changed_count: 0,
1788 sections_changed: Vec::new(),
1789 });
1790 }
1791
1792 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1793 if !doc.contains_key("memory") {
1794 return Ok(MigrationResult {
1795 output: toml_src.to_owned(),
1796 changed_count: 0,
1797 sections_changed: Vec::new(),
1798 });
1799 }
1800
1801 let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1802 # [memory.microcompact]\n\
1803 # enabled = false\n\
1804 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1805 # keep_recent = 3 # always keep this many recent outputs intact\n";
1806 let raw = doc.to_string();
1807 let output = format!("{raw}{comment}");
1808
1809 Ok(MigrationResult {
1810 output,
1811 changed_count: 1,
1812 sections_changed: vec!["memory.microcompact".to_owned()],
1813 })
1814}
1815
1816pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1822 if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1824 return Ok(MigrationResult {
1825 output: toml_src.to_owned(),
1826 changed_count: 0,
1827 sections_changed: Vec::new(),
1828 });
1829 }
1830
1831 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1832 if !doc.contains_key("memory") {
1833 return Ok(MigrationResult {
1834 output: toml_src.to_owned(),
1835 changed_count: 0,
1836 sections_changed: Vec::new(),
1837 });
1838 }
1839
1840 let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1841 # [memory.autodream]\n\
1842 # enabled = false\n\
1843 # min_sessions = 5 # sessions since last consolidation\n\
1844 # min_hours = 8 # hours since last consolidation\n\
1845 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1846 # max_iterations = 5\n";
1847 let raw = doc.to_string();
1848 let output = format!("{raw}{comment}");
1849
1850 Ok(MigrationResult {
1851 output,
1852 changed_count: 1,
1853 sections_changed: vec!["memory.autodream".to_owned()],
1854 })
1855}
1856
1857pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1863 use toml_edit::{Item, Table};
1864
1865 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1866
1867 if doc.contains_key("magic_docs") {
1868 return Ok(MigrationResult {
1869 output: toml_src.to_owned(),
1870 changed_count: 0,
1871 sections_changed: Vec::new(),
1872 });
1873 }
1874
1875 doc.insert("magic_docs", Item::Table(Table::new()));
1876 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1877 # [magic_docs]\n\
1878 # enabled = false\n\
1879 # min_turns_between_updates = 10\n\
1880 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1881 # max_iterations = 3\n";
1882 doc.remove("magic_docs");
1884 let raw = doc.to_string();
1886 let output = format!("{raw}\n{comment}");
1887
1888 Ok(MigrationResult {
1889 output,
1890 changed_count: 1,
1891 sections_changed: vec!["magic_docs".to_owned()],
1892 })
1893}
1894
1895pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1904 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1905
1906 if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1907 return Ok(MigrationResult {
1908 output: toml_src.to_owned(),
1909 changed_count: 0,
1910 sections_changed: Vec::new(),
1911 });
1912 }
1913
1914 let comment = "\n\
1915 # Profiling and distributed tracing (requires --features profiling). All\n\
1916 # instrumentation points are zero-overhead when the feature is absent.\n\
1917 # [telemetry]\n\
1918 # enabled = false\n\
1919 # backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1920 # trace_dir = \".local/traces\"\n\
1921 # include_args = false\n\
1922 # service_name = \"zeph-agent\"\n\
1923 # sample_rate = 1.0\n\
1924 # otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1925
1926 let raw = doc.to_string();
1927 let output = format!("{raw}{comment}");
1928
1929 Ok(MigrationResult {
1930 output,
1931 changed_count: 1,
1932 sections_changed: vec!["telemetry".to_owned()],
1933 })
1934}
1935
1936pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1945 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1947 return Ok(MigrationResult {
1948 output: toml_src.to_owned(),
1949 changed_count: 0,
1950 sections_changed: Vec::new(),
1951 });
1952 }
1953
1954 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1955
1956 if !doc.contains_key("agent") {
1959 return Ok(MigrationResult {
1960 output: toml_src.to_owned(),
1961 changed_count: 0,
1962 sections_changed: Vec::new(),
1963 });
1964 }
1965
1966 let comment = "\n\
1967 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1968 # [agent.supervisor]\n\
1969 # enrichment_limit = 4\n\
1970 # telemetry_limit = 8\n\
1971 # abort_enrichment_on_turn = false\n";
1972
1973 let raw = doc.to_string();
1974 let output = format!("{raw}{comment}");
1975
1976 Ok(MigrationResult {
1977 output,
1978 changed_count: 1,
1979 sections_changed: vec!["agent.supervisor".to_owned()],
1980 })
1981}
1982
1983pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1993 if toml_src.contains("otel_filter") {
1995 return Ok(MigrationResult {
1996 output: toml_src.to_owned(),
1997 changed_count: 0,
1998 sections_changed: Vec::new(),
1999 });
2000 }
2001
2002 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
2003
2004 if !doc.contains_key("telemetry") {
2007 return Ok(MigrationResult {
2008 output: toml_src.to_owned(),
2009 changed_count: 0,
2010 sections_changed: Vec::new(),
2011 });
2012 }
2013
2014 let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
2015 (tonic=warn etc.) are always appended (#2997).\n\
2016 # otel_filter = \"info\"\n";
2017 let raw = doc.to_string();
2018 let output = insert_after_section(&raw, "telemetry", comment);
2020
2021 Ok(MigrationResult {
2022 output,
2023 changed_count: 1,
2024 sections_changed: vec!["telemetry.otel_filter".to_owned()],
2025 })
2026}
2027
2028pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2034 if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
2035 return Ok(MigrationResult {
2036 output: toml_src.to_owned(),
2037 changed_count: 0,
2038 sections_changed: Vec::new(),
2039 });
2040 }
2041
2042 let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
2043 # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
2044 # [tools.egress]\n\
2045 # enabled = true # set to false to disable all egress event recording\n\
2046 # log_blocked = true # record scheme/domain/SSRF-blocked requests\n\
2047 # log_response_bytes = true\n\
2048 # log_hosts_to_tui = true\n";
2049
2050 let mut output = toml_src.to_owned();
2051 output.push_str(comment);
2052 Ok(MigrationResult {
2053 output,
2054 changed_count: 1,
2055 sections_changed: vec!["tools.egress".to_owned()],
2056 })
2057}
2058
2059pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2065 if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
2066 return Ok(MigrationResult {
2067 output: toml_src.to_owned(),
2068 changed_count: 0,
2069 sections_changed: Vec::new(),
2070 });
2071 }
2072
2073 let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2074 # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2075 # [security.vigil]\n\
2076 # enabled = true # master switch; false bypasses VIGIL entirely\n\
2077 # strict_mode = false # true: block (replace with sentinel); false: truncate+annotate\n\
2078 # sanitize_max_chars = 2048\n\
2079 # extra_patterns = [] # operator-supplied additional injection patterns (max 64)\n\
2080 # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2081
2082 let mut output = toml_src.to_owned();
2083 output.push_str(comment);
2084 Ok(MigrationResult {
2085 output,
2086 changed_count: 1,
2087 sections_changed: vec!["security.vigil".to_owned()],
2088 })
2089}
2090
2091pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2103 let doc: DocumentMut = toml_src.parse()?;
2104 let already_present = doc
2105 .get("tools")
2106 .and_then(|t| t.as_table())
2107 .and_then(|t| t.get("sandbox"))
2108 .is_some();
2109 if already_present || toml_src.contains("# [tools.sandbox]") {
2112 return Ok(MigrationResult {
2113 output: toml_src.to_owned(),
2114 changed_count: 0,
2115 sections_changed: Vec::new(),
2116 });
2117 }
2118
2119 let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2120 # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2121 # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2122 # [tools.sandbox]\n\
2123 # enabled = false # set to true to wrap shell commands\n\
2124 # profile = \"workspace\" # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2125 # backend = \"auto\" # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2126 # strict = true # fail startup if sandbox init fails (fail-closed)\n\
2127 # allow_read = [] # additional read-allowed absolute paths\n\
2128 # allow_write = [] # additional write-allowed absolute paths\n";
2129
2130 let mut output = toml_src.to_owned();
2131 output.push_str(comment);
2132 Ok(MigrationResult {
2133 output,
2134 changed_count: 1,
2135 sections_changed: vec!["tools.sandbox".to_owned()],
2136 })
2137}
2138
2139pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2148 if !toml_src.contains("[tools.sandbox]") {
2150 return Ok(MigrationResult {
2151 output: toml_src.to_owned(),
2152 changed_count: 0,
2153 sections_changed: Vec::new(),
2154 });
2155 }
2156
2157 let already_has_denied =
2158 toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
2159 let already_has_fail =
2160 toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
2161
2162 if already_has_denied && already_has_fail {
2163 return Ok(MigrationResult {
2164 output: toml_src.to_owned(),
2165 changed_count: 0,
2166 sections_changed: Vec::new(),
2167 });
2168 }
2169
2170 let mut comment = String::new();
2171 if !already_has_denied {
2172 comment.push_str(
2173 "# denied_domains = [] \
2174 # hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
2175 );
2176 }
2177 if !already_has_fail {
2178 comment.push_str(
2179 "# fail_if_unavailable = false \
2180 # abort startup when no effective OS sandbox is available\n",
2181 );
2182 }
2183
2184 let output = toml_src.replacen(
2185 "[tools.sandbox]\n",
2186 &format!("[tools.sandbox]\n{comment}"),
2187 1,
2188 );
2189 Ok(MigrationResult {
2190 output,
2191 changed_count: 1,
2192 sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
2193 })
2194}
2195
2196pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2205 if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2207 return Ok(MigrationResult {
2208 output: toml_src.to_owned(),
2209 changed_count: 0,
2210 sections_changed: Vec::new(),
2211 });
2212 }
2213
2214 if !toml_src.contains("[orchestration]") {
2216 return Ok(MigrationResult {
2217 output: toml_src.to_owned(),
2218 changed_count: 0,
2219 sections_changed: Vec::new(),
2220 });
2221 }
2222
2223 let comment = "# persistence_enabled = true \
2225 # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2226 let output = toml_src.replacen(
2227 "[orchestration]\n",
2228 &format!("[orchestration]\n{comment}"),
2229 1,
2230 );
2231 Ok(MigrationResult {
2232 output,
2233 changed_count: 1,
2234 sections_changed: vec!["orchestration.persistence_enabled".to_owned()],
2235 })
2236}
2237
2238pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2246 if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2248 return Ok(MigrationResult {
2249 output: toml_src.to_owned(),
2250 changed_count: 0,
2251 sections_changed: Vec::new(),
2252 });
2253 }
2254
2255 let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2256 # [session.recap]\n\
2257 # on_resume = true\n\
2258 # max_tokens = 200\n\
2259 # provider = \"\"\n\
2260 # max_input_messages = 20\n";
2261 let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2262 let output = format!("{raw}{comment}");
2263
2264 Ok(MigrationResult {
2265 output,
2266 changed_count: 1,
2267 sections_changed: vec!["session.recap".to_owned()],
2268 })
2269}
2270
2271pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2279 if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2281 return Ok(MigrationResult {
2282 output: toml_src.to_owned(),
2283 changed_count: 0,
2284 sections_changed: Vec::new(),
2285 });
2286 }
2287
2288 if !toml_src.contains("[mcp]") {
2290 return Ok(MigrationResult {
2291 output: toml_src.to_owned(),
2292 changed_count: 0,
2293 sections_changed: Vec::new(),
2294 });
2295 }
2296
2297 if !toml_src.contains("[mcp]\n") {
2299 return Ok(MigrationResult {
2300 output: toml_src.to_owned(),
2301 changed_count: 0,
2302 sections_changed: Vec::new(),
2303 });
2304 }
2305
2306 let comment = "# elicitation_enabled = false \
2307 # opt-in: servers may request user input mid-task (#3141)\n\
2308 # elicitation_timeout = 120 # seconds to wait for user response\n\
2309 # elicitation_queue_capacity = 16 # beyond this limit requests are auto-declined\n\
2310 # elicitation_warn_sensitive_fields = true # warn before prompting for password/token/etc.\n";
2311 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2312
2313 Ok(MigrationResult {
2314 output,
2315 changed_count: 1,
2316 sections_changed: vec!["mcp.elicitation".to_owned()],
2317 })
2318}
2319
2320pub fn migrate_mcp_max_connect_attempts(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2330 if toml_src.contains("max_connect_attempts") {
2331 return Ok(MigrationResult {
2332 output: toml_src.to_owned(),
2333 changed_count: 0,
2334 sections_changed: Vec::new(),
2335 });
2336 }
2337
2338 if !toml_src.contains("[mcp]\n") {
2339 return Ok(MigrationResult {
2340 output: toml_src.to_owned(),
2341 changed_count: 0,
2342 sections_changed: Vec::new(),
2343 });
2344 }
2345
2346 let comment = "# max_connect_attempts = 3 \
2347 # startup retry count per server (1 = no retry, 1..=10, backoff: 500ms/1s/2s/...)\n";
2348 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2349
2350 Ok(MigrationResult {
2351 output,
2352 changed_count: 1,
2353 sections_changed: vec!["mcp".to_owned()],
2354 })
2355}
2356
2357pub fn migrate_mcp_retry_and_tool_timeout(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2367 let has_backoff = toml_src.contains("startup_retry_backoff_ms");
2368 let has_timeout = toml_src.contains("tool_timeout_secs");
2369
2370 if (has_backoff && has_timeout) || !toml_src.contains("[mcp]\n") {
2371 return Ok(MigrationResult {
2372 output: toml_src.to_owned(),
2373 changed_count: 0,
2374 sections_changed: Vec::new(),
2375 });
2376 }
2377
2378 let mut output = toml_src.to_owned();
2379 let mut changed = false;
2380
2381 if !has_backoff {
2382 let comment = "# startup_retry_backoff_ms = 1000 \
2383 # base backoff ms between startup retries (doubles per attempt, cap 8000 ms)\n";
2384 output = output.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2385 changed = true;
2386 }
2387
2388 if !has_timeout {
2389 let comment = "# tool_timeout_secs = 60 \
2390 # per-call timeout for tools/call requests; when absent, per-server timeout is used\n";
2391 output = output.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2392 changed = true;
2393 }
2394
2395 if changed {
2396 Ok(MigrationResult {
2397 output,
2398 changed_count: 1,
2399 sections_changed: vec!["mcp".to_owned()],
2400 })
2401 } else {
2402 Ok(MigrationResult {
2403 output: toml_src.to_owned(),
2404 changed_count: 0,
2405 sections_changed: Vec::new(),
2406 })
2407 }
2408}
2409
2410pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2421 if toml_src
2423 .lines()
2424 .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2425 {
2426 return Ok(MigrationResult {
2427 output: toml_src.to_owned(),
2428 changed_count: 0,
2429 sections_changed: Vec::new(),
2430 });
2431 }
2432
2433 let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2434 # [quality]\n\
2435 # self_check = false # enable post-response self-check\n\
2436 # trigger = \"has_retrieval\" # has_retrieval | always | manual\n\
2437 # latency_budget_ms = 4000 # hard ceiling for the whole pipeline\n\
2438 # proposer_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2439 # checker_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2440 # min_evidence = 0.6 # 0.0..1.0; below → flag assertion\n\
2441 # async_run = false # true = fire-and-forget (non-blocking)\n\
2442 # per_call_timeout_ms = 2000 # per-LLM-call timeout\n\
2443 # max_assertions = 12 # maximum assertions extracted from one response\n\
2444 # max_response_chars = 8000 # skip pipeline when response exceeds this\n\
2445 # cache_disabled_for_checker = true # suppress prompt-cache on Checker provider\n\
2446 # flag_marker = \"[verify]\" # marker appended when assertions are flagged\n";
2447 let output = format!("{toml_src}{comment}");
2448
2449 Ok(MigrationResult {
2450 output,
2451 changed_count: 1,
2452 sections_changed: vec!["quality".to_owned()],
2453 })
2454}
2455
2456pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2467 if toml_src
2468 .lines()
2469 .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2470 {
2471 return Ok(MigrationResult {
2472 output: toml_src.to_owned(),
2473 changed_count: 0,
2474 sections_changed: Vec::new(),
2475 });
2476 }
2477
2478 let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2479 # [acp.subagents]\n\
2480 # enabled = false\n\
2481 #\n\
2482 # [[acp.subagents.presets]]\n\
2483 # name = \"inner\" # identifier used in /subagent commands\n\
2484 # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2485 # # cwd = \"/path/to/agent\" # optional working directory\n\
2486 # # handshake_timeout_secs = 30 # initialize+session/new timeout\n\
2487 # # prompt_timeout_secs = 600 # single round-trip timeout\n";
2488 let output = format!("{toml_src}{comment}");
2489
2490 Ok(MigrationResult {
2491 output,
2492 changed_count: 1,
2493 sections_changed: vec!["acp.subagents".to_owned()],
2494 })
2495}
2496
2497pub fn migrate_hooks_permission_denied_config(
2508 toml_src: &str,
2509) -> Result<MigrationResult, MigrateError> {
2510 if toml_src.lines().any(|l| {
2511 l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2512 }) {
2513 return Ok(MigrationResult {
2514 output: toml_src.to_owned(),
2515 changed_count: 0,
2516 sections_changed: Vec::new(),
2517 });
2518 }
2519
2520 let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2521 # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2522 # [[hooks.permission_denied]]\n\
2523 # [hooks.permission_denied.action]\n\
2524 # type = \"command\"\n\
2525 # command = \"echo denied: $ZEPH_TOOL\"\n";
2526 let output = format!("{toml_src}{comment}");
2527
2528 Ok(MigrationResult {
2529 output,
2530 changed_count: 1,
2531 sections_changed: vec!["hooks.permission_denied".to_owned()],
2532 })
2533}
2534
2535pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2546 if toml_src.contains("retrieval_strategy")
2547 || toml_src.contains("[memory.graph.beam_search]")
2548 || toml_src.contains("# [memory.graph.beam_search]")
2549 {
2550 return Ok(MigrationResult {
2551 output: toml_src.to_owned(),
2552 changed_count: 0,
2553 sections_changed: Vec::new(),
2554 });
2555 }
2556
2557 let comment = "\n# [memory.graph] retrieval strategy options (#3311).\n\
2558 # retrieval_strategy = \"synapse\" # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2559 #\n\
2560 # [memory.graph.beam_search] # active when retrieval_strategy = \"beam_search\"\n\
2561 # beam_width = 10 # top-K candidates kept per hop\n\
2562 #\n\
2563 # [memory.graph.watercircles] # active when retrieval_strategy = \"watercircles\"\n\
2564 # ring_limit = 0 # max facts per ring; 0 = auto\n\
2565 #\n\
2566 # [memory.graph.experience] # experience memory recording\n\
2567 # enabled = false\n\
2568 # evolution_sweep_enabled = false\n\
2569 # confidence_prune_threshold = 0.1 # prune edges below this threshold\n\
2570 # evolution_sweep_interval = 50 # turns between sweeps\n";
2571 let output = format!("{toml_src}{comment}");
2572
2573 Ok(MigrationResult {
2574 output,
2575 changed_count: 1,
2576 sections_changed: vec!["memory.graph.retrieval".to_owned()],
2577 })
2578}
2579
2580pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2591 if toml_src
2592 .lines()
2593 .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2594 {
2595 return Ok(MigrationResult {
2596 output: toml_src.to_owned(),
2597 changed_count: 0,
2598 sections_changed: Vec::new(),
2599 });
2600 }
2601
2602 let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2603 # [scheduler.daemon]\n\
2604 # pid_file = \"/tmp/zeph-scheduler.pid\" # PID file path (must be on a local filesystem)\n\
2605 # log_file = \"/tmp/zeph-scheduler.log\" # daemon log file path (append-only; rotate externally)\n\
2606 # tick_secs = 60 # scheduler tick interval in seconds (clamped 5..=3600)\n\
2607 # shutdown_grace_secs = 30 # grace period after SIGTERM before process exits\n\
2608 # catch_up = true # replay missed cron tasks on daemon restart\n";
2609 let output = format!("{toml_src}{comment}");
2610
2611 Ok(MigrationResult {
2612 output,
2613 changed_count: 1,
2614 sections_changed: vec!["scheduler.daemon".to_owned()],
2615 })
2616}
2617
2618pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2629 if toml_src
2630 .lines()
2631 .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2632 {
2633 return Ok(MigrationResult {
2634 output: toml_src.to_owned(),
2635 changed_count: 0,
2636 sections_changed: Vec::new(),
2637 });
2638 }
2639
2640 let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2641 # [memory.retrieval]\n\
2642 # depth = 0 # ANN candidates fetched from the vector store, directly.\n\
2643 # # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2644 # # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2645 # search_prompt_template = \"\" # embedding query template; {query} = raw user query; empty = identity\n\
2646 # context_format = \"structured\" # structured | plain — memory snippet rendering format\n\
2647 # query_bias_correction = true # shift first-person queries towards user profile centroid (MM-F3)\n\
2648 # query_bias_profile_weight = 0.25 # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2649 # query_bias_centroid_ttl_secs = 300 # seconds before profile centroid cache is recomputed\n";
2650 let output = format!("{toml_src}{comment}");
2651
2652 Ok(MigrationResult {
2653 output,
2654 changed_count: 1,
2655 sections_changed: vec!["memory.retrieval".to_owned()],
2656 })
2657}
2658
2659pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2670 if toml_src
2671 .lines()
2672 .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2673 {
2674 return Ok(MigrationResult {
2675 output: toml_src.to_owned(),
2676 changed_count: 0,
2677 sections_changed: Vec::new(),
2678 });
2679 }
2680
2681 let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2682 # [memory.reasoning]\n\
2683 # enabled = false\n\
2684 # extract_provider = \"\" # SLM: self-judge (JSON response) — leave blank to use primary\n\
2685 # distill_provider = \"\" # SLM: strategy distillation — leave blank to use primary\n\
2686 # top_k = 3 # strategies injected per turn\n\
2687 # store_limit = 1000 # max rows in reasoning_strategies table\n\
2688 # context_budget_tokens = 500\n\
2689 # extraction_timeout_secs = 30\n\
2690 # distill_timeout_secs = 30\n\
2691 # max_messages = 6\n\
2692 # min_messages = 2\n\
2693 # max_message_chars = 2000\n";
2694 let output = format!("{toml_src}{comment}");
2695
2696 Ok(MigrationResult {
2697 output,
2698 changed_count: 1,
2699 sections_changed: vec!["memory.reasoning".to_owned()],
2700 })
2701}
2702
2703pub fn migrate_memory_reasoning_judge_config(
2715 toml_src: &str,
2716) -> Result<MigrationResult, MigrateError> {
2717 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2718 if !has_section {
2719 return Ok(MigrationResult {
2720 output: toml_src.to_owned(),
2721 changed_count: 0,
2722 sections_changed: Vec::new(),
2723 });
2724 }
2725
2726 let has_window = toml_src.lines().any(|l| {
2728 let t = l.trim().trim_start_matches('#').trim();
2729 t.starts_with("self_judge_window")
2730 });
2731 let has_min_chars = toml_src.lines().any(|l| {
2732 let t = l.trim().trim_start_matches('#').trim();
2733 t.starts_with("min_assistant_chars")
2734 });
2735 if has_window && has_min_chars {
2736 return Ok(MigrationResult {
2737 output: toml_src.to_owned(),
2738 changed_count: 0,
2739 sections_changed: Vec::new(),
2740 });
2741 }
2742
2743 let lines: Vec<&str> = toml_src.lines().collect();
2747 let mut section_start = None;
2748 let mut insert_after = None;
2749
2750 for (i, line) in lines.iter().enumerate() {
2751 if line.trim() == "[memory.reasoning]" {
2752 section_start = Some(i);
2753 }
2754 if let Some(start) = section_start {
2755 let trimmed = line.trim();
2756 if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2758 break;
2759 }
2760 insert_after = Some(i);
2761 }
2762 }
2763
2764 let Some(insert_idx) = insert_after else {
2765 return Ok(MigrationResult {
2766 output: toml_src.to_owned(),
2767 changed_count: 0,
2768 sections_changed: Vec::new(),
2769 });
2770 };
2771
2772 let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2773 let mut additions = Vec::new();
2774 if !has_window {
2775 additions.push(
2776 "# self_judge_window = 2 # max recent messages passed to self-judge (#3383)"
2777 .to_owned(),
2778 );
2779 }
2780 if !has_min_chars {
2781 additions.push(
2782 "# min_assistant_chars = 50 # skip self-judge for short replies (#3383)".to_owned(),
2783 );
2784 }
2785 for (offset, line) in additions.iter().enumerate() {
2786 new_lines.insert(insert_idx + 1 + offset, line.clone());
2787 }
2788
2789 let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2790 Ok(MigrationResult {
2791 output,
2792 changed_count: additions.len(),
2793 sections_changed: vec!["memory.reasoning".to_owned()],
2794 })
2795}
2796
2797pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2807 if toml_src
2808 .lines()
2809 .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2810 {
2811 return Ok(MigrationResult {
2812 output: toml_src.to_owned(),
2813 changed_count: 0,
2814 sections_changed: Vec::new(),
2815 });
2816 }
2817
2818 let comment = "\n# [memory.hebbian] # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2819 # [memory.hebbian]\n\
2820 # enabled = false # opt-in master switch; no DB writes when false\n\
2821 # hebbian_lr = 0.1 # weight increment per co-activation (0.01–0.5)\n";
2822 let output = format!("{toml_src}{comment}");
2823
2824 Ok(MigrationResult {
2825 output,
2826 changed_count: 1,
2827 sections_changed: vec!["memory.hebbian".to_owned()],
2828 })
2829}
2830
2831pub fn migrate_memory_hebbian_consolidation_config(
2843 toml_src: &str,
2844) -> Result<MigrationResult, MigrateError> {
2845 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2846
2847 if !has_section {
2848 return Ok(MigrationResult {
2849 output: toml_src.to_owned(),
2850 changed_count: 0,
2851 sections_changed: Vec::new(),
2852 });
2853 }
2854
2855 let has_interval = toml_src
2857 .lines()
2858 .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2859 let has_threshold = toml_src
2860 .lines()
2861 .any(|l| l.trim().starts_with("consolidation_threshold"));
2862 let has_provider = toml_src
2863 .lines()
2864 .any(|l| l.trim().starts_with("consolidate_provider"));
2865
2866 if has_interval && has_threshold && has_provider {
2867 return Ok(MigrationResult {
2868 output: toml_src.to_owned(),
2869 changed_count: 0,
2870 sections_changed: Vec::new(),
2871 });
2872 }
2873
2874 let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2875 # consolidation_interval_secs = 3600 # how often the sweep runs (0 = disabled)\n\
2876 # consolidation_threshold = 5.0 # degree × avg_weight score to qualify\n\
2877 # consolidate_provider = \"fast\" # provider name for LLM distillation\n\
2878 # max_candidates_per_sweep = 10\n\
2879 # consolidation_cooldown_secs = 86400 # re-consolidation cooldown per entity\n\
2880 # consolidation_prompt_timeout_secs = 30\n\
2881 # consolidation_max_neighbors = 20\n";
2882
2883 let output = format!("{toml_src}{extra}");
2884 Ok(MigrationResult {
2885 output,
2886 changed_count: 1,
2887 sections_changed: vec!["memory.hebbian".to_owned()],
2888 })
2889}
2890
2891pub fn migrate_memory_hebbian_spread_config(
2903 toml_src: &str,
2904) -> Result<MigrationResult, MigrateError> {
2905 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2906
2907 if !has_section {
2908 return Ok(MigrationResult {
2909 output: toml_src.to_owned(),
2910 changed_count: 0,
2911 sections_changed: Vec::new(),
2912 });
2913 }
2914
2915 let has_spreading = toml_src
2917 .lines()
2918 .any(|l| l.trim().starts_with("spreading_activation"));
2919 let has_depth = toml_src
2920 .lines()
2921 .any(|l| l.trim().starts_with("spread_depth"));
2922 let has_budget = toml_src
2923 .lines()
2924 .any(|l| l.trim().starts_with("step_budget_ms"));
2925 let has_embed_timeout = toml_src
2926 .lines()
2927 .any(|l| l.trim().starts_with("embed_timeout_secs"));
2928
2929 if has_spreading && has_depth && has_budget && has_embed_timeout {
2930 return Ok(MigrationResult {
2931 output: toml_src.to_owned(),
2932 changed_count: 0,
2933 sections_changed: Vec::new(),
2934 });
2935 }
2936
2937 let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2938 # spreading_activation = false # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2939 # spread_depth = 2 # BFS hops, clamped [1,6]\n\
2940 # spread_edge_types = [] # MAGMA edge types to traverse; empty = all\n\
2941 # step_budget_ms = 8 # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n\
2942 # embed_timeout_secs = 5 # timeout for the initial query embedding call (0 = disabled)\n";
2943
2944 let output = format!("{toml_src}{extra}");
2945 Ok(MigrationResult {
2946 output,
2947 changed_count: 1,
2948 sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2949 })
2950}
2951
2952pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2966 if toml_src
2967 .lines()
2968 .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2969 {
2970 return Ok(MigrationResult {
2971 output: toml_src.to_owned(),
2972 changed_count: 0,
2973 sections_changed: Vec::new(),
2974 });
2975 }
2976
2977 let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2978 # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2979 # ZEPH_TURN_LLM_REQUESTS.\n\
2980 # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2981 # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2982 # [[hooks.turn_complete]]\n\
2983 # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2984 # timeout_secs = 3\n\
2985 # fail_closed = false\n";
2986 let output = format!("{toml_src}{comment}");
2987
2988 Ok(MigrationResult {
2989 output,
2990 changed_count: 1,
2991 sections_changed: vec!["hooks.turn_complete".to_owned()],
2992 })
2993}
2994
2995pub fn migrate_focus_auto_consolidate_min_window(
3012 toml_src: &str,
3013) -> Result<MigrationResult, MigrateError> {
3014 if toml_src.contains("auto_consolidate_min_window") {
3015 return Ok(MigrationResult {
3016 output: toml_src.to_owned(),
3017 changed_count: 0,
3018 sections_changed: Vec::new(),
3019 });
3020 }
3021
3022 if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
3024 return Ok(MigrationResult {
3025 output: toml_src.to_owned(),
3026 changed_count: 0,
3027 sections_changed: Vec::new(),
3028 });
3029 }
3030
3031 let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
3032 runs (#3313).\n\
3033 # auto_consolidate_min_window = 6\n";
3034 let output = insert_after_section(toml_src, "agent.focus", comment);
3035
3036 Ok(MigrationResult {
3037 output,
3038 changed_count: 1,
3039 sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
3040 })
3041}
3042
3043pub fn migrate_session_provider_persistence(
3053 toml_src: &str,
3054) -> Result<MigrationResult, MigrateError> {
3055 if toml_src
3056 .lines()
3057 .any(|l| l.trim() == "[session]" || l.trim() == "# [session]")
3058 {
3059 return Ok(MigrationResult {
3060 output: toml_src.to_owned(),
3061 changed_count: 0,
3062 sections_changed: Vec::new(),
3063 });
3064 }
3065
3066 let comment = "\n# [session] — session-scoped user experience settings (#3308).\n\
3067 [session]\n\
3068 # Persist the last-used provider per channel across restarts.\n\
3069 # When true, the agent saves the active provider name to SQLite after each\n\
3070 # /provider switch and restores it on the next session start for the same channel.\n\
3071 provider_persistence = true\n";
3072 let output = format!("{toml_src}{comment}");
3073
3074 Ok(MigrationResult {
3075 output,
3076 changed_count: 1,
3077 sections_changed: vec!["session".to_owned()],
3078 })
3079}
3080
3081pub fn migrate_memory_retrieval_query_bias(
3093 toml_src: &str,
3094) -> Result<MigrationResult, MigrateError> {
3095 if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
3098 return Ok(MigrationResult {
3099 output: toml_src.to_owned(),
3100 changed_count: 0,
3101 sections_changed: Vec::new(),
3102 });
3103 }
3104
3105 if toml_src
3107 .lines()
3108 .any(|l| l.trim().starts_with("query_bias_correction"))
3109 {
3110 return Ok(MigrationResult {
3111 output: toml_src.to_owned(),
3112 changed_count: 0,
3113 sections_changed: Vec::new(),
3114 });
3115 }
3116
3117 let comment = "\n# MM-F3 (#3341): shift first-person queries toward the user profile centroid.\n\
3118 # No-op when the persona table is empty.\n\
3119 # query_bias_correction = true\n";
3120 let output = insert_after_section(toml_src, "memory.retrieval", comment);
3121
3122 Ok(MigrationResult {
3123 output,
3124 changed_count: 1,
3125 sections_changed: vec!["memory.retrieval.query_bias_correction".to_owned()],
3126 })
3127}
3128
3129pub fn migrate_memory_persona_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3138 if toml_src
3139 .lines()
3140 .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]")
3141 {
3142 return Ok(MigrationResult {
3143 output: toml_src.to_owned(),
3144 changed_count: 0,
3145 sections_changed: Vec::new(),
3146 });
3147 }
3148
3149 let comment = "\n# [memory.persona] — user persona profile for query-bias correction (#3341).\n\
3150 # Verified working in CI-604/CI-605. No-op when disabled.\n\
3151 # [memory.persona]\n\
3152 # enabled = true\n\
3153 # min_messages = 2 # minimum user messages before persona extraction fires\n\
3154 # min_confidence = 0.5 # minimum extraction confidence threshold (0.0–1.0)\n";
3155 let output = format!("{toml_src}{comment}");
3156
3157 Ok(MigrationResult {
3158 output,
3159 changed_count: 1,
3160 sections_changed: vec!["memory.persona".to_owned()],
3161 })
3162}
3163
3164pub fn migrate_qdrant_api_key(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3173 if toml_src.contains("qdrant_api_key") {
3174 return Ok(MigrationResult {
3175 output: toml_src.to_owned(),
3176 changed_count: 0,
3177 sections_changed: Vec::new(),
3178 });
3179 }
3180
3181 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3182
3183 if !doc.contains_key("memory") {
3184 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
3185 }
3186
3187 let comment = "\n# Qdrant API key (optional; required when connecting to remote/managed Qdrant clusters).\n\
3188 # Leave empty for local Qdrant instances. Store the actual key in the vault:\n\
3189 # zeph vault set ZEPH_QDRANT_API_KEY \"<key>\"\n\
3190 # qdrant_api_key = \"\"\n";
3191 let raw = doc.to_string();
3192 let output = format!("{raw}{comment}");
3193
3194 Ok(MigrationResult {
3195 output,
3196 changed_count: 1,
3197 sections_changed: vec!["memory.qdrant_api_key".to_owned()],
3198 })
3199}
3200
3201pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3207 if toml_src.contains("[goals]") {
3208 return Ok(MigrationResult {
3209 output: toml_src.to_owned(),
3210 changed_count: 0,
3211 sections_changed: Vec::new(),
3212 });
3213 }
3214
3215 let comment = "\n# Long-horizon goal lifecycle tracking (#3567).\n\
3216 # [goals]\n\
3217 # enabled = false\n\
3218 # inject_into_system_prompt = true\n\
3219 # max_text_chars = 2000\n\
3220 # max_history = 50\n";
3221
3222 Ok(MigrationResult {
3223 output: format!("{toml_src}{comment}"),
3224 changed_count: 1,
3225 sections_changed: vec!["goals".to_owned()],
3226 })
3227}
3228
3229pub fn migrate_tools_compression_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3235 if toml_src.contains("tools.compression")
3236 || toml_src.contains("[tools]\n") && toml_src.contains("compression")
3237 {
3238 return Ok(MigrationResult {
3239 output: toml_src.to_owned(),
3240 changed_count: 0,
3241 sections_changed: Vec::new(),
3242 });
3243 }
3244
3245 let comment = "\n# TACO self-evolving tool output compression (#3306).\n\
3246 # [tools.compression]\n\
3247 # enabled = false\n\
3248 # min_lines_to_compress = 10\n\
3249 # evolution_provider = \"\"\n\
3250 # evolution_min_interval_secs = 3600\n\
3251 # max_rules = 200\n";
3252
3253 Ok(MigrationResult {
3254 output: format!("{toml_src}{comment}"),
3255 changed_count: 1,
3256 sections_changed: vec!["tools.compression".to_owned()],
3257 })
3258}
3259
3260pub fn migrate_orchestration_orchestrator_provider(
3266 toml_src: &str,
3267) -> Result<MigrationResult, MigrateError> {
3268 if toml_src.contains("orchestrator_provider") {
3269 return Ok(MigrationResult {
3270 output: toml_src.to_owned(),
3271 changed_count: 0,
3272 sections_changed: Vec::new(),
3273 });
3274 }
3275
3276 let comment = "\n# Provider for scheduling-tier LLM calls (aggregation, predicate, verify fallback).\n\
3277 # Set to a cheap/fast model to reduce orchestration cost. Empty = primary provider.\n\
3278 # Add under the orchestration section in your config:\n\
3279 # orchestrator_provider = \"\"\n";
3280
3281 Ok(MigrationResult {
3282 output: format!("{toml_src}{comment}"),
3283 changed_count: 1,
3284 sections_changed: vec!["orchestration".to_owned()],
3285 })
3286}
3287
3288pub fn migrate_provider_max_concurrent(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3298 if toml_src.contains("max_concurrent") {
3299 return Ok(MigrationResult {
3300 output: toml_src.to_owned(),
3301 changed_count: 0,
3302 sections_changed: Vec::new(),
3303 });
3304 }
3305
3306 if !toml_src.contains("[[llm.providers]]") {
3307 return Ok(MigrationResult {
3308 output: toml_src.to_owned(),
3309 changed_count: 0,
3310 sections_changed: Vec::new(),
3311 });
3312 }
3313
3314 let comment = "\n# Optional: maximum concurrent sub-agent calls to this provider (admission control).\n\
3315 # Remove the comment to enable; omit or set to 0 for unlimited.\n\
3316 # max_concurrent = 4\n";
3317
3318 Ok(MigrationResult {
3319 output: format!("{toml_src}{comment}"),
3320 changed_count: 1,
3321 sections_changed: vec!["llm.providers".to_owned()],
3322 })
3323}
3324
3325pub trait Migration: Send + Sync {
3352 fn name(&self) -> &'static str;
3354
3355 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
3361}
3362
3363mod steps;
3364use steps::{
3365 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
3366 MigrateAutodreamConfig, MigrateCocoonProviderNotice, MigrateCompressionPredictorConfig,
3367 MigrateDatabaseUrl, MigrateEgressConfig, MigrateEmbedProviderRename, MigrateFiveSignalConfig,
3368 MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig,
3369 MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete,
3370 MigrateMagicDocsConfig, MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts,
3371 MigrateMcpRetryAndToolTimeout, MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryHebbian,
3372 MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
3373 MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
3374 MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, MigrateOrchestrationPersistence,
3375 MigrateOrchestratorProvider, MigrateOtelFilter, MigratePlannerModelToProvider,
3376 MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQualityConfig, MigrateSandboxConfig,
3377 MigrateSandboxEgressFilter, MigrateSchedulerDaemon, MigrateSessionProviderPersistence,
3378 MigrateSessionRecapConfig, MigrateShellTransactional, MigrateSttToProvider,
3379 MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
3380 MigrateTraceMetadata, MigrateVigilConfig,
3381};
3382
3383pub(crate) fn migrate_gonkagate_to_gonka(toml_src: &str) -> MigrationResult {
3389 const MARKER: &str = "# [migration] GonkaGate detected: consider migrating to type = \"gonka\"";
3391
3392 if !toml_src.contains("gonkagate") {
3393 return MigrationResult {
3394 output: toml_src.to_owned(),
3395 changed_count: 0,
3396 sections_changed: vec![],
3397 };
3398 }
3399
3400 let mut changed_count = 0;
3401 let mut lines: Vec<String> = toml_src.lines().map(str::to_owned).collect();
3402
3403 let indices: Vec<usize> = lines
3407 .iter()
3408 .enumerate()
3409 .filter(|(_, l)| l.contains("gonkagate"))
3410 .map(|(i, _)| i)
3411 .rev()
3412 .collect();
3413
3414 for gonka_idx in indices {
3415 let header_idx = (0..=gonka_idx)
3417 .rev()
3418 .find(|&i| lines[i].starts_with("[["))
3419 .unwrap_or(gonka_idx);
3420
3421 let already_marked = header_idx > 0 && lines[header_idx - 1].contains(MARKER);
3423 if already_marked {
3424 continue;
3425 }
3426
3427 lines.insert(
3428 header_idx,
3429 format!("{MARKER} (see docs/guides/gonka-native.md)"),
3430 );
3431 changed_count += 1;
3432 }
3433
3434 let output = lines.join("\n");
3435 let output = if toml_src.ends_with('\n') {
3436 format!("{output}\n")
3437 } else {
3438 output
3439 };
3440
3441 MigrationResult {
3442 output,
3443 changed_count,
3444 sections_changed: if changed_count > 0 {
3445 vec!["llm".into()]
3446 } else {
3447 vec![]
3448 },
3449 }
3450}
3451
3452pub fn migrate_cocoon_provider_notice(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3461 Ok(MigrationResult {
3462 output: toml_src.to_owned(),
3463 changed_count: 0,
3464 sections_changed: vec![],
3465 })
3466}
3467
3468pub fn migrate_trace_metadata(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3475 if toml_src.contains("trace_metadata") {
3476 return Ok(MigrationResult {
3477 output: toml_src.to_owned(),
3478 changed_count: 0,
3479 sections_changed: Vec::new(),
3480 });
3481 }
3482
3483 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3484
3485 if !doc.contains_key("telemetry") {
3486 return Ok(MigrationResult {
3487 output: toml_src.to_owned(),
3488 changed_count: 0,
3489 sections_changed: Vec::new(),
3490 });
3491 }
3492
3493 let comment = "\n# Custom key/value pairs attached as OpenTelemetry resource attributes (#4160).\n\
3494 # Appear on every exported span. Values are plaintext — do not store secrets here.\n\
3495 # [telemetry.trace_metadata]\n\
3496 # \"deployment.environment\" = \"production\"\n\
3497 # \"vcs.revision\" = \"abc1234\"\n";
3498 let raw = doc.to_string();
3499 let output = insert_after_section(&raw, "telemetry", comment);
3500
3501 Ok(MigrationResult {
3502 output,
3503 changed_count: 1,
3504 sections_changed: vec!["telemetry.trace_metadata".to_owned()],
3505 })
3506}
3507
3508pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3525 std::sync::LazyLock::new(|| {
3526 vec![
3527 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3529 Box::new(MigratePlannerModelToProvider),
3530 Box::new(MigrateMcpTrustLevels),
3531 Box::new(MigrateAgentRetryToToolsRetry),
3532 Box::new(MigrateDatabaseUrl),
3533 Box::new(MigrateShellTransactional),
3534 Box::new(MigrateAgentBudgetHint),
3535 Box::new(MigrateForgettingConfig),
3536 Box::new(MigrateCompressionPredictorConfig),
3537 Box::new(MigrateMicrocompactConfig),
3538 Box::new(MigrateAutodreamConfig),
3539 Box::new(MigrateMagicDocsConfig),
3540 Box::new(MigrateTelemetryConfig),
3541 Box::new(MigrateSupervisorConfig),
3542 Box::new(MigrateOtelFilter),
3543 Box::new(MigrateEgressConfig),
3544 Box::new(MigrateVigilConfig),
3545 Box::new(MigrateSandboxConfig),
3546 Box::new(MigrateSandboxEgressFilter),
3547 Box::new(MigrateOrchestrationPersistence),
3548 Box::new(MigrateSessionRecapConfig),
3549 Box::new(MigrateMcpElicitationConfig),
3550 Box::new(MigrateQualityConfig),
3551 Box::new(MigrateAcpSubagentsConfig),
3552 Box::new(MigrateHooksPermissionDeniedConfig),
3553 Box::new(MigrateMemoryGraph),
3555 Box::new(MigrateSchedulerDaemon),
3556 Box::new(MigrateMemoryRetrieval),
3557 Box::new(MigrateMemoryReasoning),
3558 Box::new(MigrateMemoryReasoningJudge),
3559 Box::new(MigrateMemoryHebbian),
3560 Box::new(MigrateMemoryHebbianConsolidation),
3561 Box::new(MigrateMemoryHebbianSpread),
3562 Box::new(MigrateHooksTurnComplete),
3563 Box::new(MigrateFocusAutoConsolidateMinWindow),
3564 Box::new(MigrateSessionProviderPersistence),
3566 Box::new(MigrateMemoryRetrievalQueryBias),
3567 Box::new(MigrateMemoryPersonaConfig),
3568 Box::new(MigrateQdrantApiKey),
3570 Box::new(MigrateMcpMaxConnectAttempts),
3572 Box::new(MigrateGoalsConfig),
3574 Box::new(MigrateToolsCompressionConfig),
3575 Box::new(MigrateOrchestratorProvider),
3577 Box::new(MigrateProviderMaxConcurrent),
3579 Box::new(MigrateGonkagateToGonka),
3581 Box::new(MigrateCocoonProviderNotice),
3583 Box::new(MigrateTraceMetadata),
3585 Box::new(MigrateFiveSignalConfig),
3587 Box::new(MigrateEmbedProviderRename),
3589 Box::new(MigrateMcpRetryAndToolTimeout),
3591 ]
3592 });
3593
3594pub fn migrate_five_signal_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3603 if toml_src.contains("[memory.five_signal]") || toml_src.contains("# [memory.five_signal]") {
3604 return Ok(MigrationResult {
3605 output: toml_src.to_owned(),
3606 changed_count: 0,
3607 sections_changed: Vec::new(),
3608 });
3609 }
3610
3611 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3612 if !doc.contains_key("memory") {
3613 return Ok(MigrationResult {
3614 output: toml_src.to_owned(),
3615 changed_count: 0,
3616 sections_changed: Vec::new(),
3617 });
3618 }
3619
3620 let comment = "\n# Five-signal SYNAPSE retrieval (#4374). Disabled by default.\n\
3621 # [memory.five_signal]\n\
3622 # enabled = false\n\
3623 # w_recency = 0.35\n\
3624 # w_relevance = 0.35\n\
3625 # w_frequency = 0.15\n\
3626 # w_causal = 0.10\n\
3627 # w_novelty = 0.05\n\
3628 # causal_bfs_max_depth = 10\n\
3629 # neutral_causal_distance = 5\n\
3630 # novelty_decay_rate = 0.1\n\
3631 #\n\
3632 # [memory.five_signal.consolidation_daemon]\n\
3633 # enabled = false\n\
3634 # interval_seconds = 7200\n\
3635 # batch_size = 500\n\
3636 # promotion_score_threshold = 0.70\n\
3637 # demotion_score_threshold = 0.20\n\
3638 # top_k_per_run = 500\n";
3639 let raw = doc.to_string();
3640 let output = format!("{raw}{comment}");
3641
3642 Ok(MigrationResult {
3643 output,
3644 changed_count: 1,
3645 sections_changed: vec!["memory.five_signal".to_owned()],
3646 })
3647}
3648
3649pub fn migrate_embed_provider_rename(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3661 let has_old =
3663 toml_src.contains("embed_provider") || toml_src.contains("trace_extraction_embed_provider");
3664 if !has_old {
3665 return Ok(MigrationResult {
3666 output: toml_src.to_owned(),
3667 changed_count: 0,
3668 sections_changed: Vec::new(),
3669 });
3670 }
3671
3672 let mut changed_count = 0usize;
3676 let mut sections_changed = Vec::new();
3677
3678 let output = toml_src
3679 .lines()
3680 .map(|line| {
3681 let trimmed = line.trim_start();
3682 if trimmed.starts_with("trace_extraction_embed_provider") {
3684 let replaced = line.replacen(
3685 "trace_extraction_embed_provider",
3686 "trace_extraction_embedding_provider",
3687 1,
3688 );
3689 changed_count += 1;
3690 if !sections_changed.contains(&"learning".to_owned()) {
3691 sections_changed.push("learning".to_owned());
3692 }
3693 return replaced;
3694 }
3695 if trimmed.starts_with("embed_provider") {
3696 let replaced = line.replacen("embed_provider", "embedding_provider", 1);
3697 changed_count += 1;
3698 return replaced;
3699 }
3700 line.to_owned()
3701 })
3702 .collect::<Vec<_>>()
3703 .join("\n");
3704
3705 let output = if toml_src.ends_with('\n') && !output.ends_with('\n') {
3707 format!("{output}\n")
3708 } else {
3709 output
3710 };
3711
3712 Ok(MigrationResult {
3713 output,
3714 changed_count,
3715 sections_changed,
3716 })
3717}
3718
3719#[cfg(test)]
3721fn make_formatted_str(s: &str) -> Value {
3722 use toml_edit::Formatted;
3723 Value::String(Formatted::new(s.to_owned()))
3724}
3725
3726#[cfg(test)]
3727mod tests {
3728 use super::*;
3729
3730 #[test]
3731 fn migrations_registry_has_all_steps() {
3732 assert_eq!(
3733 MIGRATIONS.len(),
3734 50,
3735 "MIGRATIONS registry must contain all 50 sequential steps"
3736 );
3737 for m in MIGRATIONS.iter() {
3738 assert!(
3739 !m.name().is_empty(),
3740 "each migration must have a non-empty name"
3741 );
3742 }
3743 }
3744
3745 #[test]
3746 fn migrations_registry_applies_to_empty_config() {
3747 let mut toml = String::new();
3748 for m in MIGRATIONS.iter() {
3749 toml = m
3750 .apply(&toml)
3751 .expect("migration must not fail on empty config")
3752 .output;
3753 }
3754 toml.parse::<toml_edit::DocumentMut>()
3756 .expect("registry output must be valid TOML");
3757 }
3758
3759 #[test]
3760 fn empty_config_gets_sections_as_comments() {
3761 let migrator = ConfigMigrator::new();
3762 let result = migrator.migrate("").expect("migrate empty");
3763 assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
3765 assert!(
3767 result.output.contains("[agent]") || result.output.contains("# [agent]"),
3768 "expected agent section in output, got:\n{}",
3769 result.output
3770 );
3771 }
3772
3773 #[test]
3774 fn existing_values_not_overwritten() {
3775 let user = r#"
3776[agent]
3777name = "MyAgent"
3778max_tool_iterations = 5
3779"#;
3780 let migrator = ConfigMigrator::new();
3781 let result = migrator.migrate(user).expect("migrate");
3782 assert!(
3784 result.output.contains("name = \"MyAgent\""),
3785 "user value should be preserved"
3786 );
3787 assert!(
3788 result.output.contains("max_tool_iterations = 5"),
3789 "user value should be preserved"
3790 );
3791 assert!(
3793 !result.output.contains("# max_tool_iterations = 10"),
3794 "already-set key should not appear as comment"
3795 );
3796 }
3797
3798 #[test]
3799 fn missing_nested_key_added_as_comment() {
3800 let user = r#"
3802[memory]
3803sqlite_path = ".zeph/data/zeph.db"
3804"#;
3805 let migrator = ConfigMigrator::new();
3806 let result = migrator.migrate(user).expect("migrate");
3807 assert!(
3809 result.output.contains("# history_limit"),
3810 "missing key should be added as comment, got:\n{}",
3811 result.output
3812 );
3813 }
3814
3815 #[test]
3816 fn unknown_user_keys_preserved() {
3817 let user = r#"
3818[agent]
3819name = "Test"
3820my_custom_key = "preserved"
3821"#;
3822 let migrator = ConfigMigrator::new();
3823 let result = migrator.migrate(user).expect("migrate");
3824 assert!(
3825 result.output.contains("my_custom_key = \"preserved\""),
3826 "custom user keys must not be removed"
3827 );
3828 }
3829
3830 #[test]
3831 fn idempotent() {
3832 let migrator = ConfigMigrator::new();
3833 let first = migrator
3834 .migrate("[agent]\nname = \"Zeph\"\n")
3835 .expect("first migrate");
3836 let second = migrator.migrate(&first.output).expect("second migrate");
3837 assert_eq!(
3838 first.output, second.output,
3839 "idempotent: full output must be identical on second run"
3840 );
3841 }
3842
3843 #[test]
3844 fn malformed_input_returns_error() {
3845 let migrator = ConfigMigrator::new();
3846 let err = migrator
3847 .migrate("[[invalid toml [[[")
3848 .expect_err("should error");
3849 assert!(
3850 matches!(err, MigrateError::Parse(_)),
3851 "expected Parse error"
3852 );
3853 }
3854
3855 #[test]
3856 fn array_of_tables_preserved() {
3857 let user = r#"
3858[mcp]
3859allowed_commands = ["npx"]
3860
3861[[mcp.servers]]
3862id = "my-server"
3863command = "npx"
3864args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3865"#;
3866 let migrator = ConfigMigrator::new();
3867 let result = migrator.migrate(user).expect("migrate");
3868 assert!(
3870 result.output.contains("[[mcp.servers]]"),
3871 "array-of-tables entries must be preserved"
3872 );
3873 assert!(result.output.contains("id = \"my-server\""));
3874 }
3875
3876 #[test]
3877 fn canonical_ordering_applied() {
3878 let user = r#"
3880[memory]
3881sqlite_path = ".zeph/data/zeph.db"
3882
3883[agent]
3884name = "Test"
3885"#;
3886 let migrator = ConfigMigrator::new();
3887 let result = migrator.migrate(user).expect("migrate");
3888 let agent_pos = result.output.find("[agent]");
3890 let memory_pos = result.output.find("[memory]");
3891 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3892 assert!(a < m, "agent section should precede memory section");
3893 }
3894 }
3895
3896 #[test]
3897 fn value_to_toml_string_formats_correctly() {
3898 use toml_edit::Formatted;
3899
3900 let s = make_formatted_str("hello");
3901 assert_eq!(value_to_toml_string(&s), "\"hello\"");
3902
3903 let i = Value::Integer(Formatted::new(42_i64));
3904 assert_eq!(value_to_toml_string(&i), "42");
3905
3906 let b = Value::Boolean(Formatted::new(true));
3907 assert_eq!(value_to_toml_string(&b), "true");
3908
3909 let f = Value::Float(Formatted::new(1.0_f64));
3910 assert_eq!(value_to_toml_string(&f), "1.0");
3911
3912 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3913 assert_eq!(value_to_toml_string(&f2), "3.14");
3914
3915 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3916 let arr_val = Value::Array(arr);
3917 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3918
3919 let empty_arr = Value::Array(Array::new());
3920 assert_eq!(value_to_toml_string(&empty_arr), "[]");
3921 }
3922
3923 #[test]
3924 fn idempotent_full_output_unchanged() {
3925 let migrator = ConfigMigrator::new();
3927 let first = migrator
3928 .migrate("[agent]\nname = \"Zeph\"\n")
3929 .expect("first migrate");
3930 let second = migrator.migrate(&first.output).expect("second migrate");
3931 assert_eq!(
3932 first.output, second.output,
3933 "full output string must be identical after second migration pass"
3934 );
3935 }
3936
3937 #[test]
3938 fn full_config_produces_zero_additions() {
3939 let reference = include_str!("../../config/default.toml");
3941 let migrator = ConfigMigrator::new();
3942 let result = migrator.migrate(reference).expect("migrate reference");
3943 assert_eq!(
3944 result.changed_count, 0,
3945 "migrating the canonical reference should add nothing (changed_count = {})",
3946 result.changed_count
3947 );
3948 assert!(
3949 result.sections_changed.is_empty(),
3950 "migrating the canonical reference should report no sections_changed: {:?}",
3951 result.sections_changed
3952 );
3953 }
3954
3955 #[test]
3956 fn empty_config_changed_count_is_positive() {
3957 let migrator = ConfigMigrator::new();
3959 let result = migrator.migrate("").expect("migrate empty");
3960 assert!(
3961 result.changed_count > 0,
3962 "empty config must report changed_count > 0"
3963 );
3964 }
3965
3966 #[test]
3969 fn security_without_guardrail_gets_guardrail_commented() {
3970 let user = "[security]\nredact_secrets = true\n";
3971 let migrator = ConfigMigrator::new();
3972 let result = migrator.migrate(user).expect("migrate");
3973 assert!(
3975 result.output.contains("guardrail"),
3976 "migration must add guardrail keys for configs without [security.guardrail]: \
3977 got:\n{}",
3978 result.output
3979 );
3980 }
3981
3982 #[test]
3983 fn migrate_reference_contains_tools_policy() {
3984 let reference = include_str!("../../config/default.toml");
3989 assert!(
3990 reference.contains("[tools.policy]"),
3991 "default.toml must contain [tools.policy] section so migrate-config can surface it"
3992 );
3993 assert!(
3994 reference.contains("enabled = false"),
3995 "tools.policy section must include enabled = false default"
3996 );
3997 }
3998
3999 #[test]
4000 fn migrate_reference_contains_probe_section() {
4001 let reference = include_str!("../../config/default.toml");
4004 assert!(
4005 reference.contains("[memory.compression.probe]"),
4006 "default.toml must contain [memory.compression.probe] section comment"
4007 );
4008 assert!(
4009 reference.contains("hard_fail_threshold"),
4010 "probe section must include hard_fail_threshold default"
4011 );
4012 }
4013
4014 #[test]
4017 fn migrate_llm_no_llm_section_is_noop() {
4018 let src = "[agent]\nname = \"Zeph\"\n";
4019 let result = migrate_llm_to_providers(src).expect("migrate");
4020 assert_eq!(result.changed_count, 0);
4021 assert_eq!(result.output, src);
4022 }
4023
4024 #[test]
4025 fn migrate_llm_already_new_format_is_noop() {
4026 let src = r#"
4027[llm]
4028[[llm.providers]]
4029type = "ollama"
4030model = "qwen3:8b"
4031"#;
4032 let result = migrate_llm_to_providers(src).expect("migrate");
4033 assert_eq!(result.changed_count, 0);
4034 }
4035
4036 #[test]
4037 fn migrate_llm_ollama_produces_providers_block() {
4038 let src = r#"
4039[llm]
4040provider = "ollama"
4041model = "qwen3:8b"
4042base_url = "http://localhost:11434"
4043embedding_model = "nomic-embed-text"
4044"#;
4045 let result = migrate_llm_to_providers(src).expect("migrate");
4046 assert!(
4047 result.output.contains("[[llm.providers]]"),
4048 "should contain [[llm.providers]]:\n{}",
4049 result.output
4050 );
4051 assert!(
4052 result.output.contains("type = \"ollama\""),
4053 "{}",
4054 result.output
4055 );
4056 assert!(
4057 result.output.contains("model = \"qwen3:8b\""),
4058 "{}",
4059 result.output
4060 );
4061 }
4062
4063 #[test]
4064 fn migrate_llm_claude_produces_providers_block() {
4065 let src = r#"
4066[llm]
4067provider = "claude"
4068
4069[llm.cloud]
4070model = "claude-sonnet-4-6"
4071max_tokens = 8192
4072server_compaction = true
4073"#;
4074 let result = migrate_llm_to_providers(src).expect("migrate");
4075 assert!(
4076 result.output.contains("[[llm.providers]]"),
4077 "{}",
4078 result.output
4079 );
4080 assert!(
4081 result.output.contains("type = \"claude\""),
4082 "{}",
4083 result.output
4084 );
4085 assert!(
4086 result.output.contains("model = \"claude-sonnet-4-6\""),
4087 "{}",
4088 result.output
4089 );
4090 assert!(
4091 result.output.contains("server_compaction = true"),
4092 "{}",
4093 result.output
4094 );
4095 }
4096
4097 #[test]
4098 fn migrate_llm_openai_copies_fields() {
4099 let src = r#"
4100[llm]
4101provider = "openai"
4102
4103[llm.openai]
4104base_url = "https://api.openai.com/v1"
4105model = "gpt-4o"
4106max_tokens = 4096
4107"#;
4108 let result = migrate_llm_to_providers(src).expect("migrate");
4109 assert!(
4110 result.output.contains("type = \"openai\""),
4111 "{}",
4112 result.output
4113 );
4114 assert!(
4115 result
4116 .output
4117 .contains("base_url = \"https://api.openai.com/v1\""),
4118 "{}",
4119 result.output
4120 );
4121 }
4122
4123 #[test]
4124 fn migrate_llm_gemini_copies_fields() {
4125 let src = r#"
4126[llm]
4127provider = "gemini"
4128
4129[llm.gemini]
4130model = "gemini-2.0-flash"
4131max_tokens = 8192
4132base_url = "https://generativelanguage.googleapis.com"
4133"#;
4134 let result = migrate_llm_to_providers(src).expect("migrate");
4135 assert!(
4136 result.output.contains("type = \"gemini\""),
4137 "{}",
4138 result.output
4139 );
4140 assert!(
4141 result.output.contains("model = \"gemini-2.0-flash\""),
4142 "{}",
4143 result.output
4144 );
4145 }
4146
4147 #[test]
4148 fn migrate_llm_compatible_copies_multiple_entries() {
4149 let src = r#"
4150[llm]
4151provider = "compatible"
4152
4153[[llm.compatible]]
4154name = "proxy-a"
4155base_url = "http://proxy-a:8080/v1"
4156model = "llama3"
4157max_tokens = 4096
4158
4159[[llm.compatible]]
4160name = "proxy-b"
4161base_url = "http://proxy-b:8080/v1"
4162model = "mistral"
4163max_tokens = 2048
4164"#;
4165 let result = migrate_llm_to_providers(src).expect("migrate");
4166 let count = result.output.matches("[[llm.providers]]").count();
4168 assert_eq!(
4169 count, 2,
4170 "expected 2 [[llm.providers]] blocks:\n{}",
4171 result.output
4172 );
4173 assert!(
4174 result.output.contains("name = \"proxy-a\""),
4175 "{}",
4176 result.output
4177 );
4178 assert!(
4179 result.output.contains("name = \"proxy-b\""),
4180 "{}",
4181 result.output
4182 );
4183 }
4184
4185 #[test]
4186 fn migrate_llm_mixed_format_errors() {
4187 let src = r#"
4189[llm]
4190provider = "ollama"
4191
4192[[llm.providers]]
4193type = "ollama"
4194"#;
4195 assert!(
4196 migrate_llm_to_providers(src).is_err(),
4197 "mixed format must return error"
4198 );
4199 }
4200
4201 #[test]
4204 fn stt_migration_no_stt_section_returns_unchanged() {
4205 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
4206 let result = migrate_stt_to_provider(src).unwrap();
4207 assert_eq!(result.changed_count, 0);
4208 assert_eq!(result.output, src);
4209 }
4210
4211 #[test]
4212 fn stt_migration_no_model_or_base_url_returns_unchanged() {
4213 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
4214 let result = migrate_stt_to_provider(src).unwrap();
4215 assert_eq!(result.changed_count, 0);
4216 }
4217
4218 #[test]
4219 fn stt_migration_moves_model_to_provider_entry() {
4220 let src = r#"
4221[llm]
4222
4223[[llm.providers]]
4224type = "openai"
4225name = "quality"
4226model = "gpt-5.4"
4227
4228[llm.stt]
4229provider = "quality"
4230model = "gpt-4o-mini-transcribe"
4231language = "en"
4232"#;
4233 let result = migrate_stt_to_provider(src).unwrap();
4234 assert_eq!(result.changed_count, 1);
4235 assert!(
4237 result.output.contains("stt_model"),
4238 "stt_model must be in output"
4239 );
4240 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4243 let stt = doc
4244 .get("llm")
4245 .and_then(toml_edit::Item::as_table)
4246 .and_then(|l| l.get("stt"))
4247 .and_then(toml_edit::Item::as_table)
4248 .unwrap();
4249 assert!(
4250 stt.get("model").is_none(),
4251 "model must be removed from [llm.stt]"
4252 );
4253 assert_eq!(
4254 stt.get("provider").and_then(toml_edit::Item::as_str),
4255 Some("quality")
4256 );
4257 }
4258
4259 #[test]
4260 fn stt_migration_creates_new_provider_when_no_match() {
4261 let src = r#"
4262[llm]
4263
4264[[llm.providers]]
4265type = "ollama"
4266name = "local"
4267model = "qwen3:8b"
4268
4269[llm.stt]
4270provider = "whisper"
4271model = "whisper-1"
4272base_url = "https://api.openai.com/v1"
4273language = "en"
4274"#;
4275 let result = migrate_stt_to_provider(src).unwrap();
4276 assert!(
4277 result.output.contains("openai-stt"),
4278 "new entry name must be openai-stt"
4279 );
4280 assert!(
4281 result.output.contains("stt_model"),
4282 "stt_model must be in output"
4283 );
4284 }
4285
4286 #[test]
4287 fn stt_migration_candle_whisper_creates_candle_entry() {
4288 let src = r#"
4289[llm]
4290
4291[llm.stt]
4292provider = "candle-whisper"
4293model = "openai/whisper-tiny"
4294language = "auto"
4295"#;
4296 let result = migrate_stt_to_provider(src).unwrap();
4297 assert!(
4298 result.output.contains("local-whisper"),
4299 "candle entry name must be local-whisper"
4300 );
4301 assert!(result.output.contains("candle"), "type must be candle");
4302 }
4303
4304 #[test]
4305 fn stt_migration_w2_assigns_explicit_name() {
4306 let src = r#"
4308[llm]
4309
4310[[llm.providers]]
4311type = "openai"
4312model = "gpt-5.4"
4313
4314[llm.stt]
4315provider = "openai"
4316model = "whisper-1"
4317language = "auto"
4318"#;
4319 let result = migrate_stt_to_provider(src).unwrap();
4320 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4321 let providers = doc
4322 .get("llm")
4323 .and_then(toml_edit::Item::as_table)
4324 .and_then(|l| l.get("providers"))
4325 .and_then(toml_edit::Item::as_array_of_tables)
4326 .unwrap();
4327 let entry = providers
4328 .iter()
4329 .find(|t| t.get("stt_model").is_some())
4330 .unwrap();
4331 assert!(
4333 entry.get("name").is_some(),
4334 "migrated entry must have explicit name"
4335 );
4336 }
4337
4338 #[test]
4339 fn stt_migration_removes_base_url_from_stt_table() {
4340 let src = r#"
4342[llm]
4343
4344[[llm.providers]]
4345type = "openai"
4346name = "quality"
4347model = "gpt-5.4"
4348
4349[llm.stt]
4350provider = "quality"
4351model = "whisper-1"
4352base_url = "https://api.openai.com/v1"
4353language = "en"
4354"#;
4355 let result = migrate_stt_to_provider(src).unwrap();
4356 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4357 let stt = doc
4358 .get("llm")
4359 .and_then(toml_edit::Item::as_table)
4360 .and_then(|l| l.get("stt"))
4361 .and_then(toml_edit::Item::as_table)
4362 .unwrap();
4363 assert!(
4364 stt.get("model").is_none(),
4365 "model must be removed from [llm.stt]"
4366 );
4367 assert!(
4368 stt.get("base_url").is_none(),
4369 "base_url must be removed from [llm.stt]"
4370 );
4371 }
4372
4373 #[test]
4374 fn migrate_planner_model_to_provider_with_field() {
4375 let input = r#"
4376[orchestration]
4377enabled = true
4378planner_model = "gpt-4o"
4379max_tasks = 20
4380"#;
4381 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4382 assert_eq!(result.changed_count, 1, "changed_count must be 1");
4383 assert!(
4384 !result.output.contains("planner_model = "),
4385 "planner_model key must be removed from output"
4386 );
4387 assert!(
4388 result.output.contains("# planner_provider"),
4389 "commented-out planner_provider entry must be present"
4390 );
4391 assert!(
4392 result.output.contains("gpt-4o"),
4393 "old value must appear in the comment"
4394 );
4395 assert!(
4396 result.output.contains("MIGRATED"),
4397 "comment must include MIGRATED marker"
4398 );
4399 }
4400
4401 #[test]
4402 fn migrate_planner_model_to_provider_no_op() {
4403 let input = r"
4404[orchestration]
4405enabled = true
4406max_tasks = 20
4407";
4408 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4409 assert_eq!(
4410 result.changed_count, 0,
4411 "changed_count must be 0 when field is absent"
4412 );
4413 assert_eq!(
4414 result.output, input,
4415 "output must equal input when nothing to migrate"
4416 );
4417 }
4418
4419 #[test]
4420 fn migrate_error_invalid_structure_formats_correctly() {
4421 let err = MigrateError::InvalidStructure("test sentinel");
4426 assert!(
4427 matches!(err, MigrateError::InvalidStructure(_)),
4428 "variant must match"
4429 );
4430 let msg = err.to_string();
4431 assert!(
4432 msg.contains("invalid TOML structure"),
4433 "error message must mention 'invalid TOML structure', got: {msg}"
4434 );
4435 assert!(
4436 msg.contains("test sentinel"),
4437 "message must include reason: {msg}"
4438 );
4439 }
4440
4441 #[test]
4444 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
4445 let src = r#"
4446[mcp]
4447allowed_commands = ["npx"]
4448
4449[[mcp.servers]]
4450id = "srv-a"
4451command = "npx"
4452args = ["-y", "some-mcp"]
4453
4454[[mcp.servers]]
4455id = "srv-b"
4456command = "npx"
4457args = ["-y", "other-mcp"]
4458"#;
4459 let result = migrate_mcp_trust_levels(src).expect("migrate");
4460 assert_eq!(
4461 result.changed_count, 2,
4462 "both entries must get trust_level added"
4463 );
4464 assert!(
4465 result
4466 .sections_changed
4467 .contains(&"mcp.servers.trust_level".to_owned()),
4468 "sections_changed must report mcp.servers.trust_level"
4469 );
4470 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
4472 assert_eq!(
4473 occurrences, 2,
4474 "each entry must have trust_level = \"trusted\""
4475 );
4476 }
4477
4478 #[test]
4479 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
4480 let src = r#"
4481[[mcp.servers]]
4482id = "srv-a"
4483command = "npx"
4484trust_level = "sandboxed"
4485tool_allowlist = ["read_file"]
4486
4487[[mcp.servers]]
4488id = "srv-b"
4489command = "npx"
4490"#;
4491 let result = migrate_mcp_trust_levels(src).expect("migrate");
4492 assert_eq!(
4494 result.changed_count, 1,
4495 "only entry without trust_level gets updated"
4496 );
4497 assert!(
4499 result.output.contains("trust_level = \"sandboxed\""),
4500 "existing trust_level must not be overwritten"
4501 );
4502 assert!(
4504 result.output.contains("trust_level = \"trusted\""),
4505 "entry without trust_level must get trusted"
4506 );
4507 }
4508
4509 #[test]
4510 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4511 let src = "[agent]\nname = \"Zeph\"\n";
4512 let result = migrate_mcp_trust_levels(src).expect("migrate");
4513 assert_eq!(result.changed_count, 0);
4514 assert!(result.sections_changed.is_empty());
4515 assert_eq!(result.output, src);
4516 }
4517
4518 #[test]
4519 fn migrate_mcp_trust_levels_no_servers_is_noop() {
4520 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4521 let result = migrate_mcp_trust_levels(src).expect("migrate");
4522 assert_eq!(result.changed_count, 0);
4523 assert!(result.sections_changed.is_empty());
4524 assert_eq!(result.output, src);
4525 }
4526
4527 #[test]
4528 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4529 let src = r#"
4530[[mcp.servers]]
4531id = "srv-a"
4532trust_level = "trusted"
4533
4534[[mcp.servers]]
4535id = "srv-b"
4536trust_level = "untrusted"
4537"#;
4538 let result = migrate_mcp_trust_levels(src).expect("migrate");
4539 assert_eq!(result.changed_count, 0);
4540 assert!(result.sections_changed.is_empty());
4541 }
4542
4543 #[test]
4544 fn migrate_database_url_adds_comment_when_absent() {
4545 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4546 let result = migrate_database_url(src).expect("migrate");
4547 assert_eq!(result.changed_count, 1);
4548 assert!(
4549 result
4550 .sections_changed
4551 .contains(&"memory.database_url".to_owned())
4552 );
4553 assert!(result.output.contains("# database_url = \"\""));
4554 }
4555
4556 #[test]
4557 fn migrate_database_url_is_noop_when_present() {
4558 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4559 let result = migrate_database_url(src).expect("migrate");
4560 assert_eq!(result.changed_count, 0);
4561 assert!(result.sections_changed.is_empty());
4562 assert_eq!(result.output, src);
4563 }
4564
4565 #[test]
4566 fn migrate_database_url_creates_memory_section_when_absent() {
4567 let src = "[agent]\nname = \"Zeph\"\n";
4568 let result = migrate_database_url(src).expect("migrate");
4569 assert_eq!(result.changed_count, 1);
4570 assert!(result.output.contains("# database_url = \"\""));
4571 }
4572
4573 #[test]
4576 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4577 let src = "[agent]\nname = \"Zeph\"\n";
4578 let result = migrate_agent_budget_hint(src).expect("migrate");
4579 assert_eq!(result.changed_count, 1);
4580 assert!(result.output.contains("budget_hint_enabled"));
4581 assert!(
4582 result
4583 .sections_changed
4584 .contains(&"agent.budget_hint_enabled".to_owned())
4585 );
4586 }
4587
4588 #[test]
4589 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4590 let src = "[llm]\nmodel = \"gpt-4o\"\n";
4591 let result = migrate_agent_budget_hint(src).expect("migrate");
4592 assert_eq!(result.changed_count, 0);
4593 assert_eq!(result.output, src);
4594 }
4595
4596 #[test]
4597 fn migrate_agent_budget_hint_already_present_is_noop() {
4598 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4599 let result = migrate_agent_budget_hint(src).expect("migrate");
4600 assert_eq!(result.changed_count, 0);
4601 assert_eq!(result.output, src);
4602 }
4603
4604 #[test]
4605 fn migrate_telemetry_config_empty_config_appends_comment_block() {
4606 let src = "[agent]\nname = \"Zeph\"\n";
4607 let result = migrate_telemetry_config(src).expect("migrate");
4608 assert_eq!(result.changed_count, 1);
4609 assert_eq!(result.sections_changed, vec!["telemetry"]);
4610 assert!(
4611 result.output.contains("# [telemetry]"),
4612 "expected commented-out [telemetry] block in output"
4613 );
4614 assert!(
4615 result.output.contains("enabled = false"),
4616 "expected enabled = false in telemetry comment block"
4617 );
4618 }
4619
4620 #[test]
4621 fn migrate_telemetry_config_existing_section_is_noop() {
4622 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4623 let result = migrate_telemetry_config(src).expect("migrate");
4624 assert_eq!(result.changed_count, 0);
4625 assert_eq!(result.output, src);
4626 }
4627
4628 #[test]
4629 fn migrate_telemetry_config_existing_comment_is_noop() {
4630 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4632 let result = migrate_telemetry_config(src).expect("migrate");
4633 assert_eq!(result.changed_count, 0);
4634 assert_eq!(result.output, src);
4635 }
4636
4637 #[test]
4640 fn migrate_otel_filter_already_present_is_noop() {
4641 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4643 let result = migrate_otel_filter(src).expect("migrate");
4644 assert_eq!(result.changed_count, 0);
4645 assert_eq!(result.output, src);
4646 }
4647
4648 #[test]
4649 fn migrate_otel_filter_commented_key_is_noop() {
4650 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4652 let result = migrate_otel_filter(src).expect("migrate");
4653 assert_eq!(result.changed_count, 0);
4654 assert_eq!(result.output, src);
4655 }
4656
4657 #[test]
4658 fn migrate_otel_filter_no_telemetry_section_is_noop() {
4659 let src = "[agent]\nname = \"Zeph\"\n";
4661 let result = migrate_otel_filter(src).expect("migrate");
4662 assert_eq!(result.changed_count, 0);
4663 assert_eq!(result.output, src);
4664 assert!(!result.output.contains("otel_filter"));
4665 }
4666
4667 #[test]
4668 fn migrate_otel_filter_injects_within_telemetry_section() {
4669 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4670 let result = migrate_otel_filter(src).expect("migrate");
4671 assert_eq!(result.changed_count, 1);
4672 assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4673 assert!(
4674 result.output.contains("otel_filter"),
4675 "otel_filter comment must appear"
4676 );
4677 let otel_pos = result
4679 .output
4680 .find("otel_filter")
4681 .expect("otel_filter present");
4682 let agent_pos = result.output.find("[agent]").expect("[agent] present");
4683 assert!(
4684 otel_pos < agent_pos,
4685 "otel_filter comment should appear before [agent] section"
4686 );
4687 }
4688
4689 #[test]
4690 fn sandbox_migration_adds_commented_section_when_absent() {
4691 let src = "[agent]\nname = \"Z\"\n";
4692 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4693 assert_eq!(result.changed_count, 1);
4694 assert!(result.output.contains("# [tools.sandbox]"));
4695 assert!(result.output.contains("# profile = \"workspace\""));
4696 }
4697
4698 #[test]
4699 fn sandbox_migration_noop_when_section_present() {
4700 let src = "[tools.sandbox]\nenabled = true\n";
4701 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4702 assert_eq!(result.changed_count, 0);
4703 }
4704
4705 #[test]
4706 fn sandbox_migration_noop_when_dotted_key_present() {
4707 let src = "[tools]\nsandbox = { enabled = true }\n";
4708 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4709 assert_eq!(result.changed_count, 0);
4710 }
4711
4712 #[test]
4713 fn sandbox_migration_false_positive_comment_does_not_block() {
4714 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
4716 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4717 assert_eq!(result.changed_count, 1);
4718 }
4719
4720 #[test]
4721 fn embedded_default_mentions_tools_sandbox() {
4722 let default_src = include_str!("../../config/default.toml");
4723 assert!(
4724 default_src.contains("tools.sandbox"),
4725 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
4726 );
4727 }
4728
4729 #[test]
4730 fn sandbox_migration_idempotent_on_own_output() {
4731 let base = "[agent]\nmodel = \"test\"\n";
4732 let first = migrate_sandbox_config(base).unwrap();
4733 assert_eq!(first.changed_count, 1);
4734 let second = migrate_sandbox_config(&first.output).unwrap();
4735 assert_eq!(second.changed_count, 0, "second run must not double-append");
4736 assert_eq!(second.output, first.output);
4737 }
4738
4739 #[test]
4740 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
4741 let base = "[agent]\nname = \"Zeph\"\n";
4742 let first = migrate_agent_budget_hint(base).unwrap();
4743 assert_eq!(first.changed_count, 1);
4744 let second = migrate_agent_budget_hint(&first.output).unwrap();
4745 assert_eq!(second.changed_count, 0, "second run must not double-append");
4746 assert_eq!(second.output, first.output);
4747 }
4748
4749 #[test]
4750 fn migrate_forgetting_config_idempotent_on_commented_output() {
4751 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4752 let first = migrate_forgetting_config(base).unwrap();
4753 assert_eq!(first.changed_count, 1);
4754 let second = migrate_forgetting_config(&first.output).unwrap();
4755 assert_eq!(second.changed_count, 0, "second run must not double-append");
4756 assert_eq!(second.output, first.output);
4757 }
4758
4759 #[test]
4760 fn migrate_microcompact_config_idempotent_on_commented_output() {
4761 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4762 let first = migrate_microcompact_config(base).unwrap();
4763 assert_eq!(first.changed_count, 1);
4764 let second = migrate_microcompact_config(&first.output).unwrap();
4765 assert_eq!(second.changed_count, 0, "second run must not double-append");
4766 assert_eq!(second.output, first.output);
4767 }
4768
4769 #[test]
4770 fn migrate_autodream_config_idempotent_on_commented_output() {
4771 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4772 let first = migrate_autodream_config(base).unwrap();
4773 assert_eq!(first.changed_count, 1);
4774 let second = migrate_autodream_config(&first.output).unwrap();
4775 assert_eq!(second.changed_count, 0, "second run must not double-append");
4776 assert_eq!(second.output, first.output);
4777 }
4778
4779 #[test]
4780 fn migrate_compression_predictor_strips_active_section() {
4781 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
4782 let result = migrate_compression_predictor_config(base).unwrap();
4783 assert!(!result.output.contains("[memory.compression.predictor]"));
4784 assert!(!result.output.contains("min_samples"));
4785 assert!(result.output.contains("[memory.other]"));
4786 assert_eq!(result.changed_count, 1);
4787 }
4788
4789 #[test]
4790 fn migrate_compression_predictor_strips_commented_section() {
4791 let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
4792 let result = migrate_compression_predictor_config(base).unwrap();
4793 assert!(!result.output.contains("compression.predictor"));
4794 assert!(result.output.contains("[memory.other]"));
4795 }
4796
4797 #[test]
4798 fn migrate_compression_predictor_idempotent() {
4799 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
4800 let first = migrate_compression_predictor_config(base).unwrap();
4801 let second = migrate_compression_predictor_config(&first.output).unwrap();
4802 assert_eq!(second.output, first.output);
4803 assert_eq!(second.changed_count, 0);
4804 }
4805
4806 #[test]
4807 fn migrate_compression_predictor_noop_when_absent() {
4808 let base = "[memory]\ndb_path = \"test\"\n";
4809 let result = migrate_compression_predictor_config(base).unwrap();
4810 assert_eq!(result.output, base);
4811 assert_eq!(result.changed_count, 0);
4812 }
4813
4814 #[test]
4815 fn migrate_database_url_idempotent_on_commented_output() {
4816 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4817 let first = migrate_database_url(base).unwrap();
4818 assert_eq!(first.changed_count, 1);
4819 let second = migrate_database_url(&first.output).unwrap();
4820 assert_eq!(second.changed_count, 0, "second run must not double-append");
4821 assert_eq!(second.output, first.output);
4822 }
4823
4824 #[test]
4825 fn migrate_shell_transactional_idempotent_on_commented_output() {
4826 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
4827 let first = migrate_shell_transactional(base).unwrap();
4828 assert_eq!(first.changed_count, 1);
4829 let second = migrate_shell_transactional(&first.output).unwrap();
4830 assert_eq!(second.changed_count, 0, "second run must not double-append");
4831 assert_eq!(second.output, first.output);
4832 }
4833
4834 #[test]
4835 fn migrate_otel_filter_idempotent_on_commented_output() {
4836 let base = "[telemetry]\nenabled = true\n";
4837 let first = migrate_otel_filter(base).unwrap();
4838 assert_eq!(first.changed_count, 1);
4839 let second = migrate_otel_filter(&first.output).unwrap();
4840 assert_eq!(second.changed_count, 0, "second run must not double-append");
4841 assert_eq!(second.output, first.output);
4842 }
4843
4844 #[test]
4845 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4846 let migrator = ConfigMigrator::new();
4847 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4848 let result = migrator.migrate(src).expect("migrate");
4849 let sec_body_start = result
4850 .output
4851 .find("[security.content_isolation]")
4852 .unwrap_or(0);
4853 let sec_body = &result.output[sec_body_start..];
4854 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4855 let sec_slice = &sec_body[..next_header];
4856 assert!(
4857 sec_slice.contains("# enabled"),
4858 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4859 );
4860 }
4861
4862 #[test]
4863 fn config_migrator_idempotent_on_realistic_config() {
4864 let base = r#"
4865[agent]
4866name = "Zeph"
4867
4868[memory]
4869db_path = "~/.zeph/memory.db"
4870soft_compaction_threshold = 0.6
4871
4872[index]
4873max_chunks = 12
4874
4875[tools]
4876[tools.shell]
4877allow_list = []
4878
4879[telemetry]
4880enabled = false
4881
4882[security]
4883[security.content_isolation]
4884enabled = true
4885"#;
4886 let migrator = ConfigMigrator::new();
4887 let first = migrator.migrate(base).expect("first migrate");
4888 let second = migrator.migrate(&first.output).expect("second migrate");
4889 assert_eq!(
4890 second.changed_count, 0,
4891 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4892 second.changed_count
4893 );
4894 assert_eq!(
4895 first.output, second.output,
4896 "output must be identical on second run"
4897 );
4898 for line in first.output.lines() {
4899 if line.starts_with('[') && !line.starts_with("[[") {
4900 assert!(
4901 !line.contains('#'),
4902 "section header must not have inline comment: {line:?}"
4903 );
4904 }
4905 }
4906 }
4907
4908 #[test]
4909 fn migrate_claude_prompt_cache_ttl_1h_survives() {
4910 let src = r#"
4911[llm]
4912provider = "claude"
4913
4914[llm.cloud]
4915model = "claude-sonnet-4-6"
4916prompt_cache_ttl = "1h"
4917"#;
4918 let result = migrate_llm_to_providers(src).expect("migrate");
4919 assert!(
4920 result.output.contains("prompt_cache_ttl = \"1h\""),
4921 "1h TTL must be preserved in migrated output:\n{}",
4922 result.output
4923 );
4924 }
4925
4926 #[test]
4927 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4928 let src = r#"
4929[llm]
4930provider = "claude"
4931
4932[llm.cloud]
4933model = "claude-sonnet-4-6"
4934prompt_cache_ttl = "ephemeral"
4935"#;
4936 let result = migrate_llm_to_providers(src).expect("migrate");
4937 assert!(
4938 !result.output.contains("prompt_cache_ttl"),
4939 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4940 result.output
4941 );
4942 }
4943
4944 #[test]
4945 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4946 let src = r#"
4947[[llm.providers]]
4948type = "claude"
4949model = "claude-sonnet-4-6"
4950prompt_cache_ttl = "1h"
4951"#;
4952 let migrator = ConfigMigrator::new();
4953 let first = migrator.migrate(src).expect("first migrate");
4954 let second = migrator.migrate(&first.output).expect("second migrate");
4955 assert_eq!(
4956 first.output, second.output,
4957 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4958 );
4959 }
4960
4961 #[test]
4964 fn migrate_session_recap_adds_block_when_absent() {
4965 let src = "[agent]\nname = \"Zeph\"\n";
4966 let result = migrate_session_recap_config(src).expect("migrate");
4967 assert_eq!(result.changed_count, 1);
4968 assert!(
4969 result
4970 .sections_changed
4971 .contains(&"session.recap".to_owned())
4972 );
4973 assert!(result.output.contains("# [session.recap]"));
4974 assert!(result.output.contains("on_resume = true"));
4975 }
4976
4977 #[test]
4978 fn migrate_session_recap_idempotent_on_commented_block() {
4979 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4980 let result = migrate_session_recap_config(src).expect("migrate");
4981 assert_eq!(result.changed_count, 0);
4982 assert_eq!(result.output, src);
4983 }
4984
4985 #[test]
4986 fn migrate_session_recap_idempotent_on_active_section() {
4987 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4988 let result = migrate_session_recap_config(src).expect("migrate");
4989 assert_eq!(result.changed_count, 0);
4990 assert_eq!(result.output, src);
4991 }
4992
4993 #[test]
4996 fn migrate_mcp_elicitation_adds_keys_when_absent() {
4997 let src = "[mcp]\nallowed_commands = []\n";
4998 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4999 assert_eq!(result.changed_count, 1);
5000 assert!(
5001 result
5002 .sections_changed
5003 .contains(&"mcp.elicitation".to_owned())
5004 );
5005 assert!(result.output.contains("# elicitation_enabled = false"));
5006 assert!(result.output.contains("# elicitation_timeout = 120"));
5007 }
5008
5009 #[test]
5010 fn migrate_mcp_elicitation_idempotent_when_key_present() {
5011 let src = "[mcp]\nelicitation_enabled = true\n";
5012 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5013 assert_eq!(result.changed_count, 0);
5014 assert_eq!(result.output, src);
5015 }
5016
5017 #[test]
5018 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
5019 let src = "[agent]\nname = \"Zeph\"\n";
5020 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5021 assert_eq!(result.changed_count, 0);
5022 assert_eq!(result.output, src);
5023 }
5024
5025 #[test]
5026 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
5027 let src = "[mcp]";
5029 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5030 assert_eq!(result.changed_count, 0);
5031 assert_eq!(result.output, src);
5032 }
5033
5034 #[test]
5037 fn migrate_quality_adds_block_when_absent() {
5038 let src = "[agent]\nname = \"Zeph\"\n";
5039 let result = migrate_quality_config(src).expect("migrate");
5040 assert_eq!(result.changed_count, 1);
5041 assert!(result.sections_changed.contains(&"quality".to_owned()));
5042 assert!(result.output.contains("# [quality]"));
5043 assert!(result.output.contains("self_check = false"));
5044 assert!(result.output.contains("trigger = \"has_retrieval\""));
5045 }
5046
5047 #[test]
5048 fn migrate_quality_idempotent_on_commented_block() {
5049 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
5050 let result = migrate_quality_config(src).expect("migrate");
5051 assert_eq!(result.changed_count, 0);
5052 assert_eq!(result.output, src);
5053 }
5054
5055 #[test]
5056 fn migrate_quality_idempotent_on_active_section() {
5057 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
5058 let result = migrate_quality_config(src).expect("migrate");
5059 assert_eq!(result.changed_count, 0);
5060 assert_eq!(result.output, src);
5061 }
5062
5063 #[test]
5066 fn migrate_acp_subagents_adds_block_when_absent() {
5067 let src = "[agent]\nname = \"Zeph\"\n";
5068 let result = migrate_acp_subagents_config(src).expect("migrate");
5069 assert_eq!(result.changed_count, 1);
5070 assert!(
5071 result
5072 .sections_changed
5073 .contains(&"acp.subagents".to_owned())
5074 );
5075 assert!(result.output.contains("# [acp.subagents]"));
5076 assert!(result.output.contains("enabled = false"));
5077 }
5078
5079 #[test]
5080 fn migrate_acp_subagents_idempotent_on_existing_block() {
5081 let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
5082 let result = migrate_acp_subagents_config(src).expect("migrate");
5083 assert_eq!(result.changed_count, 0);
5084 assert_eq!(result.output, src);
5085 }
5086
5087 #[test]
5090 fn migrate_hooks_permission_denied_adds_block_when_absent() {
5091 let src = "[agent]\nname = \"Zeph\"\n";
5092 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
5093 assert_eq!(result.changed_count, 1);
5094 assert!(
5095 result
5096 .sections_changed
5097 .contains(&"hooks.permission_denied".to_owned())
5098 );
5099 assert!(result.output.contains("# [[hooks.permission_denied]]"));
5100 assert!(result.output.contains("ZEPH_TOOL"));
5101 }
5102
5103 #[test]
5104 fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
5105 let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
5106 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
5107 assert_eq!(result.changed_count, 0);
5108 assert_eq!(result.output, src);
5109 }
5110
5111 #[test]
5114 fn migrate_memory_graph_adds_block_when_absent() {
5115 let src = "[agent]\nname = \"Zeph\"\n";
5116 let result = migrate_memory_graph_config(src).expect("migrate");
5117 assert_eq!(result.changed_count, 1);
5118 assert!(
5119 result
5120 .sections_changed
5121 .contains(&"memory.graph.retrieval".to_owned())
5122 );
5123 assert!(result.output.contains("retrieval_strategy"));
5124 assert!(result.output.contains("# [memory.graph.beam_search]"));
5125 }
5126
5127 #[test]
5128 fn migrate_memory_graph_idempotent_on_existing_block() {
5129 let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
5130 let result = migrate_memory_graph_config(src).expect("migrate");
5131 assert_eq!(result.changed_count, 0);
5132 assert_eq!(result.output, src);
5133 }
5134
5135 #[test]
5138 fn migrate_scheduler_daemon_adds_block_when_absent() {
5139 let src = "[agent]\nname = \"Zeph\"\n";
5140 let result = migrate_scheduler_daemon_config(src).expect("migrate");
5141 assert_eq!(result.changed_count, 1);
5142 assert!(
5143 result
5144 .sections_changed
5145 .contains(&"scheduler.daemon".to_owned())
5146 );
5147 assert!(result.output.contains("# [scheduler.daemon]"));
5148 assert!(result.output.contains("pid_file"));
5149 assert!(result.output.contains("tick_secs = 60"));
5150 assert!(result.output.contains("shutdown_grace_secs = 30"));
5151 assert!(result.output.contains("catch_up = true"));
5152 }
5153
5154 #[test]
5155 fn migrate_scheduler_daemon_idempotent_on_existing_block() {
5156 let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
5157 let result = migrate_scheduler_daemon_config(src).expect("migrate");
5158 assert_eq!(result.changed_count, 0);
5159 assert_eq!(result.output, src);
5160 }
5161
5162 #[test]
5165 fn migrate_memory_retrieval_adds_block_when_absent() {
5166 let src = "[agent]\nname = \"Zeph\"\n";
5167 let result = migrate_memory_retrieval_config(src).expect("migrate");
5168 assert_eq!(result.changed_count, 1);
5169 assert!(
5170 result
5171 .sections_changed
5172 .contains(&"memory.retrieval".to_owned())
5173 );
5174 assert!(result.output.contains("# [memory.retrieval]"));
5175 assert!(result.output.contains("depth = 0"));
5176 assert!(result.output.contains("context_format"));
5177 }
5178
5179 #[test]
5180 fn migrate_memory_retrieval_idempotent_on_active_section() {
5181 let src = "[memory.retrieval]\ndepth = 40\n";
5182 let result = migrate_memory_retrieval_config(src).expect("migrate");
5183 assert_eq!(result.changed_count, 0);
5184 assert_eq!(result.output, src);
5185 }
5186
5187 #[test]
5188 fn migrate_memory_retrieval_idempotent_on_commented_section() {
5189 let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
5190 let result = migrate_memory_retrieval_config(src).expect("migrate");
5191 assert_eq!(result.changed_count, 0);
5192 assert_eq!(result.output, src);
5193 }
5194
5195 #[test]
5198 fn migrate_adds_pr4_acp_keys_commented() {
5199 let migrator = ConfigMigrator::new();
5200 let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
5201 let out = migrator.migrate(input).expect("migrate");
5202 assert!(
5203 out.output.contains("# additional_directories = []"),
5204 "expected commented additional_directories; got:\n{}",
5205 out.output
5206 );
5207 assert!(
5208 out.output.contains("# auth_methods = [\"agent\"]"),
5209 "expected commented auth_methods; got:\n{}",
5210 out.output
5211 );
5212 assert!(
5213 out.output.contains("# message_ids_enabled = true"),
5214 "expected commented message_ids_enabled; got:\n{}",
5215 out.output
5216 );
5217 }
5218
5219 #[test]
5222 fn migrate_memory_reasoning_adds_block_when_absent() {
5223 let input = "[agent]\nmodel = \"gpt-4o\"\n";
5224 let result = migrate_memory_reasoning_config(input).unwrap();
5225 assert_eq!(result.changed_count, 1);
5226 assert!(
5227 result
5228 .sections_changed
5229 .contains(&"memory.reasoning".to_owned())
5230 );
5231 assert!(result.output.contains("# [memory.reasoning]"));
5232 assert!(result.output.contains("extraction_timeout_secs = 30"));
5233 assert!(result.output.contains("max_message_chars = 2000"));
5234 }
5235
5236 #[test]
5237 fn migrate_memory_reasoning_idempotent_on_existing_block() {
5238 let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
5239 let result = migrate_memory_reasoning_config(input).unwrap();
5240 assert_eq!(result.changed_count, 0);
5241 assert!(result.sections_changed.is_empty());
5242 assert_eq!(result.output, input);
5243 }
5244
5245 #[test]
5248 fn migrate_hooks_turn_complete_adds_block_when_absent() {
5249 let input = "[agent]\nmodel = \"gpt-4o\"\n";
5250 let result = migrate_hooks_turn_complete_config(input).unwrap();
5251 assert_eq!(result.changed_count, 1);
5252 assert!(
5253 result
5254 .sections_changed
5255 .contains(&"hooks.turn_complete".to_owned())
5256 );
5257 assert!(result.output.contains("# [[hooks.turn_complete]]"));
5258 assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
5259 assert!(result.output.contains("timeout_secs = 3"));
5260 }
5261
5262 #[test]
5263 fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
5264 let input =
5265 "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
5266 let result = migrate_hooks_turn_complete_config(input).unwrap();
5267 assert_eq!(result.changed_count, 0);
5268 assert!(result.sections_changed.is_empty());
5269 assert_eq!(result.output, input);
5270 }
5271
5272 #[test]
5276 fn migrate_focus_auto_consolidate_injects_inside_section() {
5277 let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
5278 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5279 assert_eq!(result.changed_count, 1);
5280 let comment_pos = result
5281 .output
5282 .find("auto_consolidate_min_window")
5283 .expect("comment must be present");
5284 let other_pos = result
5285 .output
5286 .find("[other]")
5287 .expect("[other] must be present");
5288 assert!(
5289 comment_pos < other_pos,
5290 "auto_consolidate_min_window comment must appear before [other] section"
5291 );
5292 }
5293
5294 #[test]
5295 fn migrate_focus_auto_consolidate_idempotent() {
5296 let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
5297 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5298 assert_eq!(result.changed_count, 0);
5299 assert_eq!(result.output, input);
5300 }
5301
5302 #[test]
5303 fn migrate_focus_auto_consolidate_noop_when_section_absent() {
5304 let input = "[agent]\nname = \"zeph\"\n";
5305 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5306 assert_eq!(result.changed_count, 0);
5307 assert_eq!(result.output, input);
5308 }
5309
5310 #[test]
5311 fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
5312 let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
5313 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5314 assert_eq!(result.changed_count, 0);
5315 assert_eq!(result.output, input);
5316 }
5317
5318 #[test]
5321 fn registry_has_fifty_entries() {
5322 assert_eq!(MIGRATIONS.len(), 50);
5323 }
5324
5325 #[test]
5326 fn registry_names_are_unique_and_non_empty() {
5327 let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5328 for name in &names {
5329 assert!(!name.is_empty(), "migration name must not be empty");
5330 }
5331 let mut deduped = names.clone();
5332 deduped.sort_unstable();
5333 deduped.dedup();
5334 assert_eq!(deduped.len(), names.len(), "migration names must be unique");
5335 }
5336
5337 #[test]
5338 fn registry_is_idempotent_on_empty_input() {
5339 const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
5342
5343 let mut toml = String::new();
5344 for m in MIGRATIONS.iter() {
5345 let result = m.apply(&toml).expect("registry migration must not fail");
5346 toml = result.output;
5347 }
5348 for m in MIGRATIONS.iter() {
5349 if COMMENT_ONLY.contains(&m.name()) {
5350 continue;
5351 }
5352 let result = m
5353 .apply(&toml)
5354 .expect("registry migration must not fail on second pass");
5355 assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
5356 }
5357 }
5358
5359 #[test]
5360 fn registry_preserves_order_matches_dispatch() {
5361 let expected = [
5363 "migrate_stt_to_provider",
5364 "migrate_planner_model_to_provider",
5365 "migrate_mcp_trust_levels",
5366 "migrate_agent_retry_to_tools_retry",
5367 "migrate_database_url",
5368 "migrate_shell_transactional",
5369 "migrate_agent_budget_hint",
5370 "migrate_forgetting_config",
5371 "migrate_compression_predictor_config",
5372 "migrate_microcompact_config",
5373 "migrate_autodream_config",
5374 "migrate_magic_docs_config",
5375 "migrate_telemetry_config",
5376 "migrate_supervisor_config",
5377 "migrate_otel_filter",
5378 "migrate_egress_config",
5379 "migrate_vigil_config",
5380 "migrate_sandbox_config",
5381 "migrate_sandbox_egress_filter",
5382 "migrate_orchestration_persistence",
5383 "migrate_session_recap_config",
5384 "migrate_mcp_elicitation_config",
5385 "migrate_quality_config",
5386 "migrate_acp_subagents_config",
5387 "migrate_hooks_permission_denied_config",
5388 "migrate_memory_graph_config",
5389 "migrate_scheduler_daemon_config",
5390 "migrate_memory_retrieval_config",
5391 "migrate_memory_reasoning_config",
5392 "migrate_memory_reasoning_judge_config",
5393 "migrate_memory_hebbian_config",
5394 "migrate_memory_hebbian_consolidation_config",
5395 "migrate_memory_hebbian_spread_config",
5396 "migrate_hooks_turn_complete_config",
5397 "migrate_focus_auto_consolidate_min_window",
5398 "migrate_session_provider_persistence",
5399 "migrate_memory_retrieval_query_bias",
5400 "migrate_memory_persona_config",
5401 "migrate_qdrant_api_key",
5402 "migrate_mcp_max_connect_attempts",
5403 "migrate_goals_config",
5404 "migrate_tools_compression_config",
5405 "migrate_orchestrator_provider",
5406 "migrate_provider_max_concurrent",
5407 "migrate_gonkagate_to_gonka",
5408 "migrate_cocoon_provider_notice",
5409 "migrate_trace_metadata",
5410 "migrate_five_signal_config",
5411 "migrate_embed_provider_rename",
5412 "migrate_mcp_retry_and_tool_timeout",
5413 ];
5414 let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5415 assert_eq!(actual, expected);
5416 }
5417
5418 #[test]
5421 fn migrate_trace_metadata_noop_when_already_present() {
5422 let src = "[telemetry]\nenabled = true\n\n[telemetry.trace_metadata]\n\"env\" = \"prod\"\n";
5423 let result = migrate_trace_metadata(src).unwrap();
5424 assert_eq!(result.changed_count, 0);
5425 assert_eq!(result.output, src);
5426 }
5427
5428 #[test]
5429 fn migrate_trace_metadata_noop_when_no_telemetry_section() {
5430 let src = "[agent]\nmax_turns = 10\n";
5431 let result = migrate_trace_metadata(src).unwrap();
5432 assert_eq!(result.changed_count, 0);
5433 assert_eq!(result.output, src);
5434 }
5435
5436 #[test]
5437 fn migrate_trace_metadata_injects_comment_when_telemetry_present() {
5438 let src = "[telemetry]\nenabled = true\nservice_name = \"zeph\"\n";
5439 let result = migrate_trace_metadata(src).unwrap();
5440 assert_eq!(result.changed_count, 1);
5441 assert!(result.output.contains("trace_metadata"));
5442 assert!(
5443 result
5444 .sections_changed
5445 .contains(&"telemetry.trace_metadata".to_owned())
5446 );
5447 let result2 = migrate_trace_metadata(&result.output).unwrap();
5449 assert_eq!(result2.changed_count, 0);
5450 }
5451
5452 #[test]
5455 fn migrate_qdrant_api_key_adds_comment_when_absent() {
5456 let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5457 let result = migrate_qdrant_api_key(src).expect("migrate");
5458 assert_eq!(result.changed_count, 1);
5459 assert!(
5460 result
5461 .sections_changed
5462 .contains(&"memory.qdrant_api_key".to_owned())
5463 );
5464 assert!(result.output.contains("# qdrant_api_key = \"\""));
5465 }
5466
5467 #[test]
5468 fn migrate_qdrant_api_key_is_noop_when_present() {
5469 let src =
5470 "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
5471 let result = migrate_qdrant_api_key(src).expect("migrate");
5472 assert_eq!(result.changed_count, 0);
5473 assert!(result.sections_changed.is_empty());
5474 assert_eq!(result.output, src);
5475 }
5476
5477 #[test]
5478 fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
5479 let src = "[agent]\nname = \"Zeph\"\n";
5480 let result = migrate_qdrant_api_key(src).expect("migrate");
5481 assert_eq!(result.changed_count, 1);
5482 assert!(result.output.contains("# qdrant_api_key = \"\""));
5483 }
5484
5485 #[test]
5486 fn migrate_qdrant_api_key_idempotent_on_commented_output() {
5487 let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5488 let first = migrate_qdrant_api_key(base).unwrap();
5489 assert_eq!(first.changed_count, 1);
5490 let second = migrate_qdrant_api_key(&first.output).unwrap();
5491 assert_eq!(second.changed_count, 0, "second run must not double-append");
5492 assert_eq!(second.output, first.output);
5493 }
5494
5495 #[test]
5496 fn migrate_mcp_max_connect_attempts_adds_comment_when_absent() {
5497 let src = "[mcp]\nallowed_commands = []\n";
5498 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5499 assert_eq!(result.changed_count, 1);
5500 assert!(
5501 result.output.contains("max_connect_attempts"),
5502 "output must mention max_connect_attempts"
5503 );
5504 }
5505
5506 #[test]
5507 fn migrate_mcp_max_connect_attempts_idempotent_when_present() {
5508 let src = "[mcp]\n# max_connect_attempts = 3\nallowed_commands = []\n";
5509 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5510 assert_eq!(
5511 result.changed_count, 0,
5512 "must not modify already-present key"
5513 );
5514 assert_eq!(result.output, src);
5515 }
5516
5517 #[test]
5518 fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() {
5519 let src = "[agent]\nname = \"Zeph\"\n";
5520 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5521 assert_eq!(result.changed_count, 0);
5522 assert_eq!(result.output, src);
5523 }
5524
5525 #[test]
5528 fn migrate_mcp_retry_and_tool_timeout_adds_both_keys_when_absent() {
5529 let src = "[mcp]\nallowed_commands = []\n";
5530 let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate");
5531 assert_eq!(result.changed_count, 1);
5532 assert!(
5533 result.output.contains("startup_retry_backoff_ms"),
5534 "output must include startup_retry_backoff_ms"
5535 );
5536 assert!(
5537 result.output.contains("tool_timeout_secs"),
5538 "output must include tool_timeout_secs"
5539 );
5540 }
5541
5542 #[test]
5543 fn migrate_mcp_retry_and_tool_timeout_idempotent_when_both_present() {
5544 let src = "[mcp]\n# startup_retry_backoff_ms = 1000\n# tool_timeout_secs = 60\n";
5545 let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate");
5546 assert_eq!(result.changed_count, 0);
5547 assert_eq!(result.output, src);
5548 }
5549
5550 #[test]
5551 fn migrate_mcp_retry_and_tool_timeout_skips_when_no_mcp_section() {
5552 let src = "[agent]\nname = \"Zeph\"\n";
5553 let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate");
5554 assert_eq!(result.changed_count, 0);
5555 assert_eq!(result.output, src);
5556 }
5557
5558 #[test]
5561 fn step43_adds_orchestrator_provider_comment_when_absent() {
5562 let src = "[orchestration]\nenabled = true\n";
5563 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5564 assert_eq!(result.changed_count, 1);
5565 assert!(
5566 result.output.contains("orchestrator_provider"),
5567 "migration must inject orchestrator_provider hint"
5568 );
5569 }
5570
5571 #[test]
5572 fn step43_noop_when_orchestrator_provider_already_present() {
5573 let src = "[orchestration]\nenabled = true\norchestrator_provider = \"\"\n";
5574 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5575 assert_eq!(
5576 result.changed_count, 0,
5577 "must not modify already-present key"
5578 );
5579 assert_eq!(result.output, src);
5580 }
5581
5582 #[test]
5585 fn step44_adds_max_concurrent_comment_when_providers_present() {
5586 let src = "[[llm.providers]]\nname = \"quality\"\ntype = \"openai\"\n";
5587 let result = migrate_provider_max_concurrent(src).expect("migrate");
5588 assert_eq!(result.changed_count, 1);
5589 assert!(
5590 result.output.contains("max_concurrent"),
5591 "migration must inject max_concurrent hint"
5592 );
5593 }
5594
5595 #[test]
5596 fn step44_noop_when_max_concurrent_already_present() {
5597 let src = "[[llm.providers]]\nname = \"quality\"\nmax_concurrent = 4\n";
5598 let result = migrate_provider_max_concurrent(src).expect("migrate");
5599 assert_eq!(
5600 result.changed_count, 0,
5601 "must not modify already-present key"
5602 );
5603 assert_eq!(result.output, src);
5604 }
5605
5606 #[test]
5607 fn step44_noop_when_no_providers_section() {
5608 let src = "[agent]\nname = \"Zeph\"\n";
5609 let result = migrate_provider_max_concurrent(src).expect("migrate");
5610 assert_eq!(result.changed_count, 0);
5611 assert_eq!(result.output, src);
5612 }
5613
5614 #[test]
5617 fn step45_adds_advisory_comment_when_gonkagate_present() {
5618 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5619 let result = migrate_gonkagate_to_gonka(src);
5620 assert!(result.changed_count > 0, "must detect gonkagate entry");
5621 assert!(
5622 result.output.contains("[migration] GonkaGate detected"),
5623 "advisory comment must be added"
5624 );
5625 let comment_pos = result
5627 .output
5628 .find("[migration] GonkaGate detected")
5629 .unwrap();
5630 let header_pos = result.output.find("[[llm.providers]]").unwrap();
5631 assert!(
5632 comment_pos < header_pos,
5633 "advisory comment must precede the [[llm.providers]] header"
5634 );
5635 }
5636
5637 #[test]
5638 fn step45_noop_when_no_gonkagate() {
5639 let src = "[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n";
5640 let result = migrate_gonkagate_to_gonka(src);
5641 assert_eq!(result.changed_count, 0);
5642 assert_eq!(result.output, src);
5643 }
5644
5645 #[test]
5646 fn step45_does_not_double_insert_comment() {
5647 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5648 let first = migrate_gonkagate_to_gonka(src);
5649 let second = migrate_gonkagate_to_gonka(&first.output);
5650 assert_eq!(second.changed_count, 0, "idempotent on second run");
5652 }
5653
5654 #[test]
5657 fn migrate_cocoon_noop_empty_config() {
5658 let src = "";
5659 let result = migrate_cocoon_provider_notice(src).unwrap();
5660 assert_eq!(result.changed_count, 0);
5661 assert_eq!(result.output, src);
5662 }
5663
5664 #[test]
5665 fn migrate_cocoon_noop_existing_config() {
5666 let src = "[agent]\nname = \"zeph\"\n\n[[llm.providers]]\ntype = \"ollama\"\n";
5667 let result = migrate_cocoon_provider_notice(src).unwrap();
5668 assert_eq!(result.changed_count, 0);
5669 assert_eq!(result.output, src);
5670 }
5671
5672 #[test]
5673 fn migrate_cocoon_idempotent() {
5674 let src = "[[llm.providers]]\ntype = \"cocoon\"\nname = \"tee\"\n";
5675 let first = migrate_cocoon_provider_notice(src).unwrap();
5676 let second = migrate_cocoon_provider_notice(&first.output).unwrap();
5677 assert_eq!(second.output, first.output);
5678 assert_eq!(second.changed_count, 0);
5679 }
5680
5681 #[test]
5684 fn migrate_five_signal_config_noop_when_already_present() {
5685 let src = "[memory]\nenabled = true\n\n[memory.five_signal]\nenabled = false\n";
5686 let result = migrate_five_signal_config(src).unwrap();
5687 assert_eq!(result.changed_count, 0);
5688 assert_eq!(result.output, src);
5689 }
5690
5691 #[test]
5692 fn migrate_five_signal_config_noop_when_no_memory_section() {
5693 let src = "[agent]\nmax_turns = 10\n";
5694 let result = migrate_five_signal_config(src).unwrap();
5695 assert_eq!(result.changed_count, 0);
5696 assert_eq!(result.output, src);
5697 }
5698
5699 #[test]
5700 fn migrate_five_signal_config_injects_comment_when_memory_present() {
5701 let src = "[memory]\nenabled = true\n";
5702 let result = migrate_five_signal_config(src).unwrap();
5703 assert_eq!(result.changed_count, 1);
5704 assert!(result.output.contains("five_signal"));
5705 assert!(
5706 result
5707 .sections_changed
5708 .contains(&"memory.five_signal".to_owned())
5709 );
5710 }
5711
5712 #[test]
5713 fn migrate_five_signal_config_idempotent_on_commented_output() {
5714 let base = "[memory]\nenabled = true\n";
5715 let first = migrate_five_signal_config(base).unwrap();
5716 let second = migrate_five_signal_config(&first.output).unwrap();
5717 assert_eq!(second.output, first.output);
5718 assert_eq!(second.changed_count, 0);
5719 }
5720
5721 #[test]
5724 fn migrate_embed_provider_rename_renames_all_four_keys() {
5725 let src = "\
5726[memory.semantic]\n\
5727embed_provider = \"ollama-embed\"\n\
5728\n\
5729[index]\n\
5730embed_provider = \"ollama-embed\"\n\
5731\n\
5732[llm.coe]\n\
5733embed_provider = \"\"\n\
5734\n\
5735[learning]\n\
5736trace_extraction_embed_provider = \"embed-fast\"\n";
5737 let result = migrate_embed_provider_rename(src).unwrap();
5738 assert_eq!(result.changed_count, 4);
5739 assert!(
5740 result
5741 .output
5742 .contains("embedding_provider = \"ollama-embed\"")
5743 );
5744 assert!(
5745 result
5746 .output
5747 .contains("trace_extraction_embedding_provider = \"embed-fast\"")
5748 );
5749 assert!(!result.output.contains("trace_extraction_embed_provider ="));
5750 assert!(!result.output.contains("\nembed_provider ="));
5751 }
5752
5753 #[test]
5754 fn migrate_embed_provider_rename_idempotent_on_own_output() {
5755 let src = "\
5756[memory.semantic]\n\
5757embed_provider = \"ollama-embed\"\n\
5758\n\
5759[learning]\n\
5760trace_extraction_embed_provider = \"embed-fast\"\n";
5761 let first = migrate_embed_provider_rename(src).unwrap();
5762 assert_eq!(first.changed_count, 2);
5763 let second = migrate_embed_provider_rename(&first.output).unwrap();
5764 assert_eq!(second.changed_count, 0, "second run must be a no-op");
5765 assert_eq!(second.output, first.output);
5766 }
5767
5768 #[test]
5769 fn migrate_embed_provider_rename_noop_when_no_old_keys() {
5770 let src = "\
5771[memory.semantic]\n\
5772embedding_provider = \"ollama-embed\"\n\
5773\n\
5774[learning]\n\
5775trace_extraction_embedding_provider = \"embed-fast\"\n";
5776 let result = migrate_embed_provider_rename(src).unwrap();
5777 assert_eq!(result.changed_count, 0);
5778 assert_eq!(result.output, src);
5779 }
5780
5781 #[test]
5782 fn migrate_embed_provider_rename_preserves_commented_lines() {
5783 let src = "# embed_provider = \"old-key\" # this is a comment\n\
5786trace_extraction_embed_provider = \"live\"\n";
5787 let result = migrate_embed_provider_rename(src).unwrap();
5788 assert_eq!(result.changed_count, 1);
5790 assert!(result.output.contains("# embed_provider = \"old-key\""));
5791 assert!(
5792 result
5793 .output
5794 .contains("trace_extraction_embedding_provider = \"live\"")
5795 );
5796 }
5797}