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_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2330 if toml_src
2332 .lines()
2333 .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2334 {
2335 return Ok(MigrationResult {
2336 output: toml_src.to_owned(),
2337 changed_count: 0,
2338 sections_changed: Vec::new(),
2339 });
2340 }
2341
2342 let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2343 # [quality]\n\
2344 # self_check = false # enable post-response self-check\n\
2345 # trigger = \"has_retrieval\" # has_retrieval | always | manual\n\
2346 # latency_budget_ms = 4000 # hard ceiling for the whole pipeline\n\
2347 # proposer_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2348 # checker_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2349 # min_evidence = 0.6 # 0.0..1.0; below → flag assertion\n\
2350 # async_run = false # true = fire-and-forget (non-blocking)\n\
2351 # per_call_timeout_ms = 2000 # per-LLM-call timeout\n\
2352 # max_assertions = 12 # maximum assertions extracted from one response\n\
2353 # max_response_chars = 8000 # skip pipeline when response exceeds this\n\
2354 # cache_disabled_for_checker = true # suppress prompt-cache on Checker provider\n\
2355 # flag_marker = \"[verify]\" # marker appended when assertions are flagged\n";
2356 let output = format!("{toml_src}{comment}");
2357
2358 Ok(MigrationResult {
2359 output,
2360 changed_count: 1,
2361 sections_changed: vec!["quality".to_owned()],
2362 })
2363}
2364
2365pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2376 if toml_src
2377 .lines()
2378 .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2379 {
2380 return Ok(MigrationResult {
2381 output: toml_src.to_owned(),
2382 changed_count: 0,
2383 sections_changed: Vec::new(),
2384 });
2385 }
2386
2387 let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2388 # [acp.subagents]\n\
2389 # enabled = false\n\
2390 #\n\
2391 # [[acp.subagents.presets]]\n\
2392 # name = \"inner\" # identifier used in /subagent commands\n\
2393 # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2394 # # cwd = \"/path/to/agent\" # optional working directory\n\
2395 # # handshake_timeout_secs = 30 # initialize+session/new timeout\n\
2396 # # prompt_timeout_secs = 600 # single round-trip timeout\n";
2397 let output = format!("{toml_src}{comment}");
2398
2399 Ok(MigrationResult {
2400 output,
2401 changed_count: 1,
2402 sections_changed: vec!["acp.subagents".to_owned()],
2403 })
2404}
2405
2406pub fn migrate_hooks_permission_denied_config(
2417 toml_src: &str,
2418) -> Result<MigrationResult, MigrateError> {
2419 if toml_src.lines().any(|l| {
2420 l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2421 }) {
2422 return Ok(MigrationResult {
2423 output: toml_src.to_owned(),
2424 changed_count: 0,
2425 sections_changed: Vec::new(),
2426 });
2427 }
2428
2429 let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2430 # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2431 # [[hooks.permission_denied]]\n\
2432 # [hooks.permission_denied.action]\n\
2433 # type = \"command\"\n\
2434 # command = \"echo denied: $ZEPH_TOOL\"\n";
2435 let output = format!("{toml_src}{comment}");
2436
2437 Ok(MigrationResult {
2438 output,
2439 changed_count: 1,
2440 sections_changed: vec!["hooks.permission_denied".to_owned()],
2441 })
2442}
2443
2444pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2455 if toml_src.contains("retrieval_strategy")
2456 || toml_src.contains("[memory.graph.beam_search]")
2457 || toml_src.contains("# [memory.graph.beam_search]")
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# [memory.graph] retrieval strategy options (#3311).\n\
2467 # retrieval_strategy = \"synapse\" # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2468 #\n\
2469 # [memory.graph.beam_search] # active when retrieval_strategy = \"beam_search\"\n\
2470 # beam_width = 10 # top-K candidates kept per hop\n\
2471 #\n\
2472 # [memory.graph.watercircles] # active when retrieval_strategy = \"watercircles\"\n\
2473 # ring_limit = 0 # max facts per ring; 0 = auto\n\
2474 #\n\
2475 # [memory.graph.experience] # experience memory recording\n\
2476 # enabled = false\n\
2477 # evolution_sweep_enabled = false\n\
2478 # confidence_prune_threshold = 0.1 # prune edges below this threshold\n\
2479 # evolution_sweep_interval = 50 # turns between sweeps\n";
2480 let output = format!("{toml_src}{comment}");
2481
2482 Ok(MigrationResult {
2483 output,
2484 changed_count: 1,
2485 sections_changed: vec!["memory.graph.retrieval".to_owned()],
2486 })
2487}
2488
2489pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2500 if toml_src
2501 .lines()
2502 .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2503 {
2504 return Ok(MigrationResult {
2505 output: toml_src.to_owned(),
2506 changed_count: 0,
2507 sections_changed: Vec::new(),
2508 });
2509 }
2510
2511 let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2512 # [scheduler.daemon]\n\
2513 # pid_file = \"/tmp/zeph-scheduler.pid\" # PID file path (must be on a local filesystem)\n\
2514 # log_file = \"/tmp/zeph-scheduler.log\" # daemon log file path (append-only; rotate externally)\n\
2515 # tick_secs = 60 # scheduler tick interval in seconds (clamped 5..=3600)\n\
2516 # shutdown_grace_secs = 30 # grace period after SIGTERM before process exits\n\
2517 # catch_up = true # replay missed cron tasks on daemon restart\n";
2518 let output = format!("{toml_src}{comment}");
2519
2520 Ok(MigrationResult {
2521 output,
2522 changed_count: 1,
2523 sections_changed: vec!["scheduler.daemon".to_owned()],
2524 })
2525}
2526
2527pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2538 if toml_src
2539 .lines()
2540 .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2541 {
2542 return Ok(MigrationResult {
2543 output: toml_src.to_owned(),
2544 changed_count: 0,
2545 sections_changed: Vec::new(),
2546 });
2547 }
2548
2549 let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2550 # [memory.retrieval]\n\
2551 # depth = 0 # ANN candidates fetched from the vector store, directly.\n\
2552 # # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2553 # # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2554 # search_prompt_template = \"\" # embedding query template; {query} = raw user query; empty = identity\n\
2555 # context_format = \"structured\" # structured | plain — memory snippet rendering format\n\
2556 # query_bias_correction = true # shift first-person queries towards user profile centroid (MM-F3)\n\
2557 # query_bias_profile_weight = 0.25 # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2558 # query_bias_centroid_ttl_secs = 300 # seconds before profile centroid cache is recomputed\n";
2559 let output = format!("{toml_src}{comment}");
2560
2561 Ok(MigrationResult {
2562 output,
2563 changed_count: 1,
2564 sections_changed: vec!["memory.retrieval".to_owned()],
2565 })
2566}
2567
2568pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2579 if toml_src
2580 .lines()
2581 .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2582 {
2583 return Ok(MigrationResult {
2584 output: toml_src.to_owned(),
2585 changed_count: 0,
2586 sections_changed: Vec::new(),
2587 });
2588 }
2589
2590 let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2591 # [memory.reasoning]\n\
2592 # enabled = false\n\
2593 # extract_provider = \"\" # SLM: self-judge (JSON response) — leave blank to use primary\n\
2594 # distill_provider = \"\" # SLM: strategy distillation — leave blank to use primary\n\
2595 # top_k = 3 # strategies injected per turn\n\
2596 # store_limit = 1000 # max rows in reasoning_strategies table\n\
2597 # context_budget_tokens = 500\n\
2598 # extraction_timeout_secs = 30\n\
2599 # distill_timeout_secs = 30\n\
2600 # max_messages = 6\n\
2601 # min_messages = 2\n\
2602 # max_message_chars = 2000\n";
2603 let output = format!("{toml_src}{comment}");
2604
2605 Ok(MigrationResult {
2606 output,
2607 changed_count: 1,
2608 sections_changed: vec!["memory.reasoning".to_owned()],
2609 })
2610}
2611
2612pub fn migrate_memory_reasoning_judge_config(
2624 toml_src: &str,
2625) -> Result<MigrationResult, MigrateError> {
2626 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2627 if !has_section {
2628 return Ok(MigrationResult {
2629 output: toml_src.to_owned(),
2630 changed_count: 0,
2631 sections_changed: Vec::new(),
2632 });
2633 }
2634
2635 let has_window = toml_src.lines().any(|l| {
2637 let t = l.trim().trim_start_matches('#').trim();
2638 t.starts_with("self_judge_window")
2639 });
2640 let has_min_chars = toml_src.lines().any(|l| {
2641 let t = l.trim().trim_start_matches('#').trim();
2642 t.starts_with("min_assistant_chars")
2643 });
2644 if has_window && has_min_chars {
2645 return Ok(MigrationResult {
2646 output: toml_src.to_owned(),
2647 changed_count: 0,
2648 sections_changed: Vec::new(),
2649 });
2650 }
2651
2652 let lines: Vec<&str> = toml_src.lines().collect();
2656 let mut section_start = None;
2657 let mut insert_after = None;
2658
2659 for (i, line) in lines.iter().enumerate() {
2660 if line.trim() == "[memory.reasoning]" {
2661 section_start = Some(i);
2662 }
2663 if let Some(start) = section_start {
2664 let trimmed = line.trim();
2665 if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2667 break;
2668 }
2669 insert_after = Some(i);
2670 }
2671 }
2672
2673 let Some(insert_idx) = insert_after else {
2674 return Ok(MigrationResult {
2675 output: toml_src.to_owned(),
2676 changed_count: 0,
2677 sections_changed: Vec::new(),
2678 });
2679 };
2680
2681 let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2682 let mut additions = Vec::new();
2683 if !has_window {
2684 additions.push(
2685 "# self_judge_window = 2 # max recent messages passed to self-judge (#3383)"
2686 .to_owned(),
2687 );
2688 }
2689 if !has_min_chars {
2690 additions.push(
2691 "# min_assistant_chars = 50 # skip self-judge for short replies (#3383)".to_owned(),
2692 );
2693 }
2694 for (offset, line) in additions.iter().enumerate() {
2695 new_lines.insert(insert_idx + 1 + offset, line.clone());
2696 }
2697
2698 let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2699 Ok(MigrationResult {
2700 output,
2701 changed_count: additions.len(),
2702 sections_changed: vec!["memory.reasoning".to_owned()],
2703 })
2704}
2705
2706pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2716 if toml_src
2717 .lines()
2718 .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2719 {
2720 return Ok(MigrationResult {
2721 output: toml_src.to_owned(),
2722 changed_count: 0,
2723 sections_changed: Vec::new(),
2724 });
2725 }
2726
2727 let comment = "\n# [memory.hebbian] # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2728 # [memory.hebbian]\n\
2729 # enabled = false # opt-in master switch; no DB writes when false\n\
2730 # hebbian_lr = 0.1 # weight increment per co-activation (0.01–0.5)\n";
2731 let output = format!("{toml_src}{comment}");
2732
2733 Ok(MigrationResult {
2734 output,
2735 changed_count: 1,
2736 sections_changed: vec!["memory.hebbian".to_owned()],
2737 })
2738}
2739
2740pub fn migrate_memory_hebbian_consolidation_config(
2752 toml_src: &str,
2753) -> Result<MigrationResult, MigrateError> {
2754 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2755
2756 if !has_section {
2757 return Ok(MigrationResult {
2758 output: toml_src.to_owned(),
2759 changed_count: 0,
2760 sections_changed: Vec::new(),
2761 });
2762 }
2763
2764 let has_interval = toml_src
2766 .lines()
2767 .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2768 let has_threshold = toml_src
2769 .lines()
2770 .any(|l| l.trim().starts_with("consolidation_threshold"));
2771 let has_provider = toml_src
2772 .lines()
2773 .any(|l| l.trim().starts_with("consolidate_provider"));
2774
2775 if has_interval && has_threshold && has_provider {
2776 return Ok(MigrationResult {
2777 output: toml_src.to_owned(),
2778 changed_count: 0,
2779 sections_changed: Vec::new(),
2780 });
2781 }
2782
2783 let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2784 # consolidation_interval_secs = 3600 # how often the sweep runs (0 = disabled)\n\
2785 # consolidation_threshold = 5.0 # degree × avg_weight score to qualify\n\
2786 # consolidate_provider = \"fast\" # provider name for LLM distillation\n\
2787 # max_candidates_per_sweep = 10\n\
2788 # consolidation_cooldown_secs = 86400 # re-consolidation cooldown per entity\n\
2789 # consolidation_prompt_timeout_secs = 30\n\
2790 # consolidation_max_neighbors = 20\n";
2791
2792 let output = format!("{toml_src}{extra}");
2793 Ok(MigrationResult {
2794 output,
2795 changed_count: 1,
2796 sections_changed: vec!["memory.hebbian".to_owned()],
2797 })
2798}
2799
2800pub fn migrate_memory_hebbian_spread_config(
2812 toml_src: &str,
2813) -> Result<MigrationResult, MigrateError> {
2814 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2815
2816 if !has_section {
2817 return Ok(MigrationResult {
2818 output: toml_src.to_owned(),
2819 changed_count: 0,
2820 sections_changed: Vec::new(),
2821 });
2822 }
2823
2824 let has_spreading = toml_src
2826 .lines()
2827 .any(|l| l.trim().starts_with("spreading_activation"));
2828 let has_depth = toml_src
2829 .lines()
2830 .any(|l| l.trim().starts_with("spread_depth"));
2831 let has_budget = toml_src
2832 .lines()
2833 .any(|l| l.trim().starts_with("step_budget_ms"));
2834
2835 if has_spreading && has_depth && has_budget {
2836 return Ok(MigrationResult {
2837 output: toml_src.to_owned(),
2838 changed_count: 0,
2839 sections_changed: Vec::new(),
2840 });
2841 }
2842
2843 let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2844 # spreading_activation = false # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2845 # spread_depth = 2 # BFS hops, clamped [1,6]\n\
2846 # spread_edge_types = [] # MAGMA edge types to traverse; empty = all\n\
2847 # step_budget_ms = 8 # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n";
2848
2849 let output = format!("{toml_src}{extra}");
2850 Ok(MigrationResult {
2851 output,
2852 changed_count: 1,
2853 sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2854 })
2855}
2856
2857pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2871 if toml_src
2872 .lines()
2873 .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2874 {
2875 return Ok(MigrationResult {
2876 output: toml_src.to_owned(),
2877 changed_count: 0,
2878 sections_changed: Vec::new(),
2879 });
2880 }
2881
2882 let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2883 # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2884 # ZEPH_TURN_LLM_REQUESTS.\n\
2885 # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2886 # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2887 # [[hooks.turn_complete]]\n\
2888 # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2889 # timeout_secs = 3\n\
2890 # fail_closed = false\n";
2891 let output = format!("{toml_src}{comment}");
2892
2893 Ok(MigrationResult {
2894 output,
2895 changed_count: 1,
2896 sections_changed: vec!["hooks.turn_complete".to_owned()],
2897 })
2898}
2899
2900pub fn migrate_focus_auto_consolidate_min_window(
2917 toml_src: &str,
2918) -> Result<MigrationResult, MigrateError> {
2919 if toml_src.contains("auto_consolidate_min_window") {
2920 return Ok(MigrationResult {
2921 output: toml_src.to_owned(),
2922 changed_count: 0,
2923 sections_changed: Vec::new(),
2924 });
2925 }
2926
2927 if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
2929 return Ok(MigrationResult {
2930 output: toml_src.to_owned(),
2931 changed_count: 0,
2932 sections_changed: Vec::new(),
2933 });
2934 }
2935
2936 let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
2937 runs (#3313).\n\
2938 # auto_consolidate_min_window = 6\n";
2939 let output = insert_after_section(toml_src, "agent.focus", comment);
2940
2941 Ok(MigrationResult {
2942 output,
2943 changed_count: 1,
2944 sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
2945 })
2946}
2947
2948pub fn migrate_session_provider_persistence(
2958 toml_src: &str,
2959) -> Result<MigrationResult, MigrateError> {
2960 if toml_src
2961 .lines()
2962 .any(|l| l.trim() == "[session]" || l.trim() == "# [session]")
2963 {
2964 return Ok(MigrationResult {
2965 output: toml_src.to_owned(),
2966 changed_count: 0,
2967 sections_changed: Vec::new(),
2968 });
2969 }
2970
2971 let comment = "\n# [session] — session-scoped user experience settings (#3308).\n\
2972 [session]\n\
2973 # Persist the last-used provider per channel across restarts.\n\
2974 # When true, the agent saves the active provider name to SQLite after each\n\
2975 # /provider switch and restores it on the next session start for the same channel.\n\
2976 provider_persistence = true\n";
2977 let output = format!("{toml_src}{comment}");
2978
2979 Ok(MigrationResult {
2980 output,
2981 changed_count: 1,
2982 sections_changed: vec!["session".to_owned()],
2983 })
2984}
2985
2986pub fn migrate_memory_retrieval_query_bias(
2998 toml_src: &str,
2999) -> Result<MigrationResult, MigrateError> {
3000 if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
3003 return Ok(MigrationResult {
3004 output: toml_src.to_owned(),
3005 changed_count: 0,
3006 sections_changed: Vec::new(),
3007 });
3008 }
3009
3010 if toml_src
3012 .lines()
3013 .any(|l| l.trim().starts_with("query_bias_correction"))
3014 {
3015 return Ok(MigrationResult {
3016 output: toml_src.to_owned(),
3017 changed_count: 0,
3018 sections_changed: Vec::new(),
3019 });
3020 }
3021
3022 let comment = "\n# MM-F3 (#3341): shift first-person queries toward the user profile centroid.\n\
3023 # No-op when the persona table is empty.\n\
3024 # query_bias_correction = true\n";
3025 let output = insert_after_section(toml_src, "memory.retrieval", comment);
3026
3027 Ok(MigrationResult {
3028 output,
3029 changed_count: 1,
3030 sections_changed: vec!["memory.retrieval.query_bias_correction".to_owned()],
3031 })
3032}
3033
3034pub fn migrate_memory_persona_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3043 if toml_src
3044 .lines()
3045 .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]")
3046 {
3047 return Ok(MigrationResult {
3048 output: toml_src.to_owned(),
3049 changed_count: 0,
3050 sections_changed: Vec::new(),
3051 });
3052 }
3053
3054 let comment = "\n# [memory.persona] — user persona profile for query-bias correction (#3341).\n\
3055 # Verified working in CI-604/CI-605. No-op when disabled.\n\
3056 # [memory.persona]\n\
3057 # enabled = true\n\
3058 # min_messages = 2 # minimum user messages before persona extraction fires\n\
3059 # min_confidence = 0.5 # minimum extraction confidence threshold (0.0–1.0)\n";
3060 let output = format!("{toml_src}{comment}");
3061
3062 Ok(MigrationResult {
3063 output,
3064 changed_count: 1,
3065 sections_changed: vec!["memory.persona".to_owned()],
3066 })
3067}
3068
3069pub fn migrate_qdrant_api_key(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3078 if toml_src.contains("qdrant_api_key") {
3079 return Ok(MigrationResult {
3080 output: toml_src.to_owned(),
3081 changed_count: 0,
3082 sections_changed: Vec::new(),
3083 });
3084 }
3085
3086 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3087
3088 if !doc.contains_key("memory") {
3089 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
3090 }
3091
3092 let comment = "\n# Qdrant API key (optional; required when connecting to remote/managed Qdrant clusters).\n\
3093 # Leave empty for local Qdrant instances. Store the actual key in the vault:\n\
3094 # zeph vault set ZEPH_QDRANT_API_KEY \"<key>\"\n\
3095 # qdrant_api_key = \"\"\n";
3096 let raw = doc.to_string();
3097 let output = format!("{raw}{comment}");
3098
3099 Ok(MigrationResult {
3100 output,
3101 changed_count: 1,
3102 sections_changed: vec!["memory.qdrant_api_key".to_owned()],
3103 })
3104}
3105
3106pub trait Migration: Send + Sync {
3133 fn name(&self) -> &'static str;
3135
3136 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
3142}
3143
3144mod steps;
3145use steps::{
3146 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
3147 MigrateAutodreamConfig, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
3148 MigrateEgressConfig, MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig,
3149 MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateMagicDocsConfig,
3150 MigrateMcpElicitationConfig, MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryHebbian,
3151 MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig,
3152 MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval,
3153 MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, MigrateOrchestrationPersistence,
3154 MigrateOtelFilter, MigratePlannerModelToProvider, MigrateQdrantApiKey, MigrateQualityConfig,
3155 MigrateSandboxConfig, MigrateSandboxEgressFilter, MigrateSchedulerDaemon,
3156 MigrateSessionProviderPersistence, MigrateSessionRecapConfig, MigrateShellTransactional,
3157 MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateVigilConfig,
3158};
3159
3160pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3177 std::sync::LazyLock::new(|| {
3178 vec![
3179 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3181 Box::new(MigratePlannerModelToProvider),
3182 Box::new(MigrateMcpTrustLevels),
3183 Box::new(MigrateAgentRetryToToolsRetry),
3184 Box::new(MigrateDatabaseUrl),
3185 Box::new(MigrateShellTransactional),
3186 Box::new(MigrateAgentBudgetHint),
3187 Box::new(MigrateForgettingConfig),
3188 Box::new(MigrateCompressionPredictorConfig),
3189 Box::new(MigrateMicrocompactConfig),
3190 Box::new(MigrateAutodreamConfig),
3191 Box::new(MigrateMagicDocsConfig),
3192 Box::new(MigrateTelemetryConfig),
3193 Box::new(MigrateSupervisorConfig),
3194 Box::new(MigrateOtelFilter),
3195 Box::new(MigrateEgressConfig),
3196 Box::new(MigrateVigilConfig),
3197 Box::new(MigrateSandboxConfig),
3198 Box::new(MigrateSandboxEgressFilter),
3199 Box::new(MigrateOrchestrationPersistence),
3200 Box::new(MigrateSessionRecapConfig),
3201 Box::new(MigrateMcpElicitationConfig),
3202 Box::new(MigrateQualityConfig),
3203 Box::new(MigrateAcpSubagentsConfig),
3204 Box::new(MigrateHooksPermissionDeniedConfig),
3205 Box::new(MigrateMemoryGraph),
3207 Box::new(MigrateSchedulerDaemon),
3208 Box::new(MigrateMemoryRetrieval),
3209 Box::new(MigrateMemoryReasoning),
3210 Box::new(MigrateMemoryReasoningJudge),
3211 Box::new(MigrateMemoryHebbian),
3212 Box::new(MigrateMemoryHebbianConsolidation),
3213 Box::new(MigrateMemoryHebbianSpread),
3214 Box::new(MigrateHooksTurnComplete),
3215 Box::new(MigrateFocusAutoConsolidateMinWindow),
3216 Box::new(MigrateSessionProviderPersistence),
3218 Box::new(MigrateMemoryRetrievalQueryBias),
3219 Box::new(MigrateMemoryPersonaConfig),
3220 Box::new(MigrateQdrantApiKey),
3222 ]
3223 });
3224
3225#[cfg(test)]
3227fn make_formatted_str(s: &str) -> Value {
3228 use toml_edit::Formatted;
3229 Value::String(Formatted::new(s.to_owned()))
3230}
3231
3232#[cfg(test)]
3233mod tests {
3234 use super::*;
3235
3236 #[test]
3237 fn migrations_registry_has_all_steps() {
3238 assert_eq!(
3239 MIGRATIONS.len(),
3240 39,
3241 "MIGRATIONS registry must contain all 39 sequential steps"
3242 );
3243 for m in MIGRATIONS.iter() {
3244 assert!(
3245 !m.name().is_empty(),
3246 "each migration must have a non-empty name"
3247 );
3248 }
3249 }
3250
3251 #[test]
3252 fn migrations_registry_applies_to_empty_config() {
3253 let mut toml = String::new();
3254 for m in MIGRATIONS.iter() {
3255 toml = m
3256 .apply(&toml)
3257 .expect("migration must not fail on empty config")
3258 .output;
3259 }
3260 toml.parse::<toml_edit::DocumentMut>()
3262 .expect("registry output must be valid TOML");
3263 }
3264
3265 #[test]
3266 fn empty_config_gets_sections_as_comments() {
3267 let migrator = ConfigMigrator::new();
3268 let result = migrator.migrate("").expect("migrate empty");
3269 assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
3271 assert!(
3273 result.output.contains("[agent]") || result.output.contains("# [agent]"),
3274 "expected agent section in output, got:\n{}",
3275 result.output
3276 );
3277 }
3278
3279 #[test]
3280 fn existing_values_not_overwritten() {
3281 let user = r#"
3282[agent]
3283name = "MyAgent"
3284max_tool_iterations = 5
3285"#;
3286 let migrator = ConfigMigrator::new();
3287 let result = migrator.migrate(user).expect("migrate");
3288 assert!(
3290 result.output.contains("name = \"MyAgent\""),
3291 "user value should be preserved"
3292 );
3293 assert!(
3294 result.output.contains("max_tool_iterations = 5"),
3295 "user value should be preserved"
3296 );
3297 assert!(
3299 !result.output.contains("# max_tool_iterations = 10"),
3300 "already-set key should not appear as comment"
3301 );
3302 }
3303
3304 #[test]
3305 fn missing_nested_key_added_as_comment() {
3306 let user = r#"
3308[memory]
3309sqlite_path = ".zeph/data/zeph.db"
3310"#;
3311 let migrator = ConfigMigrator::new();
3312 let result = migrator.migrate(user).expect("migrate");
3313 assert!(
3315 result.output.contains("# history_limit"),
3316 "missing key should be added as comment, got:\n{}",
3317 result.output
3318 );
3319 }
3320
3321 #[test]
3322 fn unknown_user_keys_preserved() {
3323 let user = r#"
3324[agent]
3325name = "Test"
3326my_custom_key = "preserved"
3327"#;
3328 let migrator = ConfigMigrator::new();
3329 let result = migrator.migrate(user).expect("migrate");
3330 assert!(
3331 result.output.contains("my_custom_key = \"preserved\""),
3332 "custom user keys must not be removed"
3333 );
3334 }
3335
3336 #[test]
3337 fn idempotent() {
3338 let migrator = ConfigMigrator::new();
3339 let first = migrator
3340 .migrate("[agent]\nname = \"Zeph\"\n")
3341 .expect("first migrate");
3342 let second = migrator.migrate(&first.output).expect("second migrate");
3343 assert_eq!(
3344 first.output, second.output,
3345 "idempotent: full output must be identical on second run"
3346 );
3347 }
3348
3349 #[test]
3350 fn malformed_input_returns_error() {
3351 let migrator = ConfigMigrator::new();
3352 let err = migrator
3353 .migrate("[[invalid toml [[[")
3354 .expect_err("should error");
3355 assert!(
3356 matches!(err, MigrateError::Parse(_)),
3357 "expected Parse error"
3358 );
3359 }
3360
3361 #[test]
3362 fn array_of_tables_preserved() {
3363 let user = r#"
3364[mcp]
3365allowed_commands = ["npx"]
3366
3367[[mcp.servers]]
3368id = "my-server"
3369command = "npx"
3370args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3371"#;
3372 let migrator = ConfigMigrator::new();
3373 let result = migrator.migrate(user).expect("migrate");
3374 assert!(
3376 result.output.contains("[[mcp.servers]]"),
3377 "array-of-tables entries must be preserved"
3378 );
3379 assert!(result.output.contains("id = \"my-server\""));
3380 }
3381
3382 #[test]
3383 fn canonical_ordering_applied() {
3384 let user = r#"
3386[memory]
3387sqlite_path = ".zeph/data/zeph.db"
3388
3389[agent]
3390name = "Test"
3391"#;
3392 let migrator = ConfigMigrator::new();
3393 let result = migrator.migrate(user).expect("migrate");
3394 let agent_pos = result.output.find("[agent]");
3396 let memory_pos = result.output.find("[memory]");
3397 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3398 assert!(a < m, "agent section should precede memory section");
3399 }
3400 }
3401
3402 #[test]
3403 fn value_to_toml_string_formats_correctly() {
3404 use toml_edit::Formatted;
3405
3406 let s = make_formatted_str("hello");
3407 assert_eq!(value_to_toml_string(&s), "\"hello\"");
3408
3409 let i = Value::Integer(Formatted::new(42_i64));
3410 assert_eq!(value_to_toml_string(&i), "42");
3411
3412 let b = Value::Boolean(Formatted::new(true));
3413 assert_eq!(value_to_toml_string(&b), "true");
3414
3415 let f = Value::Float(Formatted::new(1.0_f64));
3416 assert_eq!(value_to_toml_string(&f), "1.0");
3417
3418 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3419 assert_eq!(value_to_toml_string(&f2), "3.14");
3420
3421 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3422 let arr_val = Value::Array(arr);
3423 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3424
3425 let empty_arr = Value::Array(Array::new());
3426 assert_eq!(value_to_toml_string(&empty_arr), "[]");
3427 }
3428
3429 #[test]
3430 fn idempotent_full_output_unchanged() {
3431 let migrator = ConfigMigrator::new();
3433 let first = migrator
3434 .migrate("[agent]\nname = \"Zeph\"\n")
3435 .expect("first migrate");
3436 let second = migrator.migrate(&first.output).expect("second migrate");
3437 assert_eq!(
3438 first.output, second.output,
3439 "full output string must be identical after second migration pass"
3440 );
3441 }
3442
3443 #[test]
3444 fn full_config_produces_zero_additions() {
3445 let reference = include_str!("../../config/default.toml");
3447 let migrator = ConfigMigrator::new();
3448 let result = migrator.migrate(reference).expect("migrate reference");
3449 assert_eq!(
3450 result.changed_count, 0,
3451 "migrating the canonical reference should add nothing (changed_count = {})",
3452 result.changed_count
3453 );
3454 assert!(
3455 result.sections_changed.is_empty(),
3456 "migrating the canonical reference should report no sections_changed: {:?}",
3457 result.sections_changed
3458 );
3459 }
3460
3461 #[test]
3462 fn empty_config_changed_count_is_positive() {
3463 let migrator = ConfigMigrator::new();
3465 let result = migrator.migrate("").expect("migrate empty");
3466 assert!(
3467 result.changed_count > 0,
3468 "empty config must report changed_count > 0"
3469 );
3470 }
3471
3472 #[test]
3475 fn security_without_guardrail_gets_guardrail_commented() {
3476 let user = "[security]\nredact_secrets = true\n";
3477 let migrator = ConfigMigrator::new();
3478 let result = migrator.migrate(user).expect("migrate");
3479 assert!(
3481 result.output.contains("guardrail"),
3482 "migration must add guardrail keys for configs without [security.guardrail]: \
3483 got:\n{}",
3484 result.output
3485 );
3486 }
3487
3488 #[test]
3489 fn migrate_reference_contains_tools_policy() {
3490 let reference = include_str!("../../config/default.toml");
3495 assert!(
3496 reference.contains("[tools.policy]"),
3497 "default.toml must contain [tools.policy] section so migrate-config can surface it"
3498 );
3499 assert!(
3500 reference.contains("enabled = false"),
3501 "tools.policy section must include enabled = false default"
3502 );
3503 }
3504
3505 #[test]
3506 fn migrate_reference_contains_probe_section() {
3507 let reference = include_str!("../../config/default.toml");
3510 assert!(
3511 reference.contains("[memory.compression.probe]"),
3512 "default.toml must contain [memory.compression.probe] section comment"
3513 );
3514 assert!(
3515 reference.contains("hard_fail_threshold"),
3516 "probe section must include hard_fail_threshold default"
3517 );
3518 }
3519
3520 #[test]
3523 fn migrate_llm_no_llm_section_is_noop() {
3524 let src = "[agent]\nname = \"Zeph\"\n";
3525 let result = migrate_llm_to_providers(src).expect("migrate");
3526 assert_eq!(result.changed_count, 0);
3527 assert_eq!(result.output, src);
3528 }
3529
3530 #[test]
3531 fn migrate_llm_already_new_format_is_noop() {
3532 let src = r#"
3533[llm]
3534[[llm.providers]]
3535type = "ollama"
3536model = "qwen3:8b"
3537"#;
3538 let result = migrate_llm_to_providers(src).expect("migrate");
3539 assert_eq!(result.changed_count, 0);
3540 }
3541
3542 #[test]
3543 fn migrate_llm_ollama_produces_providers_block() {
3544 let src = r#"
3545[llm]
3546provider = "ollama"
3547model = "qwen3:8b"
3548base_url = "http://localhost:11434"
3549embedding_model = "nomic-embed-text"
3550"#;
3551 let result = migrate_llm_to_providers(src).expect("migrate");
3552 assert!(
3553 result.output.contains("[[llm.providers]]"),
3554 "should contain [[llm.providers]]:\n{}",
3555 result.output
3556 );
3557 assert!(
3558 result.output.contains("type = \"ollama\""),
3559 "{}",
3560 result.output
3561 );
3562 assert!(
3563 result.output.contains("model = \"qwen3:8b\""),
3564 "{}",
3565 result.output
3566 );
3567 }
3568
3569 #[test]
3570 fn migrate_llm_claude_produces_providers_block() {
3571 let src = r#"
3572[llm]
3573provider = "claude"
3574
3575[llm.cloud]
3576model = "claude-sonnet-4-6"
3577max_tokens = 8192
3578server_compaction = true
3579"#;
3580 let result = migrate_llm_to_providers(src).expect("migrate");
3581 assert!(
3582 result.output.contains("[[llm.providers]]"),
3583 "{}",
3584 result.output
3585 );
3586 assert!(
3587 result.output.contains("type = \"claude\""),
3588 "{}",
3589 result.output
3590 );
3591 assert!(
3592 result.output.contains("model = \"claude-sonnet-4-6\""),
3593 "{}",
3594 result.output
3595 );
3596 assert!(
3597 result.output.contains("server_compaction = true"),
3598 "{}",
3599 result.output
3600 );
3601 }
3602
3603 #[test]
3604 fn migrate_llm_openai_copies_fields() {
3605 let src = r#"
3606[llm]
3607provider = "openai"
3608
3609[llm.openai]
3610base_url = "https://api.openai.com/v1"
3611model = "gpt-4o"
3612max_tokens = 4096
3613"#;
3614 let result = migrate_llm_to_providers(src).expect("migrate");
3615 assert!(
3616 result.output.contains("type = \"openai\""),
3617 "{}",
3618 result.output
3619 );
3620 assert!(
3621 result
3622 .output
3623 .contains("base_url = \"https://api.openai.com/v1\""),
3624 "{}",
3625 result.output
3626 );
3627 }
3628
3629 #[test]
3630 fn migrate_llm_gemini_copies_fields() {
3631 let src = r#"
3632[llm]
3633provider = "gemini"
3634
3635[llm.gemini]
3636model = "gemini-2.0-flash"
3637max_tokens = 8192
3638base_url = "https://generativelanguage.googleapis.com"
3639"#;
3640 let result = migrate_llm_to_providers(src).expect("migrate");
3641 assert!(
3642 result.output.contains("type = \"gemini\""),
3643 "{}",
3644 result.output
3645 );
3646 assert!(
3647 result.output.contains("model = \"gemini-2.0-flash\""),
3648 "{}",
3649 result.output
3650 );
3651 }
3652
3653 #[test]
3654 fn migrate_llm_compatible_copies_multiple_entries() {
3655 let src = r#"
3656[llm]
3657provider = "compatible"
3658
3659[[llm.compatible]]
3660name = "proxy-a"
3661base_url = "http://proxy-a:8080/v1"
3662model = "llama3"
3663max_tokens = 4096
3664
3665[[llm.compatible]]
3666name = "proxy-b"
3667base_url = "http://proxy-b:8080/v1"
3668model = "mistral"
3669max_tokens = 2048
3670"#;
3671 let result = migrate_llm_to_providers(src).expect("migrate");
3672 let count = result.output.matches("[[llm.providers]]").count();
3674 assert_eq!(
3675 count, 2,
3676 "expected 2 [[llm.providers]] blocks:\n{}",
3677 result.output
3678 );
3679 assert!(
3680 result.output.contains("name = \"proxy-a\""),
3681 "{}",
3682 result.output
3683 );
3684 assert!(
3685 result.output.contains("name = \"proxy-b\""),
3686 "{}",
3687 result.output
3688 );
3689 }
3690
3691 #[test]
3692 fn migrate_llm_mixed_format_errors() {
3693 let src = r#"
3695[llm]
3696provider = "ollama"
3697
3698[[llm.providers]]
3699type = "ollama"
3700"#;
3701 assert!(
3702 migrate_llm_to_providers(src).is_err(),
3703 "mixed format must return error"
3704 );
3705 }
3706
3707 #[test]
3710 fn stt_migration_no_stt_section_returns_unchanged() {
3711 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
3712 let result = migrate_stt_to_provider(src).unwrap();
3713 assert_eq!(result.changed_count, 0);
3714 assert_eq!(result.output, src);
3715 }
3716
3717 #[test]
3718 fn stt_migration_no_model_or_base_url_returns_unchanged() {
3719 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
3720 let result = migrate_stt_to_provider(src).unwrap();
3721 assert_eq!(result.changed_count, 0);
3722 }
3723
3724 #[test]
3725 fn stt_migration_moves_model_to_provider_entry() {
3726 let src = r#"
3727[llm]
3728
3729[[llm.providers]]
3730type = "openai"
3731name = "quality"
3732model = "gpt-5.4"
3733
3734[llm.stt]
3735provider = "quality"
3736model = "gpt-4o-mini-transcribe"
3737language = "en"
3738"#;
3739 let result = migrate_stt_to_provider(src).unwrap();
3740 assert_eq!(result.changed_count, 1);
3741 assert!(
3743 result.output.contains("stt_model"),
3744 "stt_model must be in output"
3745 );
3746 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3749 let stt = doc
3750 .get("llm")
3751 .and_then(toml_edit::Item::as_table)
3752 .and_then(|l| l.get("stt"))
3753 .and_then(toml_edit::Item::as_table)
3754 .unwrap();
3755 assert!(
3756 stt.get("model").is_none(),
3757 "model must be removed from [llm.stt]"
3758 );
3759 assert_eq!(
3760 stt.get("provider").and_then(toml_edit::Item::as_str),
3761 Some("quality")
3762 );
3763 }
3764
3765 #[test]
3766 fn stt_migration_creates_new_provider_when_no_match() {
3767 let src = r#"
3768[llm]
3769
3770[[llm.providers]]
3771type = "ollama"
3772name = "local"
3773model = "qwen3:8b"
3774
3775[llm.stt]
3776provider = "whisper"
3777model = "whisper-1"
3778base_url = "https://api.openai.com/v1"
3779language = "en"
3780"#;
3781 let result = migrate_stt_to_provider(src).unwrap();
3782 assert!(
3783 result.output.contains("openai-stt"),
3784 "new entry name must be openai-stt"
3785 );
3786 assert!(
3787 result.output.contains("stt_model"),
3788 "stt_model must be in output"
3789 );
3790 }
3791
3792 #[test]
3793 fn stt_migration_candle_whisper_creates_candle_entry() {
3794 let src = r#"
3795[llm]
3796
3797[llm.stt]
3798provider = "candle-whisper"
3799model = "openai/whisper-tiny"
3800language = "auto"
3801"#;
3802 let result = migrate_stt_to_provider(src).unwrap();
3803 assert!(
3804 result.output.contains("local-whisper"),
3805 "candle entry name must be local-whisper"
3806 );
3807 assert!(result.output.contains("candle"), "type must be candle");
3808 }
3809
3810 #[test]
3811 fn stt_migration_w2_assigns_explicit_name() {
3812 let src = r#"
3814[llm]
3815
3816[[llm.providers]]
3817type = "openai"
3818model = "gpt-5.4"
3819
3820[llm.stt]
3821provider = "openai"
3822model = "whisper-1"
3823language = "auto"
3824"#;
3825 let result = migrate_stt_to_provider(src).unwrap();
3826 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3827 let providers = doc
3828 .get("llm")
3829 .and_then(toml_edit::Item::as_table)
3830 .and_then(|l| l.get("providers"))
3831 .and_then(toml_edit::Item::as_array_of_tables)
3832 .unwrap();
3833 let entry = providers
3834 .iter()
3835 .find(|t| t.get("stt_model").is_some())
3836 .unwrap();
3837 assert!(
3839 entry.get("name").is_some(),
3840 "migrated entry must have explicit name"
3841 );
3842 }
3843
3844 #[test]
3845 fn stt_migration_removes_base_url_from_stt_table() {
3846 let src = r#"
3848[llm]
3849
3850[[llm.providers]]
3851type = "openai"
3852name = "quality"
3853model = "gpt-5.4"
3854
3855[llm.stt]
3856provider = "quality"
3857model = "whisper-1"
3858base_url = "https://api.openai.com/v1"
3859language = "en"
3860"#;
3861 let result = migrate_stt_to_provider(src).unwrap();
3862 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3863 let stt = doc
3864 .get("llm")
3865 .and_then(toml_edit::Item::as_table)
3866 .and_then(|l| l.get("stt"))
3867 .and_then(toml_edit::Item::as_table)
3868 .unwrap();
3869 assert!(
3870 stt.get("model").is_none(),
3871 "model must be removed from [llm.stt]"
3872 );
3873 assert!(
3874 stt.get("base_url").is_none(),
3875 "base_url must be removed from [llm.stt]"
3876 );
3877 }
3878
3879 #[test]
3880 fn migrate_planner_model_to_provider_with_field() {
3881 let input = r#"
3882[orchestration]
3883enabled = true
3884planner_model = "gpt-4o"
3885max_tasks = 20
3886"#;
3887 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
3888 assert_eq!(result.changed_count, 1, "changed_count must be 1");
3889 assert!(
3890 !result.output.contains("planner_model = "),
3891 "planner_model key must be removed from output"
3892 );
3893 assert!(
3894 result.output.contains("# planner_provider"),
3895 "commented-out planner_provider entry must be present"
3896 );
3897 assert!(
3898 result.output.contains("gpt-4o"),
3899 "old value must appear in the comment"
3900 );
3901 assert!(
3902 result.output.contains("MIGRATED"),
3903 "comment must include MIGRATED marker"
3904 );
3905 }
3906
3907 #[test]
3908 fn migrate_planner_model_to_provider_no_op() {
3909 let input = r"
3910[orchestration]
3911enabled = true
3912max_tasks = 20
3913";
3914 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
3915 assert_eq!(
3916 result.changed_count, 0,
3917 "changed_count must be 0 when field is absent"
3918 );
3919 assert_eq!(
3920 result.output, input,
3921 "output must equal input when nothing to migrate"
3922 );
3923 }
3924
3925 #[test]
3926 fn migrate_error_invalid_structure_formats_correctly() {
3927 let err = MigrateError::InvalidStructure("test sentinel");
3932 assert!(
3933 matches!(err, MigrateError::InvalidStructure(_)),
3934 "variant must match"
3935 );
3936 let msg = err.to_string();
3937 assert!(
3938 msg.contains("invalid TOML structure"),
3939 "error message must mention 'invalid TOML structure', got: {msg}"
3940 );
3941 assert!(
3942 msg.contains("test sentinel"),
3943 "message must include reason: {msg}"
3944 );
3945 }
3946
3947 #[test]
3950 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
3951 let src = r#"
3952[mcp]
3953allowed_commands = ["npx"]
3954
3955[[mcp.servers]]
3956id = "srv-a"
3957command = "npx"
3958args = ["-y", "some-mcp"]
3959
3960[[mcp.servers]]
3961id = "srv-b"
3962command = "npx"
3963args = ["-y", "other-mcp"]
3964"#;
3965 let result = migrate_mcp_trust_levels(src).expect("migrate");
3966 assert_eq!(
3967 result.changed_count, 2,
3968 "both entries must get trust_level added"
3969 );
3970 assert!(
3971 result
3972 .sections_changed
3973 .contains(&"mcp.servers.trust_level".to_owned()),
3974 "sections_changed must report mcp.servers.trust_level"
3975 );
3976 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
3978 assert_eq!(
3979 occurrences, 2,
3980 "each entry must have trust_level = \"trusted\""
3981 );
3982 }
3983
3984 #[test]
3985 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
3986 let src = r#"
3987[[mcp.servers]]
3988id = "srv-a"
3989command = "npx"
3990trust_level = "sandboxed"
3991tool_allowlist = ["read_file"]
3992
3993[[mcp.servers]]
3994id = "srv-b"
3995command = "npx"
3996"#;
3997 let result = migrate_mcp_trust_levels(src).expect("migrate");
3998 assert_eq!(
4000 result.changed_count, 1,
4001 "only entry without trust_level gets updated"
4002 );
4003 assert!(
4005 result.output.contains("trust_level = \"sandboxed\""),
4006 "existing trust_level must not be overwritten"
4007 );
4008 assert!(
4010 result.output.contains("trust_level = \"trusted\""),
4011 "entry without trust_level must get trusted"
4012 );
4013 }
4014
4015 #[test]
4016 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4017 let src = "[agent]\nname = \"Zeph\"\n";
4018 let result = migrate_mcp_trust_levels(src).expect("migrate");
4019 assert_eq!(result.changed_count, 0);
4020 assert!(result.sections_changed.is_empty());
4021 assert_eq!(result.output, src);
4022 }
4023
4024 #[test]
4025 fn migrate_mcp_trust_levels_no_servers_is_noop() {
4026 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4027 let result = migrate_mcp_trust_levels(src).expect("migrate");
4028 assert_eq!(result.changed_count, 0);
4029 assert!(result.sections_changed.is_empty());
4030 assert_eq!(result.output, src);
4031 }
4032
4033 #[test]
4034 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4035 let src = r#"
4036[[mcp.servers]]
4037id = "srv-a"
4038trust_level = "trusted"
4039
4040[[mcp.servers]]
4041id = "srv-b"
4042trust_level = "untrusted"
4043"#;
4044 let result = migrate_mcp_trust_levels(src).expect("migrate");
4045 assert_eq!(result.changed_count, 0);
4046 assert!(result.sections_changed.is_empty());
4047 }
4048
4049 #[test]
4050 fn migrate_database_url_adds_comment_when_absent() {
4051 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4052 let result = migrate_database_url(src).expect("migrate");
4053 assert_eq!(result.changed_count, 1);
4054 assert!(
4055 result
4056 .sections_changed
4057 .contains(&"memory.database_url".to_owned())
4058 );
4059 assert!(result.output.contains("# database_url = \"\""));
4060 }
4061
4062 #[test]
4063 fn migrate_database_url_is_noop_when_present() {
4064 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4065 let result = migrate_database_url(src).expect("migrate");
4066 assert_eq!(result.changed_count, 0);
4067 assert!(result.sections_changed.is_empty());
4068 assert_eq!(result.output, src);
4069 }
4070
4071 #[test]
4072 fn migrate_database_url_creates_memory_section_when_absent() {
4073 let src = "[agent]\nname = \"Zeph\"\n";
4074 let result = migrate_database_url(src).expect("migrate");
4075 assert_eq!(result.changed_count, 1);
4076 assert!(result.output.contains("# database_url = \"\""));
4077 }
4078
4079 #[test]
4082 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4083 let src = "[agent]\nname = \"Zeph\"\n";
4084 let result = migrate_agent_budget_hint(src).expect("migrate");
4085 assert_eq!(result.changed_count, 1);
4086 assert!(result.output.contains("budget_hint_enabled"));
4087 assert!(
4088 result
4089 .sections_changed
4090 .contains(&"agent.budget_hint_enabled".to_owned())
4091 );
4092 }
4093
4094 #[test]
4095 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4096 let src = "[llm]\nmodel = \"gpt-4o\"\n";
4097 let result = migrate_agent_budget_hint(src).expect("migrate");
4098 assert_eq!(result.changed_count, 0);
4099 assert_eq!(result.output, src);
4100 }
4101
4102 #[test]
4103 fn migrate_agent_budget_hint_already_present_is_noop() {
4104 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4105 let result = migrate_agent_budget_hint(src).expect("migrate");
4106 assert_eq!(result.changed_count, 0);
4107 assert_eq!(result.output, src);
4108 }
4109
4110 #[test]
4111 fn migrate_telemetry_config_empty_config_appends_comment_block() {
4112 let src = "[agent]\nname = \"Zeph\"\n";
4113 let result = migrate_telemetry_config(src).expect("migrate");
4114 assert_eq!(result.changed_count, 1);
4115 assert_eq!(result.sections_changed, vec!["telemetry"]);
4116 assert!(
4117 result.output.contains("# [telemetry]"),
4118 "expected commented-out [telemetry] block in output"
4119 );
4120 assert!(
4121 result.output.contains("enabled = false"),
4122 "expected enabled = false in telemetry comment block"
4123 );
4124 }
4125
4126 #[test]
4127 fn migrate_telemetry_config_existing_section_is_noop() {
4128 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4129 let result = migrate_telemetry_config(src).expect("migrate");
4130 assert_eq!(result.changed_count, 0);
4131 assert_eq!(result.output, src);
4132 }
4133
4134 #[test]
4135 fn migrate_telemetry_config_existing_comment_is_noop() {
4136 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4138 let result = migrate_telemetry_config(src).expect("migrate");
4139 assert_eq!(result.changed_count, 0);
4140 assert_eq!(result.output, src);
4141 }
4142
4143 #[test]
4146 fn migrate_otel_filter_already_present_is_noop() {
4147 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4149 let result = migrate_otel_filter(src).expect("migrate");
4150 assert_eq!(result.changed_count, 0);
4151 assert_eq!(result.output, src);
4152 }
4153
4154 #[test]
4155 fn migrate_otel_filter_commented_key_is_noop() {
4156 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4158 let result = migrate_otel_filter(src).expect("migrate");
4159 assert_eq!(result.changed_count, 0);
4160 assert_eq!(result.output, src);
4161 }
4162
4163 #[test]
4164 fn migrate_otel_filter_no_telemetry_section_is_noop() {
4165 let src = "[agent]\nname = \"Zeph\"\n";
4167 let result = migrate_otel_filter(src).expect("migrate");
4168 assert_eq!(result.changed_count, 0);
4169 assert_eq!(result.output, src);
4170 assert!(!result.output.contains("otel_filter"));
4171 }
4172
4173 #[test]
4174 fn migrate_otel_filter_injects_within_telemetry_section() {
4175 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4176 let result = migrate_otel_filter(src).expect("migrate");
4177 assert_eq!(result.changed_count, 1);
4178 assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4179 assert!(
4180 result.output.contains("otel_filter"),
4181 "otel_filter comment must appear"
4182 );
4183 let otel_pos = result
4185 .output
4186 .find("otel_filter")
4187 .expect("otel_filter present");
4188 let agent_pos = result.output.find("[agent]").expect("[agent] present");
4189 assert!(
4190 otel_pos < agent_pos,
4191 "otel_filter comment should appear before [agent] section"
4192 );
4193 }
4194
4195 #[test]
4196 fn sandbox_migration_adds_commented_section_when_absent() {
4197 let src = "[agent]\nname = \"Z\"\n";
4198 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4199 assert_eq!(result.changed_count, 1);
4200 assert!(result.output.contains("# [tools.sandbox]"));
4201 assert!(result.output.contains("# profile = \"workspace\""));
4202 }
4203
4204 #[test]
4205 fn sandbox_migration_noop_when_section_present() {
4206 let src = "[tools.sandbox]\nenabled = true\n";
4207 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4208 assert_eq!(result.changed_count, 0);
4209 }
4210
4211 #[test]
4212 fn sandbox_migration_noop_when_dotted_key_present() {
4213 let src = "[tools]\nsandbox = { enabled = true }\n";
4214 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4215 assert_eq!(result.changed_count, 0);
4216 }
4217
4218 #[test]
4219 fn sandbox_migration_false_positive_comment_does_not_block() {
4220 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
4222 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4223 assert_eq!(result.changed_count, 1);
4224 }
4225
4226 #[test]
4227 fn embedded_default_mentions_tools_sandbox() {
4228 let default_src = include_str!("../../config/default.toml");
4229 assert!(
4230 default_src.contains("tools.sandbox"),
4231 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
4232 );
4233 }
4234
4235 #[test]
4236 fn sandbox_migration_idempotent_on_own_output() {
4237 let base = "[agent]\nmodel = \"test\"\n";
4238 let first = migrate_sandbox_config(base).unwrap();
4239 assert_eq!(first.changed_count, 1);
4240 let second = migrate_sandbox_config(&first.output).unwrap();
4241 assert_eq!(second.changed_count, 0, "second run must not double-append");
4242 assert_eq!(second.output, first.output);
4243 }
4244
4245 #[test]
4246 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
4247 let base = "[agent]\nname = \"Zeph\"\n";
4248 let first = migrate_agent_budget_hint(base).unwrap();
4249 assert_eq!(first.changed_count, 1);
4250 let second = migrate_agent_budget_hint(&first.output).unwrap();
4251 assert_eq!(second.changed_count, 0, "second run must not double-append");
4252 assert_eq!(second.output, first.output);
4253 }
4254
4255 #[test]
4256 fn migrate_forgetting_config_idempotent_on_commented_output() {
4257 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4258 let first = migrate_forgetting_config(base).unwrap();
4259 assert_eq!(first.changed_count, 1);
4260 let second = migrate_forgetting_config(&first.output).unwrap();
4261 assert_eq!(second.changed_count, 0, "second run must not double-append");
4262 assert_eq!(second.output, first.output);
4263 }
4264
4265 #[test]
4266 fn migrate_microcompact_config_idempotent_on_commented_output() {
4267 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4268 let first = migrate_microcompact_config(base).unwrap();
4269 assert_eq!(first.changed_count, 1);
4270 let second = migrate_microcompact_config(&first.output).unwrap();
4271 assert_eq!(second.changed_count, 0, "second run must not double-append");
4272 assert_eq!(second.output, first.output);
4273 }
4274
4275 #[test]
4276 fn migrate_autodream_config_idempotent_on_commented_output() {
4277 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4278 let first = migrate_autodream_config(base).unwrap();
4279 assert_eq!(first.changed_count, 1);
4280 let second = migrate_autodream_config(&first.output).unwrap();
4281 assert_eq!(second.changed_count, 0, "second run must not double-append");
4282 assert_eq!(second.output, first.output);
4283 }
4284
4285 #[test]
4286 fn migrate_compression_predictor_strips_active_section() {
4287 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
4288 let result = migrate_compression_predictor_config(base).unwrap();
4289 assert!(!result.output.contains("[memory.compression.predictor]"));
4290 assert!(!result.output.contains("min_samples"));
4291 assert!(result.output.contains("[memory.other]"));
4292 assert_eq!(result.changed_count, 1);
4293 }
4294
4295 #[test]
4296 fn migrate_compression_predictor_strips_commented_section() {
4297 let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
4298 let result = migrate_compression_predictor_config(base).unwrap();
4299 assert!(!result.output.contains("compression.predictor"));
4300 assert!(result.output.contains("[memory.other]"));
4301 }
4302
4303 #[test]
4304 fn migrate_compression_predictor_idempotent() {
4305 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
4306 let first = migrate_compression_predictor_config(base).unwrap();
4307 let second = migrate_compression_predictor_config(&first.output).unwrap();
4308 assert_eq!(second.output, first.output);
4309 assert_eq!(second.changed_count, 0);
4310 }
4311
4312 #[test]
4313 fn migrate_compression_predictor_noop_when_absent() {
4314 let base = "[memory]\ndb_path = \"test\"\n";
4315 let result = migrate_compression_predictor_config(base).unwrap();
4316 assert_eq!(result.output, base);
4317 assert_eq!(result.changed_count, 0);
4318 }
4319
4320 #[test]
4321 fn migrate_database_url_idempotent_on_commented_output() {
4322 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4323 let first = migrate_database_url(base).unwrap();
4324 assert_eq!(first.changed_count, 1);
4325 let second = migrate_database_url(&first.output).unwrap();
4326 assert_eq!(second.changed_count, 0, "second run must not double-append");
4327 assert_eq!(second.output, first.output);
4328 }
4329
4330 #[test]
4331 fn migrate_shell_transactional_idempotent_on_commented_output() {
4332 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
4333 let first = migrate_shell_transactional(base).unwrap();
4334 assert_eq!(first.changed_count, 1);
4335 let second = migrate_shell_transactional(&first.output).unwrap();
4336 assert_eq!(second.changed_count, 0, "second run must not double-append");
4337 assert_eq!(second.output, first.output);
4338 }
4339
4340 #[test]
4341 fn migrate_otel_filter_idempotent_on_commented_output() {
4342 let base = "[telemetry]\nenabled = true\n";
4343 let first = migrate_otel_filter(base).unwrap();
4344 assert_eq!(first.changed_count, 1);
4345 let second = migrate_otel_filter(&first.output).unwrap();
4346 assert_eq!(second.changed_count, 0, "second run must not double-append");
4347 assert_eq!(second.output, first.output);
4348 }
4349
4350 #[test]
4351 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4352 let migrator = ConfigMigrator::new();
4353 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4354 let result = migrator.migrate(src).expect("migrate");
4355 let sec_body_start = result
4356 .output
4357 .find("[security.content_isolation]")
4358 .unwrap_or(0);
4359 let sec_body = &result.output[sec_body_start..];
4360 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4361 let sec_slice = &sec_body[..next_header];
4362 assert!(
4363 sec_slice.contains("# enabled"),
4364 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4365 );
4366 }
4367
4368 #[test]
4369 fn config_migrator_idempotent_on_realistic_config() {
4370 let base = r#"
4371[agent]
4372name = "Zeph"
4373
4374[memory]
4375db_path = "~/.zeph/memory.db"
4376soft_compaction_threshold = 0.6
4377
4378[index]
4379max_chunks = 12
4380
4381[tools]
4382[tools.shell]
4383allow_list = []
4384
4385[telemetry]
4386enabled = false
4387
4388[security]
4389[security.content_isolation]
4390enabled = true
4391"#;
4392 let migrator = ConfigMigrator::new();
4393 let first = migrator.migrate(base).expect("first migrate");
4394 let second = migrator.migrate(&first.output).expect("second migrate");
4395 assert_eq!(
4396 second.changed_count, 0,
4397 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4398 second.changed_count
4399 );
4400 assert_eq!(
4401 first.output, second.output,
4402 "output must be identical on second run"
4403 );
4404 for line in first.output.lines() {
4405 if line.starts_with('[') && !line.starts_with("[[") {
4406 assert!(
4407 !line.contains('#'),
4408 "section header must not have inline comment: {line:?}"
4409 );
4410 }
4411 }
4412 }
4413
4414 #[test]
4415 fn migrate_claude_prompt_cache_ttl_1h_survives() {
4416 let src = r#"
4417[llm]
4418provider = "claude"
4419
4420[llm.cloud]
4421model = "claude-sonnet-4-6"
4422prompt_cache_ttl = "1h"
4423"#;
4424 let result = migrate_llm_to_providers(src).expect("migrate");
4425 assert!(
4426 result.output.contains("prompt_cache_ttl = \"1h\""),
4427 "1h TTL must be preserved in migrated output:\n{}",
4428 result.output
4429 );
4430 }
4431
4432 #[test]
4433 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4434 let src = r#"
4435[llm]
4436provider = "claude"
4437
4438[llm.cloud]
4439model = "claude-sonnet-4-6"
4440prompt_cache_ttl = "ephemeral"
4441"#;
4442 let result = migrate_llm_to_providers(src).expect("migrate");
4443 assert!(
4444 !result.output.contains("prompt_cache_ttl"),
4445 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4446 result.output
4447 );
4448 }
4449
4450 #[test]
4451 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4452 let src = r#"
4453[[llm.providers]]
4454type = "claude"
4455model = "claude-sonnet-4-6"
4456prompt_cache_ttl = "1h"
4457"#;
4458 let migrator = ConfigMigrator::new();
4459 let first = migrator.migrate(src).expect("first migrate");
4460 let second = migrator.migrate(&first.output).expect("second migrate");
4461 assert_eq!(
4462 first.output, second.output,
4463 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4464 );
4465 }
4466
4467 #[test]
4470 fn migrate_session_recap_adds_block_when_absent() {
4471 let src = "[agent]\nname = \"Zeph\"\n";
4472 let result = migrate_session_recap_config(src).expect("migrate");
4473 assert_eq!(result.changed_count, 1);
4474 assert!(
4475 result
4476 .sections_changed
4477 .contains(&"session.recap".to_owned())
4478 );
4479 assert!(result.output.contains("# [session.recap]"));
4480 assert!(result.output.contains("on_resume = true"));
4481 }
4482
4483 #[test]
4484 fn migrate_session_recap_idempotent_on_commented_block() {
4485 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4486 let result = migrate_session_recap_config(src).expect("migrate");
4487 assert_eq!(result.changed_count, 0);
4488 assert_eq!(result.output, src);
4489 }
4490
4491 #[test]
4492 fn migrate_session_recap_idempotent_on_active_section() {
4493 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4494 let result = migrate_session_recap_config(src).expect("migrate");
4495 assert_eq!(result.changed_count, 0);
4496 assert_eq!(result.output, src);
4497 }
4498
4499 #[test]
4502 fn migrate_mcp_elicitation_adds_keys_when_absent() {
4503 let src = "[mcp]\nallowed_commands = []\n";
4504 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4505 assert_eq!(result.changed_count, 1);
4506 assert!(
4507 result
4508 .sections_changed
4509 .contains(&"mcp.elicitation".to_owned())
4510 );
4511 assert!(result.output.contains("# elicitation_enabled = false"));
4512 assert!(result.output.contains("# elicitation_timeout = 120"));
4513 }
4514
4515 #[test]
4516 fn migrate_mcp_elicitation_idempotent_when_key_present() {
4517 let src = "[mcp]\nelicitation_enabled = true\n";
4518 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4519 assert_eq!(result.changed_count, 0);
4520 assert_eq!(result.output, src);
4521 }
4522
4523 #[test]
4524 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4525 let src = "[agent]\nname = \"Zeph\"\n";
4526 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4527 assert_eq!(result.changed_count, 0);
4528 assert_eq!(result.output, src);
4529 }
4530
4531 #[test]
4532 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4533 let src = "[mcp]";
4535 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4536 assert_eq!(result.changed_count, 0);
4537 assert_eq!(result.output, src);
4538 }
4539
4540 #[test]
4543 fn migrate_quality_adds_block_when_absent() {
4544 let src = "[agent]\nname = \"Zeph\"\n";
4545 let result = migrate_quality_config(src).expect("migrate");
4546 assert_eq!(result.changed_count, 1);
4547 assert!(result.sections_changed.contains(&"quality".to_owned()));
4548 assert!(result.output.contains("# [quality]"));
4549 assert!(result.output.contains("self_check = false"));
4550 assert!(result.output.contains("trigger = \"has_retrieval\""));
4551 }
4552
4553 #[test]
4554 fn migrate_quality_idempotent_on_commented_block() {
4555 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4556 let result = migrate_quality_config(src).expect("migrate");
4557 assert_eq!(result.changed_count, 0);
4558 assert_eq!(result.output, src);
4559 }
4560
4561 #[test]
4562 fn migrate_quality_idempotent_on_active_section() {
4563 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4564 let result = migrate_quality_config(src).expect("migrate");
4565 assert_eq!(result.changed_count, 0);
4566 assert_eq!(result.output, src);
4567 }
4568
4569 #[test]
4572 fn migrate_acp_subagents_adds_block_when_absent() {
4573 let src = "[agent]\nname = \"Zeph\"\n";
4574 let result = migrate_acp_subagents_config(src).expect("migrate");
4575 assert_eq!(result.changed_count, 1);
4576 assert!(
4577 result
4578 .sections_changed
4579 .contains(&"acp.subagents".to_owned())
4580 );
4581 assert!(result.output.contains("# [acp.subagents]"));
4582 assert!(result.output.contains("enabled = false"));
4583 }
4584
4585 #[test]
4586 fn migrate_acp_subagents_idempotent_on_existing_block() {
4587 let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4588 let result = migrate_acp_subagents_config(src).expect("migrate");
4589 assert_eq!(result.changed_count, 0);
4590 assert_eq!(result.output, src);
4591 }
4592
4593 #[test]
4596 fn migrate_hooks_permission_denied_adds_block_when_absent() {
4597 let src = "[agent]\nname = \"Zeph\"\n";
4598 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4599 assert_eq!(result.changed_count, 1);
4600 assert!(
4601 result
4602 .sections_changed
4603 .contains(&"hooks.permission_denied".to_owned())
4604 );
4605 assert!(result.output.contains("# [[hooks.permission_denied]]"));
4606 assert!(result.output.contains("ZEPH_TOOL"));
4607 }
4608
4609 #[test]
4610 fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4611 let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4612 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4613 assert_eq!(result.changed_count, 0);
4614 assert_eq!(result.output, src);
4615 }
4616
4617 #[test]
4620 fn migrate_memory_graph_adds_block_when_absent() {
4621 let src = "[agent]\nname = \"Zeph\"\n";
4622 let result = migrate_memory_graph_config(src).expect("migrate");
4623 assert_eq!(result.changed_count, 1);
4624 assert!(
4625 result
4626 .sections_changed
4627 .contains(&"memory.graph.retrieval".to_owned())
4628 );
4629 assert!(result.output.contains("retrieval_strategy"));
4630 assert!(result.output.contains("# [memory.graph.beam_search]"));
4631 }
4632
4633 #[test]
4634 fn migrate_memory_graph_idempotent_on_existing_block() {
4635 let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4636 let result = migrate_memory_graph_config(src).expect("migrate");
4637 assert_eq!(result.changed_count, 0);
4638 assert_eq!(result.output, src);
4639 }
4640
4641 #[test]
4644 fn migrate_scheduler_daemon_adds_block_when_absent() {
4645 let src = "[agent]\nname = \"Zeph\"\n";
4646 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4647 assert_eq!(result.changed_count, 1);
4648 assert!(
4649 result
4650 .sections_changed
4651 .contains(&"scheduler.daemon".to_owned())
4652 );
4653 assert!(result.output.contains("# [scheduler.daemon]"));
4654 assert!(result.output.contains("pid_file"));
4655 assert!(result.output.contains("tick_secs = 60"));
4656 assert!(result.output.contains("shutdown_grace_secs = 30"));
4657 assert!(result.output.contains("catch_up = true"));
4658 }
4659
4660 #[test]
4661 fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4662 let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4663 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4664 assert_eq!(result.changed_count, 0);
4665 assert_eq!(result.output, src);
4666 }
4667
4668 #[test]
4671 fn migrate_memory_retrieval_adds_block_when_absent() {
4672 let src = "[agent]\nname = \"Zeph\"\n";
4673 let result = migrate_memory_retrieval_config(src).expect("migrate");
4674 assert_eq!(result.changed_count, 1);
4675 assert!(
4676 result
4677 .sections_changed
4678 .contains(&"memory.retrieval".to_owned())
4679 );
4680 assert!(result.output.contains("# [memory.retrieval]"));
4681 assert!(result.output.contains("depth = 0"));
4682 assert!(result.output.contains("context_format"));
4683 }
4684
4685 #[test]
4686 fn migrate_memory_retrieval_idempotent_on_active_section() {
4687 let src = "[memory.retrieval]\ndepth = 40\n";
4688 let result = migrate_memory_retrieval_config(src).expect("migrate");
4689 assert_eq!(result.changed_count, 0);
4690 assert_eq!(result.output, src);
4691 }
4692
4693 #[test]
4694 fn migrate_memory_retrieval_idempotent_on_commented_section() {
4695 let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
4696 let result = migrate_memory_retrieval_config(src).expect("migrate");
4697 assert_eq!(result.changed_count, 0);
4698 assert_eq!(result.output, src);
4699 }
4700
4701 #[test]
4704 fn migrate_adds_pr4_acp_keys_commented() {
4705 let migrator = ConfigMigrator::new();
4706 let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
4707 let out = migrator.migrate(input).expect("migrate");
4708 assert!(
4709 out.output.contains("# additional_directories = []"),
4710 "expected commented additional_directories; got:\n{}",
4711 out.output
4712 );
4713 assert!(
4714 out.output.contains("# auth_methods = [\"agent\"]"),
4715 "expected commented auth_methods; got:\n{}",
4716 out.output
4717 );
4718 assert!(
4719 out.output.contains("# message_ids_enabled = true"),
4720 "expected commented message_ids_enabled; got:\n{}",
4721 out.output
4722 );
4723 }
4724
4725 #[test]
4728 fn migrate_memory_reasoning_adds_block_when_absent() {
4729 let input = "[agent]\nmodel = \"gpt-4o\"\n";
4730 let result = migrate_memory_reasoning_config(input).unwrap();
4731 assert_eq!(result.changed_count, 1);
4732 assert!(
4733 result
4734 .sections_changed
4735 .contains(&"memory.reasoning".to_owned())
4736 );
4737 assert!(result.output.contains("# [memory.reasoning]"));
4738 assert!(result.output.contains("extraction_timeout_secs = 30"));
4739 assert!(result.output.contains("max_message_chars = 2000"));
4740 }
4741
4742 #[test]
4743 fn migrate_memory_reasoning_idempotent_on_existing_block() {
4744 let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
4745 let result = migrate_memory_reasoning_config(input).unwrap();
4746 assert_eq!(result.changed_count, 0);
4747 assert!(result.sections_changed.is_empty());
4748 assert_eq!(result.output, input);
4749 }
4750
4751 #[test]
4754 fn migrate_hooks_turn_complete_adds_block_when_absent() {
4755 let input = "[agent]\nmodel = \"gpt-4o\"\n";
4756 let result = migrate_hooks_turn_complete_config(input).unwrap();
4757 assert_eq!(result.changed_count, 1);
4758 assert!(
4759 result
4760 .sections_changed
4761 .contains(&"hooks.turn_complete".to_owned())
4762 );
4763 assert!(result.output.contains("# [[hooks.turn_complete]]"));
4764 assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
4765 assert!(result.output.contains("timeout_secs = 3"));
4766 }
4767
4768 #[test]
4769 fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
4770 let input =
4771 "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
4772 let result = migrate_hooks_turn_complete_config(input).unwrap();
4773 assert_eq!(result.changed_count, 0);
4774 assert!(result.sections_changed.is_empty());
4775 assert_eq!(result.output, input);
4776 }
4777
4778 #[test]
4782 fn migrate_focus_auto_consolidate_injects_inside_section() {
4783 let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
4784 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4785 assert_eq!(result.changed_count, 1);
4786 let comment_pos = result
4787 .output
4788 .find("auto_consolidate_min_window")
4789 .expect("comment must be present");
4790 let other_pos = result
4791 .output
4792 .find("[other]")
4793 .expect("[other] must be present");
4794 assert!(
4795 comment_pos < other_pos,
4796 "auto_consolidate_min_window comment must appear before [other] section"
4797 );
4798 }
4799
4800 #[test]
4801 fn migrate_focus_auto_consolidate_idempotent() {
4802 let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
4803 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4804 assert_eq!(result.changed_count, 0);
4805 assert_eq!(result.output, input);
4806 }
4807
4808 #[test]
4809 fn migrate_focus_auto_consolidate_noop_when_section_absent() {
4810 let input = "[agent]\nname = \"zeph\"\n";
4811 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4812 assert_eq!(result.changed_count, 0);
4813 assert_eq!(result.output, input);
4814 }
4815
4816 #[test]
4817 fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
4818 let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
4819 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4820 assert_eq!(result.changed_count, 0);
4821 assert_eq!(result.output, input);
4822 }
4823
4824 #[test]
4827 fn registry_has_thirty_nine_entries() {
4828 assert_eq!(MIGRATIONS.len(), 39);
4829 }
4830
4831 #[test]
4832 fn registry_names_are_unique_and_non_empty() {
4833 let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
4834 for name in &names {
4835 assert!(!name.is_empty(), "migration name must not be empty");
4836 }
4837 let mut deduped = names.clone();
4838 deduped.sort_unstable();
4839 deduped.dedup();
4840 assert_eq!(deduped.len(), names.len(), "migration names must be unique");
4841 }
4842
4843 #[test]
4844 fn registry_is_idempotent_on_empty_input() {
4845 const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
4848
4849 let mut toml = String::new();
4850 for m in MIGRATIONS.iter() {
4851 let result = m.apply(&toml).expect("registry migration must not fail");
4852 toml = result.output;
4853 }
4854 for m in MIGRATIONS.iter() {
4855 if COMMENT_ONLY.contains(&m.name()) {
4856 continue;
4857 }
4858 let result = m
4859 .apply(&toml)
4860 .expect("registry migration must not fail on second pass");
4861 assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
4862 }
4863 }
4864
4865 #[test]
4866 fn registry_preserves_order_matches_dispatch() {
4867 let expected = [
4869 "migrate_stt_to_provider",
4870 "migrate_planner_model_to_provider",
4871 "migrate_mcp_trust_levels",
4872 "migrate_agent_retry_to_tools_retry",
4873 "migrate_database_url",
4874 "migrate_shell_transactional",
4875 "migrate_agent_budget_hint",
4876 "migrate_forgetting_config",
4877 "migrate_compression_predictor_config",
4878 "migrate_microcompact_config",
4879 "migrate_autodream_config",
4880 "migrate_magic_docs_config",
4881 "migrate_telemetry_config",
4882 "migrate_supervisor_config",
4883 "migrate_otel_filter",
4884 "migrate_egress_config",
4885 "migrate_vigil_config",
4886 "migrate_sandbox_config",
4887 "migrate_sandbox_egress_filter",
4888 "migrate_orchestration_persistence",
4889 "migrate_session_recap_config",
4890 "migrate_mcp_elicitation_config",
4891 "migrate_quality_config",
4892 "migrate_acp_subagents_config",
4893 "migrate_hooks_permission_denied_config",
4894 "migrate_memory_graph_config",
4895 "migrate_scheduler_daemon_config",
4896 "migrate_memory_retrieval_config",
4897 "migrate_memory_reasoning_config",
4898 "migrate_memory_reasoning_judge_config",
4899 "migrate_memory_hebbian_config",
4900 "migrate_memory_hebbian_consolidation_config",
4901 "migrate_memory_hebbian_spread_config",
4902 "migrate_hooks_turn_complete_config",
4903 "migrate_focus_auto_consolidate_min_window",
4904 "migrate_session_provider_persistence",
4905 "migrate_memory_retrieval_query_bias",
4906 "migrate_memory_persona_config",
4907 "migrate_qdrant_api_key",
4908 ];
4909 let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
4910 assert_eq!(actual, expected);
4911 }
4912
4913 #[test]
4916 fn migrate_qdrant_api_key_adds_comment_when_absent() {
4917 let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
4918 let result = migrate_qdrant_api_key(src).expect("migrate");
4919 assert_eq!(result.changed_count, 1);
4920 assert!(
4921 result
4922 .sections_changed
4923 .contains(&"memory.qdrant_api_key".to_owned())
4924 );
4925 assert!(result.output.contains("# qdrant_api_key = \"\""));
4926 }
4927
4928 #[test]
4929 fn migrate_qdrant_api_key_is_noop_when_present() {
4930 let src =
4931 "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
4932 let result = migrate_qdrant_api_key(src).expect("migrate");
4933 assert_eq!(result.changed_count, 0);
4934 assert!(result.sections_changed.is_empty());
4935 assert_eq!(result.output, src);
4936 }
4937
4938 #[test]
4939 fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
4940 let src = "[agent]\nname = \"Zeph\"\n";
4941 let result = migrate_qdrant_api_key(src).expect("migrate");
4942 assert_eq!(result.changed_count, 1);
4943 assert!(result.output.contains("# qdrant_api_key = \"\""));
4944 }
4945
4946 #[test]
4947 fn migrate_qdrant_api_key_idempotent_on_commented_output() {
4948 let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
4949 let first = migrate_qdrant_api_key(base).unwrap();
4950 assert_eq!(first.changed_count, 1);
4951 let second = migrate_qdrant_api_key(&first.output).unwrap();
4952 assert_eq!(second.changed_count, 0, "second run must not double-append");
4953 assert_eq!(second.output, first.output);
4954 }
4955}