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
2872 if has_spreading && has_depth && has_budget {
2873 return Ok(MigrationResult {
2874 output: toml_src.to_owned(),
2875 changed_count: 0,
2876 sections_changed: Vec::new(),
2877 });
2878 }
2879
2880 let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2881 # spreading_activation = false # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2882 # spread_depth = 2 # BFS hops, clamped [1,6]\n\
2883 # spread_edge_types = [] # MAGMA edge types to traverse; empty = all\n\
2884 # step_budget_ms = 8 # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n";
2885
2886 let output = format!("{toml_src}{extra}");
2887 Ok(MigrationResult {
2888 output,
2889 changed_count: 1,
2890 sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2891 })
2892}
2893
2894pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2908 if toml_src
2909 .lines()
2910 .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2911 {
2912 return Ok(MigrationResult {
2913 output: toml_src.to_owned(),
2914 changed_count: 0,
2915 sections_changed: Vec::new(),
2916 });
2917 }
2918
2919 let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2920 # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2921 # ZEPH_TURN_LLM_REQUESTS.\n\
2922 # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2923 # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2924 # [[hooks.turn_complete]]\n\
2925 # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2926 # timeout_secs = 3\n\
2927 # fail_closed = false\n";
2928 let output = format!("{toml_src}{comment}");
2929
2930 Ok(MigrationResult {
2931 output,
2932 changed_count: 1,
2933 sections_changed: vec!["hooks.turn_complete".to_owned()],
2934 })
2935}
2936
2937pub fn migrate_focus_auto_consolidate_min_window(
2954 toml_src: &str,
2955) -> Result<MigrationResult, MigrateError> {
2956 if toml_src.contains("auto_consolidate_min_window") {
2957 return Ok(MigrationResult {
2958 output: toml_src.to_owned(),
2959 changed_count: 0,
2960 sections_changed: Vec::new(),
2961 });
2962 }
2963
2964 if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
2966 return Ok(MigrationResult {
2967 output: toml_src.to_owned(),
2968 changed_count: 0,
2969 sections_changed: Vec::new(),
2970 });
2971 }
2972
2973 let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
2974 runs (#3313).\n\
2975 # auto_consolidate_min_window = 6\n";
2976 let output = insert_after_section(toml_src, "agent.focus", comment);
2977
2978 Ok(MigrationResult {
2979 output,
2980 changed_count: 1,
2981 sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
2982 })
2983}
2984
2985pub fn migrate_session_provider_persistence(
2995 toml_src: &str,
2996) -> Result<MigrationResult, MigrateError> {
2997 if toml_src
2998 .lines()
2999 .any(|l| l.trim() == "[session]" || l.trim() == "# [session]")
3000 {
3001 return Ok(MigrationResult {
3002 output: toml_src.to_owned(),
3003 changed_count: 0,
3004 sections_changed: Vec::new(),
3005 });
3006 }
3007
3008 let comment = "\n# [session] — session-scoped user experience settings (#3308).\n\
3009 [session]\n\
3010 # Persist the last-used provider per channel across restarts.\n\
3011 # When true, the agent saves the active provider name to SQLite after each\n\
3012 # /provider switch and restores it on the next session start for the same channel.\n\
3013 provider_persistence = true\n";
3014 let output = format!("{toml_src}{comment}");
3015
3016 Ok(MigrationResult {
3017 output,
3018 changed_count: 1,
3019 sections_changed: vec!["session".to_owned()],
3020 })
3021}
3022
3023pub fn migrate_memory_retrieval_query_bias(
3035 toml_src: &str,
3036) -> Result<MigrationResult, MigrateError> {
3037 if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
3040 return Ok(MigrationResult {
3041 output: toml_src.to_owned(),
3042 changed_count: 0,
3043 sections_changed: Vec::new(),
3044 });
3045 }
3046
3047 if toml_src
3049 .lines()
3050 .any(|l| l.trim().starts_with("query_bias_correction"))
3051 {
3052 return Ok(MigrationResult {
3053 output: toml_src.to_owned(),
3054 changed_count: 0,
3055 sections_changed: Vec::new(),
3056 });
3057 }
3058
3059 let comment = "\n# MM-F3 (#3341): shift first-person queries toward the user profile centroid.\n\
3060 # No-op when the persona table is empty.\n\
3061 # query_bias_correction = true\n";
3062 let output = insert_after_section(toml_src, "memory.retrieval", comment);
3063
3064 Ok(MigrationResult {
3065 output,
3066 changed_count: 1,
3067 sections_changed: vec!["memory.retrieval.query_bias_correction".to_owned()],
3068 })
3069}
3070
3071pub fn migrate_memory_persona_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3080 if toml_src
3081 .lines()
3082 .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]")
3083 {
3084 return Ok(MigrationResult {
3085 output: toml_src.to_owned(),
3086 changed_count: 0,
3087 sections_changed: Vec::new(),
3088 });
3089 }
3090
3091 let comment = "\n# [memory.persona] — user persona profile for query-bias correction (#3341).\n\
3092 # Verified working in CI-604/CI-605. No-op when disabled.\n\
3093 # [memory.persona]\n\
3094 # enabled = true\n\
3095 # min_messages = 2 # minimum user messages before persona extraction fires\n\
3096 # min_confidence = 0.5 # minimum extraction confidence threshold (0.0–1.0)\n";
3097 let output = format!("{toml_src}{comment}");
3098
3099 Ok(MigrationResult {
3100 output,
3101 changed_count: 1,
3102 sections_changed: vec!["memory.persona".to_owned()],
3103 })
3104}
3105
3106pub fn migrate_qdrant_api_key(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3115 if toml_src.contains("qdrant_api_key") {
3116 return Ok(MigrationResult {
3117 output: toml_src.to_owned(),
3118 changed_count: 0,
3119 sections_changed: Vec::new(),
3120 });
3121 }
3122
3123 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3124
3125 if !doc.contains_key("memory") {
3126 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
3127 }
3128
3129 let comment = "\n# Qdrant API key (optional; required when connecting to remote/managed Qdrant clusters).\n\
3130 # Leave empty for local Qdrant instances. Store the actual key in the vault:\n\
3131 # zeph vault set ZEPH_QDRANT_API_KEY \"<key>\"\n\
3132 # qdrant_api_key = \"\"\n";
3133 let raw = doc.to_string();
3134 let output = format!("{raw}{comment}");
3135
3136 Ok(MigrationResult {
3137 output,
3138 changed_count: 1,
3139 sections_changed: vec!["memory.qdrant_api_key".to_owned()],
3140 })
3141}
3142
3143pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3149 if toml_src.contains("[goals]") {
3150 return Ok(MigrationResult {
3151 output: toml_src.to_owned(),
3152 changed_count: 0,
3153 sections_changed: Vec::new(),
3154 });
3155 }
3156
3157 let comment = "\n# Long-horizon goal lifecycle tracking (#3567).\n\
3158 # [goals]\n\
3159 # enabled = false\n\
3160 # inject_into_system_prompt = true\n\
3161 # max_text_chars = 2000\n\
3162 # max_history = 50\n";
3163
3164 Ok(MigrationResult {
3165 output: format!("{toml_src}{comment}"),
3166 changed_count: 1,
3167 sections_changed: vec!["goals".to_owned()],
3168 })
3169}
3170
3171pub fn migrate_tools_compression_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3177 if toml_src.contains("tools.compression")
3178 || toml_src.contains("[tools]\n") && toml_src.contains("compression")
3179 {
3180 return Ok(MigrationResult {
3181 output: toml_src.to_owned(),
3182 changed_count: 0,
3183 sections_changed: Vec::new(),
3184 });
3185 }
3186
3187 let comment = "\n# TACO self-evolving tool output compression (#3306).\n\
3188 # [tools.compression]\n\
3189 # enabled = false\n\
3190 # min_lines_to_compress = 10\n\
3191 # evolution_provider = \"\"\n\
3192 # evolution_min_interval_secs = 3600\n\
3193 # max_rules = 200\n";
3194
3195 Ok(MigrationResult {
3196 output: format!("{toml_src}{comment}"),
3197 changed_count: 1,
3198 sections_changed: vec!["tools.compression".to_owned()],
3199 })
3200}
3201
3202pub fn migrate_orchestration_orchestrator_provider(
3208 toml_src: &str,
3209) -> Result<MigrationResult, MigrateError> {
3210 if toml_src.contains("orchestrator_provider") {
3211 return Ok(MigrationResult {
3212 output: toml_src.to_owned(),
3213 changed_count: 0,
3214 sections_changed: Vec::new(),
3215 });
3216 }
3217
3218 let comment = "\n# Provider for scheduling-tier LLM calls (aggregation, predicate, verify fallback).\n\
3219 # Set to a cheap/fast model to reduce orchestration cost. Empty = primary provider.\n\
3220 # Add under the orchestration section in your config:\n\
3221 # orchestrator_provider = \"\"\n";
3222
3223 Ok(MigrationResult {
3224 output: format!("{toml_src}{comment}"),
3225 changed_count: 1,
3226 sections_changed: vec!["orchestration".to_owned()],
3227 })
3228}
3229
3230pub fn migrate_provider_max_concurrent(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3240 if toml_src.contains("max_concurrent") {
3241 return Ok(MigrationResult {
3242 output: toml_src.to_owned(),
3243 changed_count: 0,
3244 sections_changed: Vec::new(),
3245 });
3246 }
3247
3248 if !toml_src.contains("[[llm.providers]]") {
3249 return Ok(MigrationResult {
3250 output: toml_src.to_owned(),
3251 changed_count: 0,
3252 sections_changed: Vec::new(),
3253 });
3254 }
3255
3256 let comment = "\n# Optional: maximum concurrent sub-agent calls to this provider (admission control).\n\
3257 # Remove the comment to enable; omit or set to 0 for unlimited.\n\
3258 # max_concurrent = 4\n";
3259
3260 Ok(MigrationResult {
3261 output: format!("{toml_src}{comment}"),
3262 changed_count: 1,
3263 sections_changed: vec!["llm.providers".to_owned()],
3264 })
3265}
3266
3267pub trait Migration: Send + Sync {
3294 fn name(&self) -> &'static str;
3296
3297 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
3303}
3304
3305mod steps;
3306use steps::{
3307 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
3308 MigrateAutodreamConfig, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
3309 MigrateEgressConfig, MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig,
3310 MigrateGoalsConfig, MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig,
3311 MigrateHooksTurnComplete, MigrateMagicDocsConfig, MigrateMcpElicitationConfig,
3312 MigrateMcpMaxConnectAttempts, MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryHebbian,
3313 MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
3314 MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
3315 MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, MigrateOrchestrationPersistence,
3316 MigrateOrchestratorProvider, MigrateOtelFilter, MigratePlannerModelToProvider,
3317 MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQualityConfig, MigrateSandboxConfig,
3318 MigrateSandboxEgressFilter, MigrateSchedulerDaemon, MigrateSessionProviderPersistence,
3319 MigrateSessionRecapConfig, MigrateShellTransactional, MigrateSttToProvider,
3320 MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
3321 MigrateVigilConfig,
3322};
3323
3324pub(crate) fn migrate_gonkagate_to_gonka(toml_src: &str) -> MigrationResult {
3330 const MARKER: &str = "# [migration] GonkaGate detected: consider migrating to type = \"gonka\"";
3332
3333 if !toml_src.contains("gonkagate") {
3334 return MigrationResult {
3335 output: toml_src.to_owned(),
3336 changed_count: 0,
3337 sections_changed: vec![],
3338 };
3339 }
3340
3341 let mut changed_count = 0;
3342 let mut lines: Vec<String> = toml_src.lines().map(str::to_owned).collect();
3343
3344 let indices: Vec<usize> = lines
3348 .iter()
3349 .enumerate()
3350 .filter(|(_, l)| l.contains("gonkagate"))
3351 .map(|(i, _)| i)
3352 .rev()
3353 .collect();
3354
3355 for gonka_idx in indices {
3356 let header_idx = (0..=gonka_idx)
3358 .rev()
3359 .find(|&i| lines[i].starts_with("[["))
3360 .unwrap_or(gonka_idx);
3361
3362 let already_marked = header_idx > 0 && lines[header_idx - 1].contains(MARKER);
3364 if already_marked {
3365 continue;
3366 }
3367
3368 lines.insert(
3369 header_idx,
3370 format!("{MARKER} (see docs/guides/gonka-native.md)"),
3371 );
3372 changed_count += 1;
3373 }
3374
3375 let output = lines.join("\n");
3376 let output = if toml_src.ends_with('\n') {
3377 format!("{output}\n")
3378 } else {
3379 output
3380 };
3381
3382 MigrationResult {
3383 output,
3384 changed_count,
3385 sections_changed: if changed_count > 0 {
3386 vec!["llm".into()]
3387 } else {
3388 vec![]
3389 },
3390 }
3391}
3392
3393pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3410 std::sync::LazyLock::new(|| {
3411 vec![
3412 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3414 Box::new(MigratePlannerModelToProvider),
3415 Box::new(MigrateMcpTrustLevels),
3416 Box::new(MigrateAgentRetryToToolsRetry),
3417 Box::new(MigrateDatabaseUrl),
3418 Box::new(MigrateShellTransactional),
3419 Box::new(MigrateAgentBudgetHint),
3420 Box::new(MigrateForgettingConfig),
3421 Box::new(MigrateCompressionPredictorConfig),
3422 Box::new(MigrateMicrocompactConfig),
3423 Box::new(MigrateAutodreamConfig),
3424 Box::new(MigrateMagicDocsConfig),
3425 Box::new(MigrateTelemetryConfig),
3426 Box::new(MigrateSupervisorConfig),
3427 Box::new(MigrateOtelFilter),
3428 Box::new(MigrateEgressConfig),
3429 Box::new(MigrateVigilConfig),
3430 Box::new(MigrateSandboxConfig),
3431 Box::new(MigrateSandboxEgressFilter),
3432 Box::new(MigrateOrchestrationPersistence),
3433 Box::new(MigrateSessionRecapConfig),
3434 Box::new(MigrateMcpElicitationConfig),
3435 Box::new(MigrateQualityConfig),
3436 Box::new(MigrateAcpSubagentsConfig),
3437 Box::new(MigrateHooksPermissionDeniedConfig),
3438 Box::new(MigrateMemoryGraph),
3440 Box::new(MigrateSchedulerDaemon),
3441 Box::new(MigrateMemoryRetrieval),
3442 Box::new(MigrateMemoryReasoning),
3443 Box::new(MigrateMemoryReasoningJudge),
3444 Box::new(MigrateMemoryHebbian),
3445 Box::new(MigrateMemoryHebbianConsolidation),
3446 Box::new(MigrateMemoryHebbianSpread),
3447 Box::new(MigrateHooksTurnComplete),
3448 Box::new(MigrateFocusAutoConsolidateMinWindow),
3449 Box::new(MigrateSessionProviderPersistence),
3451 Box::new(MigrateMemoryRetrievalQueryBias),
3452 Box::new(MigrateMemoryPersonaConfig),
3453 Box::new(MigrateQdrantApiKey),
3455 Box::new(MigrateMcpMaxConnectAttempts),
3457 Box::new(MigrateGoalsConfig),
3459 Box::new(MigrateToolsCompressionConfig),
3460 Box::new(MigrateOrchestratorProvider),
3462 Box::new(MigrateProviderMaxConcurrent),
3464 Box::new(MigrateGonkagateToGonka),
3466 ]
3467 });
3468
3469#[cfg(test)]
3471fn make_formatted_str(s: &str) -> Value {
3472 use toml_edit::Formatted;
3473 Value::String(Formatted::new(s.to_owned()))
3474}
3475
3476#[cfg(test)]
3477mod tests {
3478 use super::*;
3479
3480 #[test]
3481 fn migrations_registry_has_all_steps() {
3482 assert_eq!(
3483 MIGRATIONS.len(),
3484 45,
3485 "MIGRATIONS registry must contain all 45 sequential steps"
3486 );
3487 for m in MIGRATIONS.iter() {
3488 assert!(
3489 !m.name().is_empty(),
3490 "each migration must have a non-empty name"
3491 );
3492 }
3493 }
3494
3495 #[test]
3496 fn migrations_registry_applies_to_empty_config() {
3497 let mut toml = String::new();
3498 for m in MIGRATIONS.iter() {
3499 toml = m
3500 .apply(&toml)
3501 .expect("migration must not fail on empty config")
3502 .output;
3503 }
3504 toml.parse::<toml_edit::DocumentMut>()
3506 .expect("registry output must be valid TOML");
3507 }
3508
3509 #[test]
3510 fn empty_config_gets_sections_as_comments() {
3511 let migrator = ConfigMigrator::new();
3512 let result = migrator.migrate("").expect("migrate empty");
3513 assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
3515 assert!(
3517 result.output.contains("[agent]") || result.output.contains("# [agent]"),
3518 "expected agent section in output, got:\n{}",
3519 result.output
3520 );
3521 }
3522
3523 #[test]
3524 fn existing_values_not_overwritten() {
3525 let user = r#"
3526[agent]
3527name = "MyAgent"
3528max_tool_iterations = 5
3529"#;
3530 let migrator = ConfigMigrator::new();
3531 let result = migrator.migrate(user).expect("migrate");
3532 assert!(
3534 result.output.contains("name = \"MyAgent\""),
3535 "user value should be preserved"
3536 );
3537 assert!(
3538 result.output.contains("max_tool_iterations = 5"),
3539 "user value should be preserved"
3540 );
3541 assert!(
3543 !result.output.contains("# max_tool_iterations = 10"),
3544 "already-set key should not appear as comment"
3545 );
3546 }
3547
3548 #[test]
3549 fn missing_nested_key_added_as_comment() {
3550 let user = r#"
3552[memory]
3553sqlite_path = ".zeph/data/zeph.db"
3554"#;
3555 let migrator = ConfigMigrator::new();
3556 let result = migrator.migrate(user).expect("migrate");
3557 assert!(
3559 result.output.contains("# history_limit"),
3560 "missing key should be added as comment, got:\n{}",
3561 result.output
3562 );
3563 }
3564
3565 #[test]
3566 fn unknown_user_keys_preserved() {
3567 let user = r#"
3568[agent]
3569name = "Test"
3570my_custom_key = "preserved"
3571"#;
3572 let migrator = ConfigMigrator::new();
3573 let result = migrator.migrate(user).expect("migrate");
3574 assert!(
3575 result.output.contains("my_custom_key = \"preserved\""),
3576 "custom user keys must not be removed"
3577 );
3578 }
3579
3580 #[test]
3581 fn idempotent() {
3582 let migrator = ConfigMigrator::new();
3583 let first = migrator
3584 .migrate("[agent]\nname = \"Zeph\"\n")
3585 .expect("first migrate");
3586 let second = migrator.migrate(&first.output).expect("second migrate");
3587 assert_eq!(
3588 first.output, second.output,
3589 "idempotent: full output must be identical on second run"
3590 );
3591 }
3592
3593 #[test]
3594 fn malformed_input_returns_error() {
3595 let migrator = ConfigMigrator::new();
3596 let err = migrator
3597 .migrate("[[invalid toml [[[")
3598 .expect_err("should error");
3599 assert!(
3600 matches!(err, MigrateError::Parse(_)),
3601 "expected Parse error"
3602 );
3603 }
3604
3605 #[test]
3606 fn array_of_tables_preserved() {
3607 let user = r#"
3608[mcp]
3609allowed_commands = ["npx"]
3610
3611[[mcp.servers]]
3612id = "my-server"
3613command = "npx"
3614args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3615"#;
3616 let migrator = ConfigMigrator::new();
3617 let result = migrator.migrate(user).expect("migrate");
3618 assert!(
3620 result.output.contains("[[mcp.servers]]"),
3621 "array-of-tables entries must be preserved"
3622 );
3623 assert!(result.output.contains("id = \"my-server\""));
3624 }
3625
3626 #[test]
3627 fn canonical_ordering_applied() {
3628 let user = r#"
3630[memory]
3631sqlite_path = ".zeph/data/zeph.db"
3632
3633[agent]
3634name = "Test"
3635"#;
3636 let migrator = ConfigMigrator::new();
3637 let result = migrator.migrate(user).expect("migrate");
3638 let agent_pos = result.output.find("[agent]");
3640 let memory_pos = result.output.find("[memory]");
3641 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3642 assert!(a < m, "agent section should precede memory section");
3643 }
3644 }
3645
3646 #[test]
3647 fn value_to_toml_string_formats_correctly() {
3648 use toml_edit::Formatted;
3649
3650 let s = make_formatted_str("hello");
3651 assert_eq!(value_to_toml_string(&s), "\"hello\"");
3652
3653 let i = Value::Integer(Formatted::new(42_i64));
3654 assert_eq!(value_to_toml_string(&i), "42");
3655
3656 let b = Value::Boolean(Formatted::new(true));
3657 assert_eq!(value_to_toml_string(&b), "true");
3658
3659 let f = Value::Float(Formatted::new(1.0_f64));
3660 assert_eq!(value_to_toml_string(&f), "1.0");
3661
3662 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3663 assert_eq!(value_to_toml_string(&f2), "3.14");
3664
3665 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3666 let arr_val = Value::Array(arr);
3667 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3668
3669 let empty_arr = Value::Array(Array::new());
3670 assert_eq!(value_to_toml_string(&empty_arr), "[]");
3671 }
3672
3673 #[test]
3674 fn idempotent_full_output_unchanged() {
3675 let migrator = ConfigMigrator::new();
3677 let first = migrator
3678 .migrate("[agent]\nname = \"Zeph\"\n")
3679 .expect("first migrate");
3680 let second = migrator.migrate(&first.output).expect("second migrate");
3681 assert_eq!(
3682 first.output, second.output,
3683 "full output string must be identical after second migration pass"
3684 );
3685 }
3686
3687 #[test]
3688 fn full_config_produces_zero_additions() {
3689 let reference = include_str!("../../config/default.toml");
3691 let migrator = ConfigMigrator::new();
3692 let result = migrator.migrate(reference).expect("migrate reference");
3693 assert_eq!(
3694 result.changed_count, 0,
3695 "migrating the canonical reference should add nothing (changed_count = {})",
3696 result.changed_count
3697 );
3698 assert!(
3699 result.sections_changed.is_empty(),
3700 "migrating the canonical reference should report no sections_changed: {:?}",
3701 result.sections_changed
3702 );
3703 }
3704
3705 #[test]
3706 fn empty_config_changed_count_is_positive() {
3707 let migrator = ConfigMigrator::new();
3709 let result = migrator.migrate("").expect("migrate empty");
3710 assert!(
3711 result.changed_count > 0,
3712 "empty config must report changed_count > 0"
3713 );
3714 }
3715
3716 #[test]
3719 fn security_without_guardrail_gets_guardrail_commented() {
3720 let user = "[security]\nredact_secrets = true\n";
3721 let migrator = ConfigMigrator::new();
3722 let result = migrator.migrate(user).expect("migrate");
3723 assert!(
3725 result.output.contains("guardrail"),
3726 "migration must add guardrail keys for configs without [security.guardrail]: \
3727 got:\n{}",
3728 result.output
3729 );
3730 }
3731
3732 #[test]
3733 fn migrate_reference_contains_tools_policy() {
3734 let reference = include_str!("../../config/default.toml");
3739 assert!(
3740 reference.contains("[tools.policy]"),
3741 "default.toml must contain [tools.policy] section so migrate-config can surface it"
3742 );
3743 assert!(
3744 reference.contains("enabled = false"),
3745 "tools.policy section must include enabled = false default"
3746 );
3747 }
3748
3749 #[test]
3750 fn migrate_reference_contains_probe_section() {
3751 let reference = include_str!("../../config/default.toml");
3754 assert!(
3755 reference.contains("[memory.compression.probe]"),
3756 "default.toml must contain [memory.compression.probe] section comment"
3757 );
3758 assert!(
3759 reference.contains("hard_fail_threshold"),
3760 "probe section must include hard_fail_threshold default"
3761 );
3762 }
3763
3764 #[test]
3767 fn migrate_llm_no_llm_section_is_noop() {
3768 let src = "[agent]\nname = \"Zeph\"\n";
3769 let result = migrate_llm_to_providers(src).expect("migrate");
3770 assert_eq!(result.changed_count, 0);
3771 assert_eq!(result.output, src);
3772 }
3773
3774 #[test]
3775 fn migrate_llm_already_new_format_is_noop() {
3776 let src = r#"
3777[llm]
3778[[llm.providers]]
3779type = "ollama"
3780model = "qwen3:8b"
3781"#;
3782 let result = migrate_llm_to_providers(src).expect("migrate");
3783 assert_eq!(result.changed_count, 0);
3784 }
3785
3786 #[test]
3787 fn migrate_llm_ollama_produces_providers_block() {
3788 let src = r#"
3789[llm]
3790provider = "ollama"
3791model = "qwen3:8b"
3792base_url = "http://localhost:11434"
3793embedding_model = "nomic-embed-text"
3794"#;
3795 let result = migrate_llm_to_providers(src).expect("migrate");
3796 assert!(
3797 result.output.contains("[[llm.providers]]"),
3798 "should contain [[llm.providers]]:\n{}",
3799 result.output
3800 );
3801 assert!(
3802 result.output.contains("type = \"ollama\""),
3803 "{}",
3804 result.output
3805 );
3806 assert!(
3807 result.output.contains("model = \"qwen3:8b\""),
3808 "{}",
3809 result.output
3810 );
3811 }
3812
3813 #[test]
3814 fn migrate_llm_claude_produces_providers_block() {
3815 let src = r#"
3816[llm]
3817provider = "claude"
3818
3819[llm.cloud]
3820model = "claude-sonnet-4-6"
3821max_tokens = 8192
3822server_compaction = true
3823"#;
3824 let result = migrate_llm_to_providers(src).expect("migrate");
3825 assert!(
3826 result.output.contains("[[llm.providers]]"),
3827 "{}",
3828 result.output
3829 );
3830 assert!(
3831 result.output.contains("type = \"claude\""),
3832 "{}",
3833 result.output
3834 );
3835 assert!(
3836 result.output.contains("model = \"claude-sonnet-4-6\""),
3837 "{}",
3838 result.output
3839 );
3840 assert!(
3841 result.output.contains("server_compaction = true"),
3842 "{}",
3843 result.output
3844 );
3845 }
3846
3847 #[test]
3848 fn migrate_llm_openai_copies_fields() {
3849 let src = r#"
3850[llm]
3851provider = "openai"
3852
3853[llm.openai]
3854base_url = "https://api.openai.com/v1"
3855model = "gpt-4o"
3856max_tokens = 4096
3857"#;
3858 let result = migrate_llm_to_providers(src).expect("migrate");
3859 assert!(
3860 result.output.contains("type = \"openai\""),
3861 "{}",
3862 result.output
3863 );
3864 assert!(
3865 result
3866 .output
3867 .contains("base_url = \"https://api.openai.com/v1\""),
3868 "{}",
3869 result.output
3870 );
3871 }
3872
3873 #[test]
3874 fn migrate_llm_gemini_copies_fields() {
3875 let src = r#"
3876[llm]
3877provider = "gemini"
3878
3879[llm.gemini]
3880model = "gemini-2.0-flash"
3881max_tokens = 8192
3882base_url = "https://generativelanguage.googleapis.com"
3883"#;
3884 let result = migrate_llm_to_providers(src).expect("migrate");
3885 assert!(
3886 result.output.contains("type = \"gemini\""),
3887 "{}",
3888 result.output
3889 );
3890 assert!(
3891 result.output.contains("model = \"gemini-2.0-flash\""),
3892 "{}",
3893 result.output
3894 );
3895 }
3896
3897 #[test]
3898 fn migrate_llm_compatible_copies_multiple_entries() {
3899 let src = r#"
3900[llm]
3901provider = "compatible"
3902
3903[[llm.compatible]]
3904name = "proxy-a"
3905base_url = "http://proxy-a:8080/v1"
3906model = "llama3"
3907max_tokens = 4096
3908
3909[[llm.compatible]]
3910name = "proxy-b"
3911base_url = "http://proxy-b:8080/v1"
3912model = "mistral"
3913max_tokens = 2048
3914"#;
3915 let result = migrate_llm_to_providers(src).expect("migrate");
3916 let count = result.output.matches("[[llm.providers]]").count();
3918 assert_eq!(
3919 count, 2,
3920 "expected 2 [[llm.providers]] blocks:\n{}",
3921 result.output
3922 );
3923 assert!(
3924 result.output.contains("name = \"proxy-a\""),
3925 "{}",
3926 result.output
3927 );
3928 assert!(
3929 result.output.contains("name = \"proxy-b\""),
3930 "{}",
3931 result.output
3932 );
3933 }
3934
3935 #[test]
3936 fn migrate_llm_mixed_format_errors() {
3937 let src = r#"
3939[llm]
3940provider = "ollama"
3941
3942[[llm.providers]]
3943type = "ollama"
3944"#;
3945 assert!(
3946 migrate_llm_to_providers(src).is_err(),
3947 "mixed format must return error"
3948 );
3949 }
3950
3951 #[test]
3954 fn stt_migration_no_stt_section_returns_unchanged() {
3955 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
3956 let result = migrate_stt_to_provider(src).unwrap();
3957 assert_eq!(result.changed_count, 0);
3958 assert_eq!(result.output, src);
3959 }
3960
3961 #[test]
3962 fn stt_migration_no_model_or_base_url_returns_unchanged() {
3963 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
3964 let result = migrate_stt_to_provider(src).unwrap();
3965 assert_eq!(result.changed_count, 0);
3966 }
3967
3968 #[test]
3969 fn stt_migration_moves_model_to_provider_entry() {
3970 let src = r#"
3971[llm]
3972
3973[[llm.providers]]
3974type = "openai"
3975name = "quality"
3976model = "gpt-5.4"
3977
3978[llm.stt]
3979provider = "quality"
3980model = "gpt-4o-mini-transcribe"
3981language = "en"
3982"#;
3983 let result = migrate_stt_to_provider(src).unwrap();
3984 assert_eq!(result.changed_count, 1);
3985 assert!(
3987 result.output.contains("stt_model"),
3988 "stt_model must be in output"
3989 );
3990 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3993 let stt = doc
3994 .get("llm")
3995 .and_then(toml_edit::Item::as_table)
3996 .and_then(|l| l.get("stt"))
3997 .and_then(toml_edit::Item::as_table)
3998 .unwrap();
3999 assert!(
4000 stt.get("model").is_none(),
4001 "model must be removed from [llm.stt]"
4002 );
4003 assert_eq!(
4004 stt.get("provider").and_then(toml_edit::Item::as_str),
4005 Some("quality")
4006 );
4007 }
4008
4009 #[test]
4010 fn stt_migration_creates_new_provider_when_no_match() {
4011 let src = r#"
4012[llm]
4013
4014[[llm.providers]]
4015type = "ollama"
4016name = "local"
4017model = "qwen3:8b"
4018
4019[llm.stt]
4020provider = "whisper"
4021model = "whisper-1"
4022base_url = "https://api.openai.com/v1"
4023language = "en"
4024"#;
4025 let result = migrate_stt_to_provider(src).unwrap();
4026 assert!(
4027 result.output.contains("openai-stt"),
4028 "new entry name must be openai-stt"
4029 );
4030 assert!(
4031 result.output.contains("stt_model"),
4032 "stt_model must be in output"
4033 );
4034 }
4035
4036 #[test]
4037 fn stt_migration_candle_whisper_creates_candle_entry() {
4038 let src = r#"
4039[llm]
4040
4041[llm.stt]
4042provider = "candle-whisper"
4043model = "openai/whisper-tiny"
4044language = "auto"
4045"#;
4046 let result = migrate_stt_to_provider(src).unwrap();
4047 assert!(
4048 result.output.contains("local-whisper"),
4049 "candle entry name must be local-whisper"
4050 );
4051 assert!(result.output.contains("candle"), "type must be candle");
4052 }
4053
4054 #[test]
4055 fn stt_migration_w2_assigns_explicit_name() {
4056 let src = r#"
4058[llm]
4059
4060[[llm.providers]]
4061type = "openai"
4062model = "gpt-5.4"
4063
4064[llm.stt]
4065provider = "openai"
4066model = "whisper-1"
4067language = "auto"
4068"#;
4069 let result = migrate_stt_to_provider(src).unwrap();
4070 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4071 let providers = doc
4072 .get("llm")
4073 .and_then(toml_edit::Item::as_table)
4074 .and_then(|l| l.get("providers"))
4075 .and_then(toml_edit::Item::as_array_of_tables)
4076 .unwrap();
4077 let entry = providers
4078 .iter()
4079 .find(|t| t.get("stt_model").is_some())
4080 .unwrap();
4081 assert!(
4083 entry.get("name").is_some(),
4084 "migrated entry must have explicit name"
4085 );
4086 }
4087
4088 #[test]
4089 fn stt_migration_removes_base_url_from_stt_table() {
4090 let src = r#"
4092[llm]
4093
4094[[llm.providers]]
4095type = "openai"
4096name = "quality"
4097model = "gpt-5.4"
4098
4099[llm.stt]
4100provider = "quality"
4101model = "whisper-1"
4102base_url = "https://api.openai.com/v1"
4103language = "en"
4104"#;
4105 let result = migrate_stt_to_provider(src).unwrap();
4106 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4107 let stt = doc
4108 .get("llm")
4109 .and_then(toml_edit::Item::as_table)
4110 .and_then(|l| l.get("stt"))
4111 .and_then(toml_edit::Item::as_table)
4112 .unwrap();
4113 assert!(
4114 stt.get("model").is_none(),
4115 "model must be removed from [llm.stt]"
4116 );
4117 assert!(
4118 stt.get("base_url").is_none(),
4119 "base_url must be removed from [llm.stt]"
4120 );
4121 }
4122
4123 #[test]
4124 fn migrate_planner_model_to_provider_with_field() {
4125 let input = r#"
4126[orchestration]
4127enabled = true
4128planner_model = "gpt-4o"
4129max_tasks = 20
4130"#;
4131 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4132 assert_eq!(result.changed_count, 1, "changed_count must be 1");
4133 assert!(
4134 !result.output.contains("planner_model = "),
4135 "planner_model key must be removed from output"
4136 );
4137 assert!(
4138 result.output.contains("# planner_provider"),
4139 "commented-out planner_provider entry must be present"
4140 );
4141 assert!(
4142 result.output.contains("gpt-4o"),
4143 "old value must appear in the comment"
4144 );
4145 assert!(
4146 result.output.contains("MIGRATED"),
4147 "comment must include MIGRATED marker"
4148 );
4149 }
4150
4151 #[test]
4152 fn migrate_planner_model_to_provider_no_op() {
4153 let input = r"
4154[orchestration]
4155enabled = true
4156max_tasks = 20
4157";
4158 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4159 assert_eq!(
4160 result.changed_count, 0,
4161 "changed_count must be 0 when field is absent"
4162 );
4163 assert_eq!(
4164 result.output, input,
4165 "output must equal input when nothing to migrate"
4166 );
4167 }
4168
4169 #[test]
4170 fn migrate_error_invalid_structure_formats_correctly() {
4171 let err = MigrateError::InvalidStructure("test sentinel");
4176 assert!(
4177 matches!(err, MigrateError::InvalidStructure(_)),
4178 "variant must match"
4179 );
4180 let msg = err.to_string();
4181 assert!(
4182 msg.contains("invalid TOML structure"),
4183 "error message must mention 'invalid TOML structure', got: {msg}"
4184 );
4185 assert!(
4186 msg.contains("test sentinel"),
4187 "message must include reason: {msg}"
4188 );
4189 }
4190
4191 #[test]
4194 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
4195 let src = r#"
4196[mcp]
4197allowed_commands = ["npx"]
4198
4199[[mcp.servers]]
4200id = "srv-a"
4201command = "npx"
4202args = ["-y", "some-mcp"]
4203
4204[[mcp.servers]]
4205id = "srv-b"
4206command = "npx"
4207args = ["-y", "other-mcp"]
4208"#;
4209 let result = migrate_mcp_trust_levels(src).expect("migrate");
4210 assert_eq!(
4211 result.changed_count, 2,
4212 "both entries must get trust_level added"
4213 );
4214 assert!(
4215 result
4216 .sections_changed
4217 .contains(&"mcp.servers.trust_level".to_owned()),
4218 "sections_changed must report mcp.servers.trust_level"
4219 );
4220 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
4222 assert_eq!(
4223 occurrences, 2,
4224 "each entry must have trust_level = \"trusted\""
4225 );
4226 }
4227
4228 #[test]
4229 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
4230 let src = r#"
4231[[mcp.servers]]
4232id = "srv-a"
4233command = "npx"
4234trust_level = "sandboxed"
4235tool_allowlist = ["read_file"]
4236
4237[[mcp.servers]]
4238id = "srv-b"
4239command = "npx"
4240"#;
4241 let result = migrate_mcp_trust_levels(src).expect("migrate");
4242 assert_eq!(
4244 result.changed_count, 1,
4245 "only entry without trust_level gets updated"
4246 );
4247 assert!(
4249 result.output.contains("trust_level = \"sandboxed\""),
4250 "existing trust_level must not be overwritten"
4251 );
4252 assert!(
4254 result.output.contains("trust_level = \"trusted\""),
4255 "entry without trust_level must get trusted"
4256 );
4257 }
4258
4259 #[test]
4260 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4261 let src = "[agent]\nname = \"Zeph\"\n";
4262 let result = migrate_mcp_trust_levels(src).expect("migrate");
4263 assert_eq!(result.changed_count, 0);
4264 assert!(result.sections_changed.is_empty());
4265 assert_eq!(result.output, src);
4266 }
4267
4268 #[test]
4269 fn migrate_mcp_trust_levels_no_servers_is_noop() {
4270 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4271 let result = migrate_mcp_trust_levels(src).expect("migrate");
4272 assert_eq!(result.changed_count, 0);
4273 assert!(result.sections_changed.is_empty());
4274 assert_eq!(result.output, src);
4275 }
4276
4277 #[test]
4278 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4279 let src = r#"
4280[[mcp.servers]]
4281id = "srv-a"
4282trust_level = "trusted"
4283
4284[[mcp.servers]]
4285id = "srv-b"
4286trust_level = "untrusted"
4287"#;
4288 let result = migrate_mcp_trust_levels(src).expect("migrate");
4289 assert_eq!(result.changed_count, 0);
4290 assert!(result.sections_changed.is_empty());
4291 }
4292
4293 #[test]
4294 fn migrate_database_url_adds_comment_when_absent() {
4295 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4296 let result = migrate_database_url(src).expect("migrate");
4297 assert_eq!(result.changed_count, 1);
4298 assert!(
4299 result
4300 .sections_changed
4301 .contains(&"memory.database_url".to_owned())
4302 );
4303 assert!(result.output.contains("# database_url = \"\""));
4304 }
4305
4306 #[test]
4307 fn migrate_database_url_is_noop_when_present() {
4308 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4309 let result = migrate_database_url(src).expect("migrate");
4310 assert_eq!(result.changed_count, 0);
4311 assert!(result.sections_changed.is_empty());
4312 assert_eq!(result.output, src);
4313 }
4314
4315 #[test]
4316 fn migrate_database_url_creates_memory_section_when_absent() {
4317 let src = "[agent]\nname = \"Zeph\"\n";
4318 let result = migrate_database_url(src).expect("migrate");
4319 assert_eq!(result.changed_count, 1);
4320 assert!(result.output.contains("# database_url = \"\""));
4321 }
4322
4323 #[test]
4326 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4327 let src = "[agent]\nname = \"Zeph\"\n";
4328 let result = migrate_agent_budget_hint(src).expect("migrate");
4329 assert_eq!(result.changed_count, 1);
4330 assert!(result.output.contains("budget_hint_enabled"));
4331 assert!(
4332 result
4333 .sections_changed
4334 .contains(&"agent.budget_hint_enabled".to_owned())
4335 );
4336 }
4337
4338 #[test]
4339 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4340 let src = "[llm]\nmodel = \"gpt-4o\"\n";
4341 let result = migrate_agent_budget_hint(src).expect("migrate");
4342 assert_eq!(result.changed_count, 0);
4343 assert_eq!(result.output, src);
4344 }
4345
4346 #[test]
4347 fn migrate_agent_budget_hint_already_present_is_noop() {
4348 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4349 let result = migrate_agent_budget_hint(src).expect("migrate");
4350 assert_eq!(result.changed_count, 0);
4351 assert_eq!(result.output, src);
4352 }
4353
4354 #[test]
4355 fn migrate_telemetry_config_empty_config_appends_comment_block() {
4356 let src = "[agent]\nname = \"Zeph\"\n";
4357 let result = migrate_telemetry_config(src).expect("migrate");
4358 assert_eq!(result.changed_count, 1);
4359 assert_eq!(result.sections_changed, vec!["telemetry"]);
4360 assert!(
4361 result.output.contains("# [telemetry]"),
4362 "expected commented-out [telemetry] block in output"
4363 );
4364 assert!(
4365 result.output.contains("enabled = false"),
4366 "expected enabled = false in telemetry comment block"
4367 );
4368 }
4369
4370 #[test]
4371 fn migrate_telemetry_config_existing_section_is_noop() {
4372 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4373 let result = migrate_telemetry_config(src).expect("migrate");
4374 assert_eq!(result.changed_count, 0);
4375 assert_eq!(result.output, src);
4376 }
4377
4378 #[test]
4379 fn migrate_telemetry_config_existing_comment_is_noop() {
4380 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4382 let result = migrate_telemetry_config(src).expect("migrate");
4383 assert_eq!(result.changed_count, 0);
4384 assert_eq!(result.output, src);
4385 }
4386
4387 #[test]
4390 fn migrate_otel_filter_already_present_is_noop() {
4391 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4393 let result = migrate_otel_filter(src).expect("migrate");
4394 assert_eq!(result.changed_count, 0);
4395 assert_eq!(result.output, src);
4396 }
4397
4398 #[test]
4399 fn migrate_otel_filter_commented_key_is_noop() {
4400 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4402 let result = migrate_otel_filter(src).expect("migrate");
4403 assert_eq!(result.changed_count, 0);
4404 assert_eq!(result.output, src);
4405 }
4406
4407 #[test]
4408 fn migrate_otel_filter_no_telemetry_section_is_noop() {
4409 let src = "[agent]\nname = \"Zeph\"\n";
4411 let result = migrate_otel_filter(src).expect("migrate");
4412 assert_eq!(result.changed_count, 0);
4413 assert_eq!(result.output, src);
4414 assert!(!result.output.contains("otel_filter"));
4415 }
4416
4417 #[test]
4418 fn migrate_otel_filter_injects_within_telemetry_section() {
4419 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4420 let result = migrate_otel_filter(src).expect("migrate");
4421 assert_eq!(result.changed_count, 1);
4422 assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4423 assert!(
4424 result.output.contains("otel_filter"),
4425 "otel_filter comment must appear"
4426 );
4427 let otel_pos = result
4429 .output
4430 .find("otel_filter")
4431 .expect("otel_filter present");
4432 let agent_pos = result.output.find("[agent]").expect("[agent] present");
4433 assert!(
4434 otel_pos < agent_pos,
4435 "otel_filter comment should appear before [agent] section"
4436 );
4437 }
4438
4439 #[test]
4440 fn sandbox_migration_adds_commented_section_when_absent() {
4441 let src = "[agent]\nname = \"Z\"\n";
4442 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4443 assert_eq!(result.changed_count, 1);
4444 assert!(result.output.contains("# [tools.sandbox]"));
4445 assert!(result.output.contains("# profile = \"workspace\""));
4446 }
4447
4448 #[test]
4449 fn sandbox_migration_noop_when_section_present() {
4450 let src = "[tools.sandbox]\nenabled = true\n";
4451 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4452 assert_eq!(result.changed_count, 0);
4453 }
4454
4455 #[test]
4456 fn sandbox_migration_noop_when_dotted_key_present() {
4457 let src = "[tools]\nsandbox = { enabled = true }\n";
4458 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4459 assert_eq!(result.changed_count, 0);
4460 }
4461
4462 #[test]
4463 fn sandbox_migration_false_positive_comment_does_not_block() {
4464 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
4466 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4467 assert_eq!(result.changed_count, 1);
4468 }
4469
4470 #[test]
4471 fn embedded_default_mentions_tools_sandbox() {
4472 let default_src = include_str!("../../config/default.toml");
4473 assert!(
4474 default_src.contains("tools.sandbox"),
4475 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
4476 );
4477 }
4478
4479 #[test]
4480 fn sandbox_migration_idempotent_on_own_output() {
4481 let base = "[agent]\nmodel = \"test\"\n";
4482 let first = migrate_sandbox_config(base).unwrap();
4483 assert_eq!(first.changed_count, 1);
4484 let second = migrate_sandbox_config(&first.output).unwrap();
4485 assert_eq!(second.changed_count, 0, "second run must not double-append");
4486 assert_eq!(second.output, first.output);
4487 }
4488
4489 #[test]
4490 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
4491 let base = "[agent]\nname = \"Zeph\"\n";
4492 let first = migrate_agent_budget_hint(base).unwrap();
4493 assert_eq!(first.changed_count, 1);
4494 let second = migrate_agent_budget_hint(&first.output).unwrap();
4495 assert_eq!(second.changed_count, 0, "second run must not double-append");
4496 assert_eq!(second.output, first.output);
4497 }
4498
4499 #[test]
4500 fn migrate_forgetting_config_idempotent_on_commented_output() {
4501 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4502 let first = migrate_forgetting_config(base).unwrap();
4503 assert_eq!(first.changed_count, 1);
4504 let second = migrate_forgetting_config(&first.output).unwrap();
4505 assert_eq!(second.changed_count, 0, "second run must not double-append");
4506 assert_eq!(second.output, first.output);
4507 }
4508
4509 #[test]
4510 fn migrate_microcompact_config_idempotent_on_commented_output() {
4511 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4512 let first = migrate_microcompact_config(base).unwrap();
4513 assert_eq!(first.changed_count, 1);
4514 let second = migrate_microcompact_config(&first.output).unwrap();
4515 assert_eq!(second.changed_count, 0, "second run must not double-append");
4516 assert_eq!(second.output, first.output);
4517 }
4518
4519 #[test]
4520 fn migrate_autodream_config_idempotent_on_commented_output() {
4521 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4522 let first = migrate_autodream_config(base).unwrap();
4523 assert_eq!(first.changed_count, 1);
4524 let second = migrate_autodream_config(&first.output).unwrap();
4525 assert_eq!(second.changed_count, 0, "second run must not double-append");
4526 assert_eq!(second.output, first.output);
4527 }
4528
4529 #[test]
4530 fn migrate_compression_predictor_strips_active_section() {
4531 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
4532 let result = migrate_compression_predictor_config(base).unwrap();
4533 assert!(!result.output.contains("[memory.compression.predictor]"));
4534 assert!(!result.output.contains("min_samples"));
4535 assert!(result.output.contains("[memory.other]"));
4536 assert_eq!(result.changed_count, 1);
4537 }
4538
4539 #[test]
4540 fn migrate_compression_predictor_strips_commented_section() {
4541 let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
4542 let result = migrate_compression_predictor_config(base).unwrap();
4543 assert!(!result.output.contains("compression.predictor"));
4544 assert!(result.output.contains("[memory.other]"));
4545 }
4546
4547 #[test]
4548 fn migrate_compression_predictor_idempotent() {
4549 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
4550 let first = migrate_compression_predictor_config(base).unwrap();
4551 let second = migrate_compression_predictor_config(&first.output).unwrap();
4552 assert_eq!(second.output, first.output);
4553 assert_eq!(second.changed_count, 0);
4554 }
4555
4556 #[test]
4557 fn migrate_compression_predictor_noop_when_absent() {
4558 let base = "[memory]\ndb_path = \"test\"\n";
4559 let result = migrate_compression_predictor_config(base).unwrap();
4560 assert_eq!(result.output, base);
4561 assert_eq!(result.changed_count, 0);
4562 }
4563
4564 #[test]
4565 fn migrate_database_url_idempotent_on_commented_output() {
4566 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4567 let first = migrate_database_url(base).unwrap();
4568 assert_eq!(first.changed_count, 1);
4569 let second = migrate_database_url(&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_shell_transactional_idempotent_on_commented_output() {
4576 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
4577 let first = migrate_shell_transactional(base).unwrap();
4578 assert_eq!(first.changed_count, 1);
4579 let second = migrate_shell_transactional(&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_otel_filter_idempotent_on_commented_output() {
4586 let base = "[telemetry]\nenabled = true\n";
4587 let first = migrate_otel_filter(base).unwrap();
4588 assert_eq!(first.changed_count, 1);
4589 let second = migrate_otel_filter(&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 config_migrator_does_not_suppress_duplicate_key_across_sections() {
4596 let migrator = ConfigMigrator::new();
4597 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4598 let result = migrator.migrate(src).expect("migrate");
4599 let sec_body_start = result
4600 .output
4601 .find("[security.content_isolation]")
4602 .unwrap_or(0);
4603 let sec_body = &result.output[sec_body_start..];
4604 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4605 let sec_slice = &sec_body[..next_header];
4606 assert!(
4607 sec_slice.contains("# enabled"),
4608 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4609 );
4610 }
4611
4612 #[test]
4613 fn config_migrator_idempotent_on_realistic_config() {
4614 let base = r#"
4615[agent]
4616name = "Zeph"
4617
4618[memory]
4619db_path = "~/.zeph/memory.db"
4620soft_compaction_threshold = 0.6
4621
4622[index]
4623max_chunks = 12
4624
4625[tools]
4626[tools.shell]
4627allow_list = []
4628
4629[telemetry]
4630enabled = false
4631
4632[security]
4633[security.content_isolation]
4634enabled = true
4635"#;
4636 let migrator = ConfigMigrator::new();
4637 let first = migrator.migrate(base).expect("first migrate");
4638 let second = migrator.migrate(&first.output).expect("second migrate");
4639 assert_eq!(
4640 second.changed_count, 0,
4641 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4642 second.changed_count
4643 );
4644 assert_eq!(
4645 first.output, second.output,
4646 "output must be identical on second run"
4647 );
4648 for line in first.output.lines() {
4649 if line.starts_with('[') && !line.starts_with("[[") {
4650 assert!(
4651 !line.contains('#'),
4652 "section header must not have inline comment: {line:?}"
4653 );
4654 }
4655 }
4656 }
4657
4658 #[test]
4659 fn migrate_claude_prompt_cache_ttl_1h_survives() {
4660 let src = r#"
4661[llm]
4662provider = "claude"
4663
4664[llm.cloud]
4665model = "claude-sonnet-4-6"
4666prompt_cache_ttl = "1h"
4667"#;
4668 let result = migrate_llm_to_providers(src).expect("migrate");
4669 assert!(
4670 result.output.contains("prompt_cache_ttl = \"1h\""),
4671 "1h TTL must be preserved in migrated output:\n{}",
4672 result.output
4673 );
4674 }
4675
4676 #[test]
4677 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4678 let src = r#"
4679[llm]
4680provider = "claude"
4681
4682[llm.cloud]
4683model = "claude-sonnet-4-6"
4684prompt_cache_ttl = "ephemeral"
4685"#;
4686 let result = migrate_llm_to_providers(src).expect("migrate");
4687 assert!(
4688 !result.output.contains("prompt_cache_ttl"),
4689 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4690 result.output
4691 );
4692 }
4693
4694 #[test]
4695 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4696 let src = r#"
4697[[llm.providers]]
4698type = "claude"
4699model = "claude-sonnet-4-6"
4700prompt_cache_ttl = "1h"
4701"#;
4702 let migrator = ConfigMigrator::new();
4703 let first = migrator.migrate(src).expect("first migrate");
4704 let second = migrator.migrate(&first.output).expect("second migrate");
4705 assert_eq!(
4706 first.output, second.output,
4707 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4708 );
4709 }
4710
4711 #[test]
4714 fn migrate_session_recap_adds_block_when_absent() {
4715 let src = "[agent]\nname = \"Zeph\"\n";
4716 let result = migrate_session_recap_config(src).expect("migrate");
4717 assert_eq!(result.changed_count, 1);
4718 assert!(
4719 result
4720 .sections_changed
4721 .contains(&"session.recap".to_owned())
4722 );
4723 assert!(result.output.contains("# [session.recap]"));
4724 assert!(result.output.contains("on_resume = true"));
4725 }
4726
4727 #[test]
4728 fn migrate_session_recap_idempotent_on_commented_block() {
4729 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4730 let result = migrate_session_recap_config(src).expect("migrate");
4731 assert_eq!(result.changed_count, 0);
4732 assert_eq!(result.output, src);
4733 }
4734
4735 #[test]
4736 fn migrate_session_recap_idempotent_on_active_section() {
4737 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4738 let result = migrate_session_recap_config(src).expect("migrate");
4739 assert_eq!(result.changed_count, 0);
4740 assert_eq!(result.output, src);
4741 }
4742
4743 #[test]
4746 fn migrate_mcp_elicitation_adds_keys_when_absent() {
4747 let src = "[mcp]\nallowed_commands = []\n";
4748 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4749 assert_eq!(result.changed_count, 1);
4750 assert!(
4751 result
4752 .sections_changed
4753 .contains(&"mcp.elicitation".to_owned())
4754 );
4755 assert!(result.output.contains("# elicitation_enabled = false"));
4756 assert!(result.output.contains("# elicitation_timeout = 120"));
4757 }
4758
4759 #[test]
4760 fn migrate_mcp_elicitation_idempotent_when_key_present() {
4761 let src = "[mcp]\nelicitation_enabled = true\n";
4762 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4763 assert_eq!(result.changed_count, 0);
4764 assert_eq!(result.output, src);
4765 }
4766
4767 #[test]
4768 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4769 let src = "[agent]\nname = \"Zeph\"\n";
4770 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4771 assert_eq!(result.changed_count, 0);
4772 assert_eq!(result.output, src);
4773 }
4774
4775 #[test]
4776 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4777 let src = "[mcp]";
4779 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4780 assert_eq!(result.changed_count, 0);
4781 assert_eq!(result.output, src);
4782 }
4783
4784 #[test]
4787 fn migrate_quality_adds_block_when_absent() {
4788 let src = "[agent]\nname = \"Zeph\"\n";
4789 let result = migrate_quality_config(src).expect("migrate");
4790 assert_eq!(result.changed_count, 1);
4791 assert!(result.sections_changed.contains(&"quality".to_owned()));
4792 assert!(result.output.contains("# [quality]"));
4793 assert!(result.output.contains("self_check = false"));
4794 assert!(result.output.contains("trigger = \"has_retrieval\""));
4795 }
4796
4797 #[test]
4798 fn migrate_quality_idempotent_on_commented_block() {
4799 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4800 let result = migrate_quality_config(src).expect("migrate");
4801 assert_eq!(result.changed_count, 0);
4802 assert_eq!(result.output, src);
4803 }
4804
4805 #[test]
4806 fn migrate_quality_idempotent_on_active_section() {
4807 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4808 let result = migrate_quality_config(src).expect("migrate");
4809 assert_eq!(result.changed_count, 0);
4810 assert_eq!(result.output, src);
4811 }
4812
4813 #[test]
4816 fn migrate_acp_subagents_adds_block_when_absent() {
4817 let src = "[agent]\nname = \"Zeph\"\n";
4818 let result = migrate_acp_subagents_config(src).expect("migrate");
4819 assert_eq!(result.changed_count, 1);
4820 assert!(
4821 result
4822 .sections_changed
4823 .contains(&"acp.subagents".to_owned())
4824 );
4825 assert!(result.output.contains("# [acp.subagents]"));
4826 assert!(result.output.contains("enabled = false"));
4827 }
4828
4829 #[test]
4830 fn migrate_acp_subagents_idempotent_on_existing_block() {
4831 let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4832 let result = migrate_acp_subagents_config(src).expect("migrate");
4833 assert_eq!(result.changed_count, 0);
4834 assert_eq!(result.output, src);
4835 }
4836
4837 #[test]
4840 fn migrate_hooks_permission_denied_adds_block_when_absent() {
4841 let src = "[agent]\nname = \"Zeph\"\n";
4842 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4843 assert_eq!(result.changed_count, 1);
4844 assert!(
4845 result
4846 .sections_changed
4847 .contains(&"hooks.permission_denied".to_owned())
4848 );
4849 assert!(result.output.contains("# [[hooks.permission_denied]]"));
4850 assert!(result.output.contains("ZEPH_TOOL"));
4851 }
4852
4853 #[test]
4854 fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4855 let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4856 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4857 assert_eq!(result.changed_count, 0);
4858 assert_eq!(result.output, src);
4859 }
4860
4861 #[test]
4864 fn migrate_memory_graph_adds_block_when_absent() {
4865 let src = "[agent]\nname = \"Zeph\"\n";
4866 let result = migrate_memory_graph_config(src).expect("migrate");
4867 assert_eq!(result.changed_count, 1);
4868 assert!(
4869 result
4870 .sections_changed
4871 .contains(&"memory.graph.retrieval".to_owned())
4872 );
4873 assert!(result.output.contains("retrieval_strategy"));
4874 assert!(result.output.contains("# [memory.graph.beam_search]"));
4875 }
4876
4877 #[test]
4878 fn migrate_memory_graph_idempotent_on_existing_block() {
4879 let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4880 let result = migrate_memory_graph_config(src).expect("migrate");
4881 assert_eq!(result.changed_count, 0);
4882 assert_eq!(result.output, src);
4883 }
4884
4885 #[test]
4888 fn migrate_scheduler_daemon_adds_block_when_absent() {
4889 let src = "[agent]\nname = \"Zeph\"\n";
4890 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4891 assert_eq!(result.changed_count, 1);
4892 assert!(
4893 result
4894 .sections_changed
4895 .contains(&"scheduler.daemon".to_owned())
4896 );
4897 assert!(result.output.contains("# [scheduler.daemon]"));
4898 assert!(result.output.contains("pid_file"));
4899 assert!(result.output.contains("tick_secs = 60"));
4900 assert!(result.output.contains("shutdown_grace_secs = 30"));
4901 assert!(result.output.contains("catch_up = true"));
4902 }
4903
4904 #[test]
4905 fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4906 let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4907 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4908 assert_eq!(result.changed_count, 0);
4909 assert_eq!(result.output, src);
4910 }
4911
4912 #[test]
4915 fn migrate_memory_retrieval_adds_block_when_absent() {
4916 let src = "[agent]\nname = \"Zeph\"\n";
4917 let result = migrate_memory_retrieval_config(src).expect("migrate");
4918 assert_eq!(result.changed_count, 1);
4919 assert!(
4920 result
4921 .sections_changed
4922 .contains(&"memory.retrieval".to_owned())
4923 );
4924 assert!(result.output.contains("# [memory.retrieval]"));
4925 assert!(result.output.contains("depth = 0"));
4926 assert!(result.output.contains("context_format"));
4927 }
4928
4929 #[test]
4930 fn migrate_memory_retrieval_idempotent_on_active_section() {
4931 let src = "[memory.retrieval]\ndepth = 40\n";
4932 let result = migrate_memory_retrieval_config(src).expect("migrate");
4933 assert_eq!(result.changed_count, 0);
4934 assert_eq!(result.output, src);
4935 }
4936
4937 #[test]
4938 fn migrate_memory_retrieval_idempotent_on_commented_section() {
4939 let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
4940 let result = migrate_memory_retrieval_config(src).expect("migrate");
4941 assert_eq!(result.changed_count, 0);
4942 assert_eq!(result.output, src);
4943 }
4944
4945 #[test]
4948 fn migrate_adds_pr4_acp_keys_commented() {
4949 let migrator = ConfigMigrator::new();
4950 let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
4951 let out = migrator.migrate(input).expect("migrate");
4952 assert!(
4953 out.output.contains("# additional_directories = []"),
4954 "expected commented additional_directories; got:\n{}",
4955 out.output
4956 );
4957 assert!(
4958 out.output.contains("# auth_methods = [\"agent\"]"),
4959 "expected commented auth_methods; got:\n{}",
4960 out.output
4961 );
4962 assert!(
4963 out.output.contains("# message_ids_enabled = true"),
4964 "expected commented message_ids_enabled; got:\n{}",
4965 out.output
4966 );
4967 }
4968
4969 #[test]
4972 fn migrate_memory_reasoning_adds_block_when_absent() {
4973 let input = "[agent]\nmodel = \"gpt-4o\"\n";
4974 let result = migrate_memory_reasoning_config(input).unwrap();
4975 assert_eq!(result.changed_count, 1);
4976 assert!(
4977 result
4978 .sections_changed
4979 .contains(&"memory.reasoning".to_owned())
4980 );
4981 assert!(result.output.contains("# [memory.reasoning]"));
4982 assert!(result.output.contains("extraction_timeout_secs = 30"));
4983 assert!(result.output.contains("max_message_chars = 2000"));
4984 }
4985
4986 #[test]
4987 fn migrate_memory_reasoning_idempotent_on_existing_block() {
4988 let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
4989 let result = migrate_memory_reasoning_config(input).unwrap();
4990 assert_eq!(result.changed_count, 0);
4991 assert!(result.sections_changed.is_empty());
4992 assert_eq!(result.output, input);
4993 }
4994
4995 #[test]
4998 fn migrate_hooks_turn_complete_adds_block_when_absent() {
4999 let input = "[agent]\nmodel = \"gpt-4o\"\n";
5000 let result = migrate_hooks_turn_complete_config(input).unwrap();
5001 assert_eq!(result.changed_count, 1);
5002 assert!(
5003 result
5004 .sections_changed
5005 .contains(&"hooks.turn_complete".to_owned())
5006 );
5007 assert!(result.output.contains("# [[hooks.turn_complete]]"));
5008 assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
5009 assert!(result.output.contains("timeout_secs = 3"));
5010 }
5011
5012 #[test]
5013 fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
5014 let input =
5015 "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
5016 let result = migrate_hooks_turn_complete_config(input).unwrap();
5017 assert_eq!(result.changed_count, 0);
5018 assert!(result.sections_changed.is_empty());
5019 assert_eq!(result.output, input);
5020 }
5021
5022 #[test]
5026 fn migrate_focus_auto_consolidate_injects_inside_section() {
5027 let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
5028 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5029 assert_eq!(result.changed_count, 1);
5030 let comment_pos = result
5031 .output
5032 .find("auto_consolidate_min_window")
5033 .expect("comment must be present");
5034 let other_pos = result
5035 .output
5036 .find("[other]")
5037 .expect("[other] must be present");
5038 assert!(
5039 comment_pos < other_pos,
5040 "auto_consolidate_min_window comment must appear before [other] section"
5041 );
5042 }
5043
5044 #[test]
5045 fn migrate_focus_auto_consolidate_idempotent() {
5046 let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
5047 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5048 assert_eq!(result.changed_count, 0);
5049 assert_eq!(result.output, input);
5050 }
5051
5052 #[test]
5053 fn migrate_focus_auto_consolidate_noop_when_section_absent() {
5054 let input = "[agent]\nname = \"zeph\"\n";
5055 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5056 assert_eq!(result.changed_count, 0);
5057 assert_eq!(result.output, input);
5058 }
5059
5060 #[test]
5061 fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
5062 let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
5063 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5064 assert_eq!(result.changed_count, 0);
5065 assert_eq!(result.output, input);
5066 }
5067
5068 #[test]
5071 fn registry_has_forty_five_entries() {
5072 assert_eq!(MIGRATIONS.len(), 45);
5073 }
5074
5075 #[test]
5076 fn registry_names_are_unique_and_non_empty() {
5077 let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5078 for name in &names {
5079 assert!(!name.is_empty(), "migration name must not be empty");
5080 }
5081 let mut deduped = names.clone();
5082 deduped.sort_unstable();
5083 deduped.dedup();
5084 assert_eq!(deduped.len(), names.len(), "migration names must be unique");
5085 }
5086
5087 #[test]
5088 fn registry_is_idempotent_on_empty_input() {
5089 const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
5092
5093 let mut toml = String::new();
5094 for m in MIGRATIONS.iter() {
5095 let result = m.apply(&toml).expect("registry migration must not fail");
5096 toml = result.output;
5097 }
5098 for m in MIGRATIONS.iter() {
5099 if COMMENT_ONLY.contains(&m.name()) {
5100 continue;
5101 }
5102 let result = m
5103 .apply(&toml)
5104 .expect("registry migration must not fail on second pass");
5105 assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
5106 }
5107 }
5108
5109 #[test]
5110 fn registry_preserves_order_matches_dispatch() {
5111 let expected = [
5113 "migrate_stt_to_provider",
5114 "migrate_planner_model_to_provider",
5115 "migrate_mcp_trust_levels",
5116 "migrate_agent_retry_to_tools_retry",
5117 "migrate_database_url",
5118 "migrate_shell_transactional",
5119 "migrate_agent_budget_hint",
5120 "migrate_forgetting_config",
5121 "migrate_compression_predictor_config",
5122 "migrate_microcompact_config",
5123 "migrate_autodream_config",
5124 "migrate_magic_docs_config",
5125 "migrate_telemetry_config",
5126 "migrate_supervisor_config",
5127 "migrate_otel_filter",
5128 "migrate_egress_config",
5129 "migrate_vigil_config",
5130 "migrate_sandbox_config",
5131 "migrate_sandbox_egress_filter",
5132 "migrate_orchestration_persistence",
5133 "migrate_session_recap_config",
5134 "migrate_mcp_elicitation_config",
5135 "migrate_quality_config",
5136 "migrate_acp_subagents_config",
5137 "migrate_hooks_permission_denied_config",
5138 "migrate_memory_graph_config",
5139 "migrate_scheduler_daemon_config",
5140 "migrate_memory_retrieval_config",
5141 "migrate_memory_reasoning_config",
5142 "migrate_memory_reasoning_judge_config",
5143 "migrate_memory_hebbian_config",
5144 "migrate_memory_hebbian_consolidation_config",
5145 "migrate_memory_hebbian_spread_config",
5146 "migrate_hooks_turn_complete_config",
5147 "migrate_focus_auto_consolidate_min_window",
5148 "migrate_session_provider_persistence",
5149 "migrate_memory_retrieval_query_bias",
5150 "migrate_memory_persona_config",
5151 "migrate_qdrant_api_key",
5152 "migrate_mcp_max_connect_attempts",
5153 "migrate_goals_config",
5154 "migrate_tools_compression_config",
5155 "migrate_orchestrator_provider",
5156 "migrate_provider_max_concurrent",
5157 "migrate_gonkagate_to_gonka",
5158 ];
5159 let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5160 assert_eq!(actual, expected);
5161 }
5162
5163 #[test]
5166 fn migrate_qdrant_api_key_adds_comment_when_absent() {
5167 let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5168 let result = migrate_qdrant_api_key(src).expect("migrate");
5169 assert_eq!(result.changed_count, 1);
5170 assert!(
5171 result
5172 .sections_changed
5173 .contains(&"memory.qdrant_api_key".to_owned())
5174 );
5175 assert!(result.output.contains("# qdrant_api_key = \"\""));
5176 }
5177
5178 #[test]
5179 fn migrate_qdrant_api_key_is_noop_when_present() {
5180 let src =
5181 "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
5182 let result = migrate_qdrant_api_key(src).expect("migrate");
5183 assert_eq!(result.changed_count, 0);
5184 assert!(result.sections_changed.is_empty());
5185 assert_eq!(result.output, src);
5186 }
5187
5188 #[test]
5189 fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
5190 let src = "[agent]\nname = \"Zeph\"\n";
5191 let result = migrate_qdrant_api_key(src).expect("migrate");
5192 assert_eq!(result.changed_count, 1);
5193 assert!(result.output.contains("# qdrant_api_key = \"\""));
5194 }
5195
5196 #[test]
5197 fn migrate_qdrant_api_key_idempotent_on_commented_output() {
5198 let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5199 let first = migrate_qdrant_api_key(base).unwrap();
5200 assert_eq!(first.changed_count, 1);
5201 let second = migrate_qdrant_api_key(&first.output).unwrap();
5202 assert_eq!(second.changed_count, 0, "second run must not double-append");
5203 assert_eq!(second.output, first.output);
5204 }
5205
5206 #[test]
5207 fn migrate_mcp_max_connect_attempts_adds_comment_when_absent() {
5208 let src = "[mcp]\nallowed_commands = []\n";
5209 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5210 assert_eq!(result.changed_count, 1);
5211 assert!(
5212 result.output.contains("max_connect_attempts"),
5213 "output must mention max_connect_attempts"
5214 );
5215 }
5216
5217 #[test]
5218 fn migrate_mcp_max_connect_attempts_idempotent_when_present() {
5219 let src = "[mcp]\n# max_connect_attempts = 3\nallowed_commands = []\n";
5220 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5221 assert_eq!(
5222 result.changed_count, 0,
5223 "must not modify already-present key"
5224 );
5225 assert_eq!(result.output, src);
5226 }
5227
5228 #[test]
5229 fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() {
5230 let src = "[agent]\nname = \"Zeph\"\n";
5231 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5232 assert_eq!(result.changed_count, 0);
5233 assert_eq!(result.output, src);
5234 }
5235
5236 #[test]
5239 fn step43_adds_orchestrator_provider_comment_when_absent() {
5240 let src = "[orchestration]\nenabled = true\n";
5241 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5242 assert_eq!(result.changed_count, 1);
5243 assert!(
5244 result.output.contains("orchestrator_provider"),
5245 "migration must inject orchestrator_provider hint"
5246 );
5247 }
5248
5249 #[test]
5250 fn step43_noop_when_orchestrator_provider_already_present() {
5251 let src = "[orchestration]\nenabled = true\norchestrator_provider = \"\"\n";
5252 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5253 assert_eq!(
5254 result.changed_count, 0,
5255 "must not modify already-present key"
5256 );
5257 assert_eq!(result.output, src);
5258 }
5259
5260 #[test]
5263 fn step44_adds_max_concurrent_comment_when_providers_present() {
5264 let src = "[[llm.providers]]\nname = \"quality\"\ntype = \"openai\"\n";
5265 let result = migrate_provider_max_concurrent(src).expect("migrate");
5266 assert_eq!(result.changed_count, 1);
5267 assert!(
5268 result.output.contains("max_concurrent"),
5269 "migration must inject max_concurrent hint"
5270 );
5271 }
5272
5273 #[test]
5274 fn step44_noop_when_max_concurrent_already_present() {
5275 let src = "[[llm.providers]]\nname = \"quality\"\nmax_concurrent = 4\n";
5276 let result = migrate_provider_max_concurrent(src).expect("migrate");
5277 assert_eq!(
5278 result.changed_count, 0,
5279 "must not modify already-present key"
5280 );
5281 assert_eq!(result.output, src);
5282 }
5283
5284 #[test]
5285 fn step44_noop_when_no_providers_section() {
5286 let src = "[agent]\nname = \"Zeph\"\n";
5287 let result = migrate_provider_max_concurrent(src).expect("migrate");
5288 assert_eq!(result.changed_count, 0);
5289 assert_eq!(result.output, src);
5290 }
5291
5292 #[test]
5295 fn step45_adds_advisory_comment_when_gonkagate_present() {
5296 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5297 let result = migrate_gonkagate_to_gonka(src);
5298 assert!(result.changed_count > 0, "must detect gonkagate entry");
5299 assert!(
5300 result.output.contains("[migration] GonkaGate detected"),
5301 "advisory comment must be added"
5302 );
5303 let comment_pos = result
5305 .output
5306 .find("[migration] GonkaGate detected")
5307 .unwrap();
5308 let header_pos = result.output.find("[[llm.providers]]").unwrap();
5309 assert!(
5310 comment_pos < header_pos,
5311 "advisory comment must precede the [[llm.providers]] header"
5312 );
5313 }
5314
5315 #[test]
5316 fn step45_noop_when_no_gonkagate() {
5317 let src = "[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n";
5318 let result = migrate_gonkagate_to_gonka(src);
5319 assert_eq!(result.changed_count, 0);
5320 assert_eq!(result.output, src);
5321 }
5322
5323 #[test]
5324 fn step45_does_not_double_insert_comment() {
5325 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5326 let first = migrate_gonkagate_to_gonka(src);
5327 let second = migrate_gonkagate_to_gonka(&first.output);
5328 assert_eq!(second.changed_count, 0, "idempotent on second run");
5330 }
5331}