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