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
1087#[allow(clippy::too_many_lines)]
1106pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1107 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1108
1109 let stt_model = doc
1111 .get("llm")
1112 .and_then(toml_edit::Item::as_table)
1113 .and_then(|llm| llm.get("stt"))
1114 .and_then(toml_edit::Item::as_table)
1115 .and_then(|stt| stt.get("model"))
1116 .and_then(toml_edit::Item::as_str)
1117 .map(ToOwned::to_owned);
1118
1119 let stt_base_url = doc
1120 .get("llm")
1121 .and_then(toml_edit::Item::as_table)
1122 .and_then(|llm| llm.get("stt"))
1123 .and_then(toml_edit::Item::as_table)
1124 .and_then(|stt| stt.get("base_url"))
1125 .and_then(toml_edit::Item::as_str)
1126 .map(ToOwned::to_owned);
1127
1128 let stt_provider_hint = doc
1129 .get("llm")
1130 .and_then(toml_edit::Item::as_table)
1131 .and_then(|llm| llm.get("stt"))
1132 .and_then(toml_edit::Item::as_table)
1133 .and_then(|stt| stt.get("provider"))
1134 .and_then(toml_edit::Item::as_str)
1135 .map(ToOwned::to_owned)
1136 .unwrap_or_default();
1137
1138 if stt_model.is_none() && stt_base_url.is_none() {
1140 return Ok(MigrationResult {
1141 output: toml_src.to_owned(),
1142 changed_count: 0,
1143 sections_changed: Vec::new(),
1144 });
1145 }
1146
1147 let stt_model = stt_model.unwrap_or_else(|| "whisper-1".to_owned());
1148
1149 let target_type = match stt_provider_hint.as_str() {
1151 "candle-whisper" | "candle" => "candle",
1152 _ => "openai",
1153 };
1154
1155 let providers = doc
1158 .get("llm")
1159 .and_then(toml_edit::Item::as_table)
1160 .and_then(|llm| llm.get("providers"))
1161 .and_then(toml_edit::Item::as_array_of_tables);
1162
1163 let matching_idx = providers.and_then(|arr| {
1164 arr.iter().enumerate().find_map(|(i, t)| {
1165 let name = t
1166 .get("name")
1167 .and_then(toml_edit::Item::as_str)
1168 .unwrap_or("");
1169 let ptype = t
1170 .get("type")
1171 .and_then(toml_edit::Item::as_str)
1172 .unwrap_or("");
1173 let name_match = !stt_provider_hint.is_empty()
1175 && (name == stt_provider_hint || ptype == stt_provider_hint);
1176 let type_match = ptype == target_type;
1177 if name_match || type_match {
1178 Some(i)
1179 } else {
1180 None
1181 }
1182 })
1183 });
1184
1185 let resolved_provider_name: String;
1187
1188 if let Some(idx) = matching_idx {
1189 let llm_mut = doc
1191 .get_mut("llm")
1192 .and_then(toml_edit::Item::as_table_mut)
1193 .ok_or(MigrateError::InvalidStructure(
1194 "[llm] table not accessible for mutation",
1195 ))?;
1196 let providers_mut = llm_mut
1197 .get_mut("providers")
1198 .and_then(toml_edit::Item::as_array_of_tables_mut)
1199 .ok_or(MigrateError::InvalidStructure(
1200 "[[llm.providers]] array not accessible for mutation",
1201 ))?;
1202 let entry = providers_mut
1203 .iter_mut()
1204 .nth(idx)
1205 .ok_or(MigrateError::InvalidStructure(
1206 "[[llm.providers]] entry index out of range during mutation",
1207 ))?;
1208
1209 let existing_name = entry
1211 .get("name")
1212 .and_then(toml_edit::Item::as_str)
1213 .map(ToOwned::to_owned);
1214 let entry_name = existing_name.unwrap_or_else(|| {
1215 let t = entry
1216 .get("type")
1217 .and_then(toml_edit::Item::as_str)
1218 .unwrap_or("openai");
1219 format!("{t}-stt")
1220 });
1221 entry.insert("name", toml_edit::value(entry_name.clone()));
1222 entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1223 if stt_base_url.is_some() && entry.get("base_url").is_none() {
1224 entry.insert(
1225 "base_url",
1226 toml_edit::value(stt_base_url.as_deref().unwrap_or_default()),
1227 );
1228 }
1229 resolved_provider_name = entry_name;
1230 } else {
1231 let new_name = if target_type == "candle" {
1233 "local-whisper".to_owned()
1234 } else {
1235 "openai-stt".to_owned()
1236 };
1237 let mut new_entry = toml_edit::Table::new();
1238 new_entry.insert("name", toml_edit::value(new_name.clone()));
1239 new_entry.insert("type", toml_edit::value(target_type));
1240 new_entry.insert("stt_model", toml_edit::value(stt_model.clone()));
1241 if let Some(ref url) = stt_base_url {
1242 new_entry.insert("base_url", toml_edit::value(url.clone()));
1243 }
1244 let llm_mut = doc
1246 .get_mut("llm")
1247 .and_then(toml_edit::Item::as_table_mut)
1248 .ok_or(MigrateError::InvalidStructure(
1249 "[llm] table not accessible for mutation",
1250 ))?;
1251 if let Some(item) = llm_mut.get_mut("providers") {
1252 if let Some(arr) = item.as_array_of_tables_mut() {
1253 arr.push(new_entry);
1254 }
1255 } else {
1256 let mut arr = toml_edit::ArrayOfTables::new();
1257 arr.push(new_entry);
1258 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1259 }
1260 resolved_provider_name = new_name;
1261 }
1262
1263 if let Some(stt_table) = doc
1265 .get_mut("llm")
1266 .and_then(toml_edit::Item::as_table_mut)
1267 .and_then(|llm| llm.get_mut("stt"))
1268 .and_then(toml_edit::Item::as_table_mut)
1269 {
1270 stt_table.insert("provider", toml_edit::value(resolved_provider_name.clone()));
1271 stt_table.remove("model");
1272 stt_table.remove("base_url");
1273 }
1274
1275 Ok(MigrationResult {
1276 output: doc.to_string(),
1277 changed_count: 1,
1278 sections_changed: vec!["llm.providers.stt_model".to_owned()],
1279 })
1280}
1281
1282pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1295 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1296
1297 let old_value = doc
1298 .get("orchestration")
1299 .and_then(toml_edit::Item::as_table)
1300 .and_then(|t| t.get("planner_model"))
1301 .and_then(toml_edit::Item::as_value)
1302 .and_then(toml_edit::Value::as_str)
1303 .map(ToOwned::to_owned);
1304
1305 let Some(old_model) = old_value else {
1306 return Ok(MigrationResult {
1307 output: toml_src.to_owned(),
1308 changed_count: 0,
1309 sections_changed: Vec::new(),
1310 });
1311 };
1312
1313 let commented_out = format!(
1317 "# planner_provider = \"{old_model}\" \
1318 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1319 );
1320
1321 let orch_table = doc
1322 .get_mut("orchestration")
1323 .and_then(toml_edit::Item::as_table_mut)
1324 .ok_or(MigrateError::InvalidStructure(
1325 "[orchestration] is not a table",
1326 ))?;
1327 orch_table.remove("planner_model");
1328 let decor = orch_table.decor_mut();
1329 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1330 let new_suffix = if existing_suffix.trim().is_empty() {
1332 format!("\n{commented_out}\n")
1333 } else {
1334 format!("{existing_suffix}\n{commented_out}\n")
1335 };
1336 decor.set_suffix(new_suffix);
1337
1338 eprintln!(
1339 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1340 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1341 `name` field, not a raw model name. Update or remove the commented line."
1342 );
1343
1344 Ok(MigrationResult {
1345 output: doc.to_string(),
1346 changed_count: 1,
1347 sections_changed: vec!["orchestration.planner_provider".to_owned()],
1348 })
1349}
1350
1351pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1365 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1366 let mut added = 0usize;
1367
1368 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1369 return Ok(MigrationResult {
1370 output: toml_src.to_owned(),
1371 changed_count: 0,
1372 sections_changed: Vec::new(),
1373 });
1374 };
1375
1376 let Some(servers) = mcp
1377 .get_mut("servers")
1378 .and_then(toml_edit::Item::as_array_of_tables_mut)
1379 else {
1380 return Ok(MigrationResult {
1381 output: toml_src.to_owned(),
1382 changed_count: 0,
1383 sections_changed: Vec::new(),
1384 });
1385 };
1386
1387 for entry in servers.iter_mut() {
1388 if !entry.contains_key("trust_level") {
1389 entry.insert(
1390 "trust_level",
1391 toml_edit::value(toml_edit::Value::from("trusted")),
1392 );
1393 added += 1;
1394 }
1395 }
1396
1397 if added > 0 {
1398 eprintln!(
1399 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1400 entr{} (preserving previous SSRF-skip behavior). \
1401 Review and adjust trust levels as needed.",
1402 if added == 1 { "y" } else { "ies" }
1403 );
1404 }
1405
1406 Ok(MigrationResult {
1407 output: doc.to_string(),
1408 changed_count: added,
1409 sections_changed: if added > 0 {
1410 vec!["mcp.servers.trust_level".to_owned()]
1411 } else {
1412 Vec::new()
1413 },
1414 })
1415}
1416
1417pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1428 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1429
1430 let max_retries = doc
1431 .get("agent")
1432 .and_then(toml_edit::Item::as_table)
1433 .and_then(|t| t.get("max_tool_retries"))
1434 .and_then(toml_edit::Item::as_value)
1435 .and_then(toml_edit::Value::as_integer)
1436 .map(i64::cast_unsigned);
1437
1438 let budget_secs = doc
1439 .get("agent")
1440 .and_then(toml_edit::Item::as_table)
1441 .and_then(|t| t.get("max_retry_duration_secs"))
1442 .and_then(toml_edit::Item::as_value)
1443 .and_then(toml_edit::Value::as_integer)
1444 .map(i64::cast_unsigned);
1445
1446 if max_retries.is_none() && budget_secs.is_none() {
1447 return Ok(MigrationResult {
1448 output: toml_src.to_owned(),
1449 changed_count: 0,
1450 sections_changed: Vec::new(),
1451 });
1452 }
1453
1454 if !doc.contains_key("tools") {
1456 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1457 }
1458 let tools_table = doc
1459 .get_mut("tools")
1460 .and_then(toml_edit::Item::as_table_mut)
1461 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1462
1463 if !tools_table.contains_key("retry") {
1464 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1465 }
1466 let retry_table = tools_table
1467 .get_mut("retry")
1468 .and_then(toml_edit::Item::as_table_mut)
1469 .ok_or(MigrateError::InvalidStructure(
1470 "[tools.retry] is not a table",
1471 ))?;
1472
1473 let mut changed_count = 0usize;
1474
1475 if let Some(retries) = max_retries
1476 && !retry_table.contains_key("max_attempts")
1477 {
1478 retry_table.insert(
1479 "max_attempts",
1480 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1481 );
1482 changed_count += 1;
1483 }
1484
1485 if let Some(secs) = budget_secs
1486 && !retry_table.contains_key("budget_secs")
1487 {
1488 retry_table.insert(
1489 "budget_secs",
1490 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1491 );
1492 changed_count += 1;
1493 }
1494
1495 if changed_count > 0 {
1496 eprintln!(
1497 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1498 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1499 );
1500 }
1501
1502 Ok(MigrationResult {
1503 output: doc.to_string(),
1504 changed_count,
1505 sections_changed: if changed_count > 0 {
1506 vec!["tools.retry".to_owned()]
1507 } else {
1508 Vec::new()
1509 },
1510 })
1511}
1512
1513pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1522 if toml_src.contains("database_url") {
1524 return Ok(MigrationResult {
1525 output: toml_src.to_owned(),
1526 changed_count: 0,
1527 sections_changed: Vec::new(),
1528 });
1529 }
1530
1531 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1532
1533 if !doc.contains_key("memory") {
1535 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1536 }
1537
1538 let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1539 # Leave empty and store the actual URL in the vault:\n\
1540 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1541 # database_url = \"\"\n";
1542 let raw = doc.to_string();
1543 let output = format!("{raw}{comment}");
1544
1545 Ok(MigrationResult {
1546 output,
1547 changed_count: 1,
1548 sections_changed: vec!["memory.database_url".to_owned()],
1549 })
1550}
1551
1552pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1561 if toml_src.contains("transactional") {
1563 return Ok(MigrationResult {
1564 output: toml_src.to_owned(),
1565 changed_count: 0,
1566 sections_changed: Vec::new(),
1567 });
1568 }
1569
1570 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1571
1572 let tools_shell_exists = doc
1573 .get("tools")
1574 .and_then(toml_edit::Item::as_table)
1575 .is_some_and(|t| t.contains_key("shell"));
1576 if !tools_shell_exists {
1577 return Ok(MigrationResult {
1579 output: toml_src.to_owned(),
1580 changed_count: 0,
1581 sections_changed: Vec::new(),
1582 });
1583 }
1584
1585 let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1586 # transactional = false\n\
1587 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1588 # auto_rollback = false # rollback when exit code >= 2\n\
1589 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1590 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1591 let raw = doc.to_string();
1592 let output = format!("{raw}{comment}");
1593
1594 Ok(MigrationResult {
1595 output,
1596 changed_count: 1,
1597 sections_changed: vec!["tools.shell.transactional".to_owned()],
1598 })
1599}
1600
1601pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1607 if toml_src.contains("budget_hint_enabled") {
1609 return Ok(MigrationResult {
1610 output: toml_src.to_owned(),
1611 changed_count: 0,
1612 sections_changed: Vec::new(),
1613 });
1614 }
1615
1616 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1617 if !doc.contains_key("agent") {
1618 return Ok(MigrationResult {
1619 output: toml_src.to_owned(),
1620 changed_count: 0,
1621 sections_changed: Vec::new(),
1622 });
1623 }
1624
1625 let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1626 # budget_hint_enabled = true\n";
1627 let raw = doc.to_string();
1628 let output = format!("{raw}{comment}");
1629
1630 Ok(MigrationResult {
1631 output,
1632 changed_count: 1,
1633 sections_changed: vec!["agent.budget_hint_enabled".to_owned()],
1634 })
1635}
1636
1637pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1646 if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1648 return Ok(MigrationResult {
1649 output: toml_src.to_owned(),
1650 changed_count: 0,
1651 sections_changed: Vec::new(),
1652 });
1653 }
1654
1655 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1656 if !doc.contains_key("memory") {
1657 return Ok(MigrationResult {
1658 output: toml_src.to_owned(),
1659 changed_count: 0,
1660 sections_changed: Vec::new(),
1661 });
1662 }
1663
1664 let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1665 # [memory.forgetting]\n\
1666 # enabled = false\n\
1667 # decay_rate = 0.1 # per-sweep importance decay\n\
1668 # forgetting_floor = 0.05 # prune below this score\n\
1669 # sweep_interval_secs = 7200 # run every 2 hours\n\
1670 # sweep_batch_size = 500\n\
1671 # protect_recent_hours = 24\n\
1672 # protect_min_access_count = 3\n";
1673 let raw = doc.to_string();
1674 let output = format!("{raw}{comment}");
1675
1676 Ok(MigrationResult {
1677 output,
1678 changed_count: 1,
1679 sections_changed: vec!["memory.forgetting".to_owned()],
1680 })
1681}
1682
1683pub fn migrate_compression_predictor_config(
1692 toml_src: &str,
1693) -> Result<MigrationResult, MigrateError> {
1694 let has_active = toml_src.contains("[memory.compression.predictor]");
1697 let has_commented = toml_src.contains("# [memory.compression.predictor]");
1698 if !has_active && !has_commented {
1699 return Ok(MigrationResult {
1700 output: toml_src.to_owned(),
1701 changed_count: 0,
1702 sections_changed: Vec::new(),
1703 });
1704 }
1705
1706 let mut output_lines: Vec<&str> = Vec::new();
1710 let mut in_predictor = false;
1711 for line in toml_src.lines() {
1712 let trimmed = line.trim();
1713 if trimmed == "[memory.compression.predictor]"
1715 || trimmed == "# [memory.compression.predictor]"
1716 {
1717 in_predictor = true;
1718 continue;
1719 }
1720 if in_predictor && trimmed.starts_with('[') && !trimmed.starts_with("# [") {
1722 in_predictor = false;
1723 }
1724 if !in_predictor {
1725 output_lines.push(line);
1726 }
1727 }
1728 let mut output = output_lines.join("\n");
1730 if toml_src.ends_with('\n') {
1731 output.push('\n');
1732 }
1733
1734 Ok(MigrationResult {
1735 output,
1736 changed_count: 1,
1737 sections_changed: vec!["memory.compression.predictor".to_owned()],
1738 })
1739}
1740
1741pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1747 if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1749 return Ok(MigrationResult {
1750 output: toml_src.to_owned(),
1751 changed_count: 0,
1752 sections_changed: Vec::new(),
1753 });
1754 }
1755
1756 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1757 if !doc.contains_key("memory") {
1758 return Ok(MigrationResult {
1759 output: toml_src.to_owned(),
1760 changed_count: 0,
1761 sections_changed: Vec::new(),
1762 });
1763 }
1764
1765 let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1766 # [memory.microcompact]\n\
1767 # enabled = false\n\
1768 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1769 # keep_recent = 3 # always keep this many recent outputs intact\n";
1770 let raw = doc.to_string();
1771 let output = format!("{raw}{comment}");
1772
1773 Ok(MigrationResult {
1774 output,
1775 changed_count: 1,
1776 sections_changed: vec!["memory.microcompact".to_owned()],
1777 })
1778}
1779
1780pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1786 if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1788 return Ok(MigrationResult {
1789 output: toml_src.to_owned(),
1790 changed_count: 0,
1791 sections_changed: Vec::new(),
1792 });
1793 }
1794
1795 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1796 if !doc.contains_key("memory") {
1797 return Ok(MigrationResult {
1798 output: toml_src.to_owned(),
1799 changed_count: 0,
1800 sections_changed: Vec::new(),
1801 });
1802 }
1803
1804 let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1805 # [memory.autodream]\n\
1806 # enabled = false\n\
1807 # min_sessions = 5 # sessions since last consolidation\n\
1808 # min_hours = 8 # hours since last consolidation\n\
1809 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1810 # max_iterations = 5\n";
1811 let raw = doc.to_string();
1812 let output = format!("{raw}{comment}");
1813
1814 Ok(MigrationResult {
1815 output,
1816 changed_count: 1,
1817 sections_changed: vec!["memory.autodream".to_owned()],
1818 })
1819}
1820
1821pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1827 use toml_edit::{Item, Table};
1828
1829 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1830
1831 if doc.contains_key("magic_docs") {
1832 return Ok(MigrationResult {
1833 output: toml_src.to_owned(),
1834 changed_count: 0,
1835 sections_changed: Vec::new(),
1836 });
1837 }
1838
1839 doc.insert("magic_docs", Item::Table(Table::new()));
1840 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1841 # [magic_docs]\n\
1842 # enabled = false\n\
1843 # min_turns_between_updates = 10\n\
1844 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1845 # max_iterations = 3\n";
1846 doc.remove("magic_docs");
1848 let raw = doc.to_string();
1850 let output = format!("{raw}\n{comment}");
1851
1852 Ok(MigrationResult {
1853 output,
1854 changed_count: 1,
1855 sections_changed: vec!["magic_docs".to_owned()],
1856 })
1857}
1858
1859pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1868 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1869
1870 if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1871 return Ok(MigrationResult {
1872 output: toml_src.to_owned(),
1873 changed_count: 0,
1874 sections_changed: Vec::new(),
1875 });
1876 }
1877
1878 let comment = "\n\
1879 # Profiling and distributed tracing (requires --features profiling). All\n\
1880 # instrumentation points are zero-overhead when the feature is absent.\n\
1881 # [telemetry]\n\
1882 # enabled = false\n\
1883 # backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1884 # trace_dir = \".local/traces\"\n\
1885 # include_args = false\n\
1886 # service_name = \"zeph-agent\"\n\
1887 # sample_rate = 1.0\n\
1888 # otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1889
1890 let raw = doc.to_string();
1891 let output = format!("{raw}{comment}");
1892
1893 Ok(MigrationResult {
1894 output,
1895 changed_count: 1,
1896 sections_changed: vec!["telemetry".to_owned()],
1897 })
1898}
1899
1900pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1909 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1911 return Ok(MigrationResult {
1912 output: toml_src.to_owned(),
1913 changed_count: 0,
1914 sections_changed: Vec::new(),
1915 });
1916 }
1917
1918 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1919
1920 if !doc.contains_key("agent") {
1923 return Ok(MigrationResult {
1924 output: toml_src.to_owned(),
1925 changed_count: 0,
1926 sections_changed: Vec::new(),
1927 });
1928 }
1929
1930 let comment = "\n\
1931 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1932 # [agent.supervisor]\n\
1933 # enrichment_limit = 4\n\
1934 # telemetry_limit = 8\n\
1935 # abort_enrichment_on_turn = false\n";
1936
1937 let raw = doc.to_string();
1938 let output = format!("{raw}{comment}");
1939
1940 Ok(MigrationResult {
1941 output,
1942 changed_count: 1,
1943 sections_changed: vec!["agent.supervisor".to_owned()],
1944 })
1945}
1946
1947pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1957 if toml_src.contains("otel_filter") {
1959 return Ok(MigrationResult {
1960 output: toml_src.to_owned(),
1961 changed_count: 0,
1962 sections_changed: Vec::new(),
1963 });
1964 }
1965
1966 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1967
1968 if !doc.contains_key("telemetry") {
1971 return Ok(MigrationResult {
1972 output: toml_src.to_owned(),
1973 changed_count: 0,
1974 sections_changed: Vec::new(),
1975 });
1976 }
1977
1978 let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
1979 (tonic=warn etc.) are always appended (#2997).\n\
1980 # otel_filter = \"info\"\n";
1981 let raw = doc.to_string();
1982 let output = insert_after_section(&raw, "telemetry", comment);
1984
1985 Ok(MigrationResult {
1986 output,
1987 changed_count: 1,
1988 sections_changed: vec!["telemetry.otel_filter".to_owned()],
1989 })
1990}
1991
1992pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1998 if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
1999 return Ok(MigrationResult {
2000 output: toml_src.to_owned(),
2001 changed_count: 0,
2002 sections_changed: Vec::new(),
2003 });
2004 }
2005
2006 let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
2007 # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
2008 # [tools.egress]\n\
2009 # enabled = true # set to false to disable all egress event recording\n\
2010 # log_blocked = true # record scheme/domain/SSRF-blocked requests\n\
2011 # log_response_bytes = true\n\
2012 # log_hosts_to_tui = true\n";
2013
2014 let mut output = toml_src.to_owned();
2015 output.push_str(comment);
2016 Ok(MigrationResult {
2017 output,
2018 changed_count: 1,
2019 sections_changed: vec!["tools.egress".to_owned()],
2020 })
2021}
2022
2023pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2029 if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
2030 return Ok(MigrationResult {
2031 output: toml_src.to_owned(),
2032 changed_count: 0,
2033 sections_changed: Vec::new(),
2034 });
2035 }
2036
2037 let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2038 # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2039 # [security.vigil]\n\
2040 # enabled = true # master switch; false bypasses VIGIL entirely\n\
2041 # strict_mode = false # true: block (replace with sentinel); false: truncate+annotate\n\
2042 # sanitize_max_chars = 2048\n\
2043 # extra_patterns = [] # operator-supplied additional injection patterns (max 64)\n\
2044 # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2045
2046 let mut output = toml_src.to_owned();
2047 output.push_str(comment);
2048 Ok(MigrationResult {
2049 output,
2050 changed_count: 1,
2051 sections_changed: vec!["security.vigil".to_owned()],
2052 })
2053}
2054
2055pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2067 let doc: DocumentMut = toml_src.parse()?;
2068 let already_present = doc
2069 .get("tools")
2070 .and_then(|t| t.as_table())
2071 .and_then(|t| t.get("sandbox"))
2072 .is_some();
2073 if already_present || toml_src.contains("# [tools.sandbox]") {
2076 return Ok(MigrationResult {
2077 output: toml_src.to_owned(),
2078 changed_count: 0,
2079 sections_changed: Vec::new(),
2080 });
2081 }
2082
2083 let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2084 # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2085 # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2086 # [tools.sandbox]\n\
2087 # enabled = false # set to true to wrap shell commands\n\
2088 # profile = \"workspace\" # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2089 # backend = \"auto\" # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2090 # strict = true # fail startup if sandbox init fails (fail-closed)\n\
2091 # allow_read = [] # additional read-allowed absolute paths\n\
2092 # allow_write = [] # additional write-allowed absolute paths\n";
2093
2094 let mut output = toml_src.to_owned();
2095 output.push_str(comment);
2096 Ok(MigrationResult {
2097 output,
2098 changed_count: 1,
2099 sections_changed: vec!["tools.sandbox".to_owned()],
2100 })
2101}
2102
2103pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2112 if !toml_src.contains("[tools.sandbox]") {
2114 return Ok(MigrationResult {
2115 output: toml_src.to_owned(),
2116 changed_count: 0,
2117 sections_changed: Vec::new(),
2118 });
2119 }
2120
2121 let already_has_denied =
2122 toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
2123 let already_has_fail =
2124 toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
2125
2126 if already_has_denied && already_has_fail {
2127 return Ok(MigrationResult {
2128 output: toml_src.to_owned(),
2129 changed_count: 0,
2130 sections_changed: Vec::new(),
2131 });
2132 }
2133
2134 let mut comment = String::new();
2135 if !already_has_denied {
2136 comment.push_str(
2137 "# denied_domains = [] \
2138 # hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
2139 );
2140 }
2141 if !already_has_fail {
2142 comment.push_str(
2143 "# fail_if_unavailable = false \
2144 # abort startup when no effective OS sandbox is available\n",
2145 );
2146 }
2147
2148 let output = toml_src.replacen(
2149 "[tools.sandbox]\n",
2150 &format!("[tools.sandbox]\n{comment}"),
2151 1,
2152 );
2153 Ok(MigrationResult {
2154 output,
2155 changed_count: 1,
2156 sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
2157 })
2158}
2159
2160pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2169 if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2171 return Ok(MigrationResult {
2172 output: toml_src.to_owned(),
2173 changed_count: 0,
2174 sections_changed: Vec::new(),
2175 });
2176 }
2177
2178 if !toml_src.contains("[orchestration]") {
2180 return Ok(MigrationResult {
2181 output: toml_src.to_owned(),
2182 changed_count: 0,
2183 sections_changed: Vec::new(),
2184 });
2185 }
2186
2187 let comment = "# persistence_enabled = true \
2189 # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2190 let output = toml_src.replacen(
2191 "[orchestration]\n",
2192 &format!("[orchestration]\n{comment}"),
2193 1,
2194 );
2195 Ok(MigrationResult {
2196 output,
2197 changed_count: 1,
2198 sections_changed: vec!["orchestration.persistence_enabled".to_owned()],
2199 })
2200}
2201
2202pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2210 if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2212 return Ok(MigrationResult {
2213 output: toml_src.to_owned(),
2214 changed_count: 0,
2215 sections_changed: Vec::new(),
2216 });
2217 }
2218
2219 let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2220 # [session.recap]\n\
2221 # on_resume = true\n\
2222 # max_tokens = 200\n\
2223 # provider = \"\"\n\
2224 # max_input_messages = 20\n";
2225 let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2226 let output = format!("{raw}{comment}");
2227
2228 Ok(MigrationResult {
2229 output,
2230 changed_count: 1,
2231 sections_changed: vec!["session.recap".to_owned()],
2232 })
2233}
2234
2235pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2243 if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2245 return Ok(MigrationResult {
2246 output: toml_src.to_owned(),
2247 changed_count: 0,
2248 sections_changed: Vec::new(),
2249 });
2250 }
2251
2252 if !toml_src.contains("[mcp]") {
2254 return Ok(MigrationResult {
2255 output: toml_src.to_owned(),
2256 changed_count: 0,
2257 sections_changed: Vec::new(),
2258 });
2259 }
2260
2261 if !toml_src.contains("[mcp]\n") {
2263 return Ok(MigrationResult {
2264 output: toml_src.to_owned(),
2265 changed_count: 0,
2266 sections_changed: Vec::new(),
2267 });
2268 }
2269
2270 let comment = "# elicitation_enabled = false \
2271 # opt-in: servers may request user input mid-task (#3141)\n\
2272 # elicitation_timeout = 120 # seconds to wait for user response\n\
2273 # elicitation_queue_capacity = 16 # beyond this limit requests are auto-declined\n\
2274 # elicitation_warn_sensitive_fields = true # warn before prompting for password/token/etc.\n";
2275 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2276
2277 Ok(MigrationResult {
2278 output,
2279 changed_count: 1,
2280 sections_changed: vec!["mcp.elicitation".to_owned()],
2281 })
2282}
2283
2284pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2295 if toml_src
2297 .lines()
2298 .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2299 {
2300 return Ok(MigrationResult {
2301 output: toml_src.to_owned(),
2302 changed_count: 0,
2303 sections_changed: Vec::new(),
2304 });
2305 }
2306
2307 let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2308 # [quality]\n\
2309 # self_check = false # enable post-response self-check\n\
2310 # trigger = \"has_retrieval\" # has_retrieval | always | manual\n\
2311 # latency_budget_ms = 4000 # hard ceiling for the whole pipeline\n\
2312 # proposer_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2313 # checker_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2314 # min_evidence = 0.6 # 0.0..1.0; below → flag assertion\n\
2315 # async_run = false # true = fire-and-forget (non-blocking)\n\
2316 # per_call_timeout_ms = 2000 # per-LLM-call timeout\n\
2317 # max_assertions = 12 # maximum assertions extracted from one response\n\
2318 # max_response_chars = 8000 # skip pipeline when response exceeds this\n\
2319 # cache_disabled_for_checker = true # suppress prompt-cache on Checker provider\n\
2320 # flag_marker = \"[verify]\" # marker appended when assertions are flagged\n";
2321 let output = format!("{toml_src}{comment}");
2322
2323 Ok(MigrationResult {
2324 output,
2325 changed_count: 1,
2326 sections_changed: vec!["quality".to_owned()],
2327 })
2328}
2329
2330pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2341 if toml_src
2342 .lines()
2343 .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2344 {
2345 return Ok(MigrationResult {
2346 output: toml_src.to_owned(),
2347 changed_count: 0,
2348 sections_changed: Vec::new(),
2349 });
2350 }
2351
2352 let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2353 # [acp.subagents]\n\
2354 # enabled = false\n\
2355 #\n\
2356 # [[acp.subagents.presets]]\n\
2357 # name = \"inner\" # identifier used in /subagent commands\n\
2358 # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2359 # # cwd = \"/path/to/agent\" # optional working directory\n\
2360 # # handshake_timeout_secs = 30 # initialize+session/new timeout\n\
2361 # # prompt_timeout_secs = 600 # single round-trip timeout\n";
2362 let output = format!("{toml_src}{comment}");
2363
2364 Ok(MigrationResult {
2365 output,
2366 changed_count: 1,
2367 sections_changed: vec!["acp.subagents".to_owned()],
2368 })
2369}
2370
2371pub fn migrate_hooks_permission_denied_config(
2382 toml_src: &str,
2383) -> Result<MigrationResult, MigrateError> {
2384 if toml_src.lines().any(|l| {
2385 l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2386 }) {
2387 return Ok(MigrationResult {
2388 output: toml_src.to_owned(),
2389 changed_count: 0,
2390 sections_changed: Vec::new(),
2391 });
2392 }
2393
2394 let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2395 # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2396 # [[hooks.permission_denied]]\n\
2397 # [hooks.permission_denied.action]\n\
2398 # type = \"command\"\n\
2399 # command = \"echo denied: $ZEPH_TOOL\"\n";
2400 let output = format!("{toml_src}{comment}");
2401
2402 Ok(MigrationResult {
2403 output,
2404 changed_count: 1,
2405 sections_changed: vec!["hooks.permission_denied".to_owned()],
2406 })
2407}
2408
2409pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2420 if toml_src.contains("retrieval_strategy")
2421 || toml_src.contains("[memory.graph.beam_search]")
2422 || toml_src.contains("# [memory.graph.beam_search]")
2423 {
2424 return Ok(MigrationResult {
2425 output: toml_src.to_owned(),
2426 changed_count: 0,
2427 sections_changed: Vec::new(),
2428 });
2429 }
2430
2431 let comment = "\n# [memory.graph] retrieval strategy options (#3311).\n\
2432 # retrieval_strategy = \"synapse\" # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2433 #\n\
2434 # [memory.graph.beam_search] # active when retrieval_strategy = \"beam_search\"\n\
2435 # beam_width = 10 # top-K candidates kept per hop\n\
2436 #\n\
2437 # [memory.graph.watercircles] # active when retrieval_strategy = \"watercircles\"\n\
2438 # ring_limit = 0 # max facts per ring; 0 = auto\n\
2439 #\n\
2440 # [memory.graph.experience] # experience memory recording\n\
2441 # enabled = false\n\
2442 # evolution_sweep_enabled = false\n\
2443 # confidence_prune_threshold = 0.1 # prune edges below this threshold\n\
2444 # evolution_sweep_interval = 50 # turns between sweeps\n";
2445 let output = format!("{toml_src}{comment}");
2446
2447 Ok(MigrationResult {
2448 output,
2449 changed_count: 1,
2450 sections_changed: vec!["memory.graph.retrieval".to_owned()],
2451 })
2452}
2453
2454pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2465 if toml_src
2466 .lines()
2467 .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2468 {
2469 return Ok(MigrationResult {
2470 output: toml_src.to_owned(),
2471 changed_count: 0,
2472 sections_changed: Vec::new(),
2473 });
2474 }
2475
2476 let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2477 # [scheduler.daemon]\n\
2478 # pid_file = \"/tmp/zeph-scheduler.pid\" # PID file path (must be on a local filesystem)\n\
2479 # log_file = \"/tmp/zeph-scheduler.log\" # daemon log file path (append-only; rotate externally)\n\
2480 # tick_secs = 60 # scheduler tick interval in seconds (clamped 5..=3600)\n\
2481 # shutdown_grace_secs = 30 # grace period after SIGTERM before process exits\n\
2482 # catch_up = true # replay missed cron tasks on daemon restart\n";
2483 let output = format!("{toml_src}{comment}");
2484
2485 Ok(MigrationResult {
2486 output,
2487 changed_count: 1,
2488 sections_changed: vec!["scheduler.daemon".to_owned()],
2489 })
2490}
2491
2492pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2503 if toml_src
2504 .lines()
2505 .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2506 {
2507 return Ok(MigrationResult {
2508 output: toml_src.to_owned(),
2509 changed_count: 0,
2510 sections_changed: Vec::new(),
2511 });
2512 }
2513
2514 let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2515 # [memory.retrieval]\n\
2516 # depth = 0 # ANN candidates fetched from the vector store, directly.\n\
2517 # # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2518 # # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2519 # search_prompt_template = \"\" # embedding query template; {query} = raw user query; empty = identity\n\
2520 # context_format = \"structured\" # structured | plain — memory snippet rendering format\n\
2521 # query_bias_correction = true # shift first-person queries towards user profile centroid (MM-F3)\n\
2522 # query_bias_profile_weight = 0.25 # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2523 # query_bias_centroid_ttl_secs = 300 # seconds before profile centroid cache is recomputed\n";
2524 let output = format!("{toml_src}{comment}");
2525
2526 Ok(MigrationResult {
2527 output,
2528 changed_count: 1,
2529 sections_changed: vec!["memory.retrieval".to_owned()],
2530 })
2531}
2532
2533pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2544 if toml_src
2545 .lines()
2546 .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2547 {
2548 return Ok(MigrationResult {
2549 output: toml_src.to_owned(),
2550 changed_count: 0,
2551 sections_changed: Vec::new(),
2552 });
2553 }
2554
2555 let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2556 # [memory.reasoning]\n\
2557 # enabled = false\n\
2558 # extract_provider = \"\" # SLM: self-judge (JSON response) — leave blank to use primary\n\
2559 # distill_provider = \"\" # SLM: strategy distillation — leave blank to use primary\n\
2560 # top_k = 3 # strategies injected per turn\n\
2561 # store_limit = 1000 # max rows in reasoning_strategies table\n\
2562 # context_budget_tokens = 500\n\
2563 # extraction_timeout_secs = 30\n\
2564 # distill_timeout_secs = 30\n\
2565 # max_messages = 6\n\
2566 # min_messages = 2\n\
2567 # max_message_chars = 2000\n";
2568 let output = format!("{toml_src}{comment}");
2569
2570 Ok(MigrationResult {
2571 output,
2572 changed_count: 1,
2573 sections_changed: vec!["memory.reasoning".to_owned()],
2574 })
2575}
2576
2577pub fn migrate_memory_reasoning_judge_config(
2589 toml_src: &str,
2590) -> Result<MigrationResult, MigrateError> {
2591 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2592 if !has_section {
2593 return Ok(MigrationResult {
2594 output: toml_src.to_owned(),
2595 changed_count: 0,
2596 sections_changed: Vec::new(),
2597 });
2598 }
2599
2600 let has_window = toml_src.lines().any(|l| {
2602 let t = l.trim().trim_start_matches('#').trim();
2603 t.starts_with("self_judge_window")
2604 });
2605 let has_min_chars = toml_src.lines().any(|l| {
2606 let t = l.trim().trim_start_matches('#').trim();
2607 t.starts_with("min_assistant_chars")
2608 });
2609 if has_window && has_min_chars {
2610 return Ok(MigrationResult {
2611 output: toml_src.to_owned(),
2612 changed_count: 0,
2613 sections_changed: Vec::new(),
2614 });
2615 }
2616
2617 let lines: Vec<&str> = toml_src.lines().collect();
2621 let mut section_start = None;
2622 let mut insert_after = None;
2623
2624 for (i, line) in lines.iter().enumerate() {
2625 if line.trim() == "[memory.reasoning]" {
2626 section_start = Some(i);
2627 }
2628 if let Some(start) = section_start {
2629 let trimmed = line.trim();
2630 if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2632 break;
2633 }
2634 insert_after = Some(i);
2635 }
2636 }
2637
2638 let Some(insert_idx) = insert_after else {
2639 return Ok(MigrationResult {
2640 output: toml_src.to_owned(),
2641 changed_count: 0,
2642 sections_changed: Vec::new(),
2643 });
2644 };
2645
2646 let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2647 let mut additions = Vec::new();
2648 if !has_window {
2649 additions.push(
2650 "# self_judge_window = 2 # max recent messages passed to self-judge (#3383)"
2651 .to_owned(),
2652 );
2653 }
2654 if !has_min_chars {
2655 additions.push(
2656 "# min_assistant_chars = 50 # skip self-judge for short replies (#3383)".to_owned(),
2657 );
2658 }
2659 for (offset, line) in additions.iter().enumerate() {
2660 new_lines.insert(insert_idx + 1 + offset, line.clone());
2661 }
2662
2663 let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2664 Ok(MigrationResult {
2665 output,
2666 changed_count: additions.len(),
2667 sections_changed: vec!["memory.reasoning".to_owned()],
2668 })
2669}
2670
2671pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2681 if toml_src
2682 .lines()
2683 .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2684 {
2685 return Ok(MigrationResult {
2686 output: toml_src.to_owned(),
2687 changed_count: 0,
2688 sections_changed: Vec::new(),
2689 });
2690 }
2691
2692 let comment = "\n# [memory.hebbian] # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2693 # [memory.hebbian]\n\
2694 # enabled = false # opt-in master switch; no DB writes when false\n\
2695 # hebbian_lr = 0.1 # weight increment per co-activation (0.01–0.5)\n";
2696 let output = format!("{toml_src}{comment}");
2697
2698 Ok(MigrationResult {
2699 output,
2700 changed_count: 1,
2701 sections_changed: vec!["memory.hebbian".to_owned()],
2702 })
2703}
2704
2705pub fn migrate_memory_hebbian_consolidation_config(
2717 toml_src: &str,
2718) -> Result<MigrationResult, MigrateError> {
2719 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2720
2721 if !has_section {
2722 return Ok(MigrationResult {
2723 output: toml_src.to_owned(),
2724 changed_count: 0,
2725 sections_changed: Vec::new(),
2726 });
2727 }
2728
2729 let has_interval = toml_src
2731 .lines()
2732 .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2733 let has_threshold = toml_src
2734 .lines()
2735 .any(|l| l.trim().starts_with("consolidation_threshold"));
2736 let has_provider = toml_src
2737 .lines()
2738 .any(|l| l.trim().starts_with("consolidate_provider"));
2739
2740 if has_interval && has_threshold && has_provider {
2741 return Ok(MigrationResult {
2742 output: toml_src.to_owned(),
2743 changed_count: 0,
2744 sections_changed: Vec::new(),
2745 });
2746 }
2747
2748 let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2749 # consolidation_interval_secs = 3600 # how often the sweep runs (0 = disabled)\n\
2750 # consolidation_threshold = 5.0 # degree × avg_weight score to qualify\n\
2751 # consolidate_provider = \"fast\" # provider name for LLM distillation\n\
2752 # max_candidates_per_sweep = 10\n\
2753 # consolidation_cooldown_secs = 86400 # re-consolidation cooldown per entity\n\
2754 # consolidation_prompt_timeout_secs = 30\n\
2755 # consolidation_max_neighbors = 20\n";
2756
2757 let output = format!("{toml_src}{extra}");
2758 Ok(MigrationResult {
2759 output,
2760 changed_count: 1,
2761 sections_changed: vec!["memory.hebbian".to_owned()],
2762 })
2763}
2764
2765pub fn migrate_memory_hebbian_spread_config(
2777 toml_src: &str,
2778) -> Result<MigrationResult, MigrateError> {
2779 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2780
2781 if !has_section {
2782 return Ok(MigrationResult {
2783 output: toml_src.to_owned(),
2784 changed_count: 0,
2785 sections_changed: Vec::new(),
2786 });
2787 }
2788
2789 let has_spreading = toml_src
2791 .lines()
2792 .any(|l| l.trim().starts_with("spreading_activation"));
2793 let has_depth = toml_src
2794 .lines()
2795 .any(|l| l.trim().starts_with("spread_depth"));
2796 let has_budget = toml_src
2797 .lines()
2798 .any(|l| l.trim().starts_with("step_budget_ms"));
2799
2800 if has_spreading && has_depth && has_budget {
2801 return Ok(MigrationResult {
2802 output: toml_src.to_owned(),
2803 changed_count: 0,
2804 sections_changed: Vec::new(),
2805 });
2806 }
2807
2808 let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2809 # spreading_activation = false # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2810 # spread_depth = 2 # BFS hops, clamped [1,6]\n\
2811 # spread_edge_types = [] # MAGMA edge types to traverse; empty = all\n\
2812 # step_budget_ms = 8 # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n";
2813
2814 let output = format!("{toml_src}{extra}");
2815 Ok(MigrationResult {
2816 output,
2817 changed_count: 1,
2818 sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2819 })
2820}
2821
2822pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2836 if toml_src
2837 .lines()
2838 .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2839 {
2840 return Ok(MigrationResult {
2841 output: toml_src.to_owned(),
2842 changed_count: 0,
2843 sections_changed: Vec::new(),
2844 });
2845 }
2846
2847 let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2848 # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2849 # ZEPH_TURN_LLM_REQUESTS.\n\
2850 # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2851 # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2852 # [[hooks.turn_complete]]\n\
2853 # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2854 # timeout_secs = 3\n\
2855 # fail_closed = false\n";
2856 let output = format!("{toml_src}{comment}");
2857
2858 Ok(MigrationResult {
2859 output,
2860 changed_count: 1,
2861 sections_changed: vec!["hooks.turn_complete".to_owned()],
2862 })
2863}
2864
2865pub fn migrate_focus_auto_consolidate_min_window(
2882 toml_src: &str,
2883) -> Result<MigrationResult, MigrateError> {
2884 if toml_src.contains("auto_consolidate_min_window") {
2885 return Ok(MigrationResult {
2886 output: toml_src.to_owned(),
2887 changed_count: 0,
2888 sections_changed: Vec::new(),
2889 });
2890 }
2891
2892 if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
2894 return Ok(MigrationResult {
2895 output: toml_src.to_owned(),
2896 changed_count: 0,
2897 sections_changed: Vec::new(),
2898 });
2899 }
2900
2901 let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
2902 runs (#3313).\n\
2903 # auto_consolidate_min_window = 6\n";
2904 let output = insert_after_section(toml_src, "agent.focus", comment);
2905
2906 Ok(MigrationResult {
2907 output,
2908 changed_count: 1,
2909 sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
2910 })
2911}
2912
2913#[cfg(test)]
2915fn make_formatted_str(s: &str) -> Value {
2916 use toml_edit::Formatted;
2917 Value::String(Formatted::new(s.to_owned()))
2918}
2919
2920#[cfg(test)]
2921mod tests {
2922 use super::*;
2923
2924 #[test]
2925 fn empty_config_gets_sections_as_comments() {
2926 let migrator = ConfigMigrator::new();
2927 let result = migrator.migrate("").expect("migrate empty");
2928 assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
2930 assert!(
2932 result.output.contains("[agent]") || result.output.contains("# [agent]"),
2933 "expected agent section in output, got:\n{}",
2934 result.output
2935 );
2936 }
2937
2938 #[test]
2939 fn existing_values_not_overwritten() {
2940 let user = r#"
2941[agent]
2942name = "MyAgent"
2943max_tool_iterations = 5
2944"#;
2945 let migrator = ConfigMigrator::new();
2946 let result = migrator.migrate(user).expect("migrate");
2947 assert!(
2949 result.output.contains("name = \"MyAgent\""),
2950 "user value should be preserved"
2951 );
2952 assert!(
2953 result.output.contains("max_tool_iterations = 5"),
2954 "user value should be preserved"
2955 );
2956 assert!(
2958 !result.output.contains("# max_tool_iterations = 10"),
2959 "already-set key should not appear as comment"
2960 );
2961 }
2962
2963 #[test]
2964 fn missing_nested_key_added_as_comment() {
2965 let user = r#"
2967[memory]
2968sqlite_path = ".zeph/data/zeph.db"
2969"#;
2970 let migrator = ConfigMigrator::new();
2971 let result = migrator.migrate(user).expect("migrate");
2972 assert!(
2974 result.output.contains("# history_limit"),
2975 "missing key should be added as comment, got:\n{}",
2976 result.output
2977 );
2978 }
2979
2980 #[test]
2981 fn unknown_user_keys_preserved() {
2982 let user = r#"
2983[agent]
2984name = "Test"
2985my_custom_key = "preserved"
2986"#;
2987 let migrator = ConfigMigrator::new();
2988 let result = migrator.migrate(user).expect("migrate");
2989 assert!(
2990 result.output.contains("my_custom_key = \"preserved\""),
2991 "custom user keys must not be removed"
2992 );
2993 }
2994
2995 #[test]
2996 fn idempotent() {
2997 let migrator = ConfigMigrator::new();
2998 let first = migrator
2999 .migrate("[agent]\nname = \"Zeph\"\n")
3000 .expect("first migrate");
3001 let second = migrator.migrate(&first.output).expect("second migrate");
3002 assert_eq!(
3003 first.output, second.output,
3004 "idempotent: full output must be identical on second run"
3005 );
3006 }
3007
3008 #[test]
3009 fn malformed_input_returns_error() {
3010 let migrator = ConfigMigrator::new();
3011 let err = migrator
3012 .migrate("[[invalid toml [[[")
3013 .expect_err("should error");
3014 assert!(
3015 matches!(err, MigrateError::Parse(_)),
3016 "expected Parse error"
3017 );
3018 }
3019
3020 #[test]
3021 fn array_of_tables_preserved() {
3022 let user = r#"
3023[mcp]
3024allowed_commands = ["npx"]
3025
3026[[mcp.servers]]
3027id = "my-server"
3028command = "npx"
3029args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3030"#;
3031 let migrator = ConfigMigrator::new();
3032 let result = migrator.migrate(user).expect("migrate");
3033 assert!(
3035 result.output.contains("[[mcp.servers]]"),
3036 "array-of-tables entries must be preserved"
3037 );
3038 assert!(result.output.contains("id = \"my-server\""));
3039 }
3040
3041 #[test]
3042 fn canonical_ordering_applied() {
3043 let user = r#"
3045[memory]
3046sqlite_path = ".zeph/data/zeph.db"
3047
3048[agent]
3049name = "Test"
3050"#;
3051 let migrator = ConfigMigrator::new();
3052 let result = migrator.migrate(user).expect("migrate");
3053 let agent_pos = result.output.find("[agent]");
3055 let memory_pos = result.output.find("[memory]");
3056 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3057 assert!(a < m, "agent section should precede memory section");
3058 }
3059 }
3060
3061 #[test]
3062 fn value_to_toml_string_formats_correctly() {
3063 use toml_edit::Formatted;
3064
3065 let s = make_formatted_str("hello");
3066 assert_eq!(value_to_toml_string(&s), "\"hello\"");
3067
3068 let i = Value::Integer(Formatted::new(42_i64));
3069 assert_eq!(value_to_toml_string(&i), "42");
3070
3071 let b = Value::Boolean(Formatted::new(true));
3072 assert_eq!(value_to_toml_string(&b), "true");
3073
3074 let f = Value::Float(Formatted::new(1.0_f64));
3075 assert_eq!(value_to_toml_string(&f), "1.0");
3076
3077 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3078 assert_eq!(value_to_toml_string(&f2), "3.14");
3079
3080 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3081 let arr_val = Value::Array(arr);
3082 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3083
3084 let empty_arr = Value::Array(Array::new());
3085 assert_eq!(value_to_toml_string(&empty_arr), "[]");
3086 }
3087
3088 #[test]
3089 fn idempotent_full_output_unchanged() {
3090 let migrator = ConfigMigrator::new();
3092 let first = migrator
3093 .migrate("[agent]\nname = \"Zeph\"\n")
3094 .expect("first migrate");
3095 let second = migrator.migrate(&first.output).expect("second migrate");
3096 assert_eq!(
3097 first.output, second.output,
3098 "full output string must be identical after second migration pass"
3099 );
3100 }
3101
3102 #[test]
3103 fn full_config_produces_zero_additions() {
3104 let reference = include_str!("../config/default.toml");
3106 let migrator = ConfigMigrator::new();
3107 let result = migrator.migrate(reference).expect("migrate reference");
3108 assert_eq!(
3109 result.changed_count, 0,
3110 "migrating the canonical reference should add nothing (changed_count = {})",
3111 result.changed_count
3112 );
3113 assert!(
3114 result.sections_changed.is_empty(),
3115 "migrating the canonical reference should report no sections_changed: {:?}",
3116 result.sections_changed
3117 );
3118 }
3119
3120 #[test]
3121 fn empty_config_changed_count_is_positive() {
3122 let migrator = ConfigMigrator::new();
3124 let result = migrator.migrate("").expect("migrate empty");
3125 assert!(
3126 result.changed_count > 0,
3127 "empty config must report changed_count > 0"
3128 );
3129 }
3130
3131 #[test]
3134 fn security_without_guardrail_gets_guardrail_commented() {
3135 let user = "[security]\nredact_secrets = true\n";
3136 let migrator = ConfigMigrator::new();
3137 let result = migrator.migrate(user).expect("migrate");
3138 assert!(
3140 result.output.contains("guardrail"),
3141 "migration must add guardrail keys for configs without [security.guardrail]: \
3142 got:\n{}",
3143 result.output
3144 );
3145 }
3146
3147 #[test]
3148 fn migrate_reference_contains_tools_policy() {
3149 let reference = include_str!("../config/default.toml");
3154 assert!(
3155 reference.contains("[tools.policy]"),
3156 "default.toml must contain [tools.policy] section so migrate-config can surface it"
3157 );
3158 assert!(
3159 reference.contains("enabled = false"),
3160 "tools.policy section must include enabled = false default"
3161 );
3162 }
3163
3164 #[test]
3165 fn migrate_reference_contains_probe_section() {
3166 let reference = include_str!("../config/default.toml");
3169 assert!(
3170 reference.contains("[memory.compression.probe]"),
3171 "default.toml must contain [memory.compression.probe] section comment"
3172 );
3173 assert!(
3174 reference.contains("hard_fail_threshold"),
3175 "probe section must include hard_fail_threshold default"
3176 );
3177 }
3178
3179 #[test]
3182 fn migrate_llm_no_llm_section_is_noop() {
3183 let src = "[agent]\nname = \"Zeph\"\n";
3184 let result = migrate_llm_to_providers(src).expect("migrate");
3185 assert_eq!(result.changed_count, 0);
3186 assert_eq!(result.output, src);
3187 }
3188
3189 #[test]
3190 fn migrate_llm_already_new_format_is_noop() {
3191 let src = r#"
3192[llm]
3193[[llm.providers]]
3194type = "ollama"
3195model = "qwen3:8b"
3196"#;
3197 let result = migrate_llm_to_providers(src).expect("migrate");
3198 assert_eq!(result.changed_count, 0);
3199 }
3200
3201 #[test]
3202 fn migrate_llm_ollama_produces_providers_block() {
3203 let src = r#"
3204[llm]
3205provider = "ollama"
3206model = "qwen3:8b"
3207base_url = "http://localhost:11434"
3208embedding_model = "nomic-embed-text"
3209"#;
3210 let result = migrate_llm_to_providers(src).expect("migrate");
3211 assert!(
3212 result.output.contains("[[llm.providers]]"),
3213 "should contain [[llm.providers]]:\n{}",
3214 result.output
3215 );
3216 assert!(
3217 result.output.contains("type = \"ollama\""),
3218 "{}",
3219 result.output
3220 );
3221 assert!(
3222 result.output.contains("model = \"qwen3:8b\""),
3223 "{}",
3224 result.output
3225 );
3226 }
3227
3228 #[test]
3229 fn migrate_llm_claude_produces_providers_block() {
3230 let src = r#"
3231[llm]
3232provider = "claude"
3233
3234[llm.cloud]
3235model = "claude-sonnet-4-6"
3236max_tokens = 8192
3237server_compaction = true
3238"#;
3239 let result = migrate_llm_to_providers(src).expect("migrate");
3240 assert!(
3241 result.output.contains("[[llm.providers]]"),
3242 "{}",
3243 result.output
3244 );
3245 assert!(
3246 result.output.contains("type = \"claude\""),
3247 "{}",
3248 result.output
3249 );
3250 assert!(
3251 result.output.contains("model = \"claude-sonnet-4-6\""),
3252 "{}",
3253 result.output
3254 );
3255 assert!(
3256 result.output.contains("server_compaction = true"),
3257 "{}",
3258 result.output
3259 );
3260 }
3261
3262 #[test]
3263 fn migrate_llm_openai_copies_fields() {
3264 let src = r#"
3265[llm]
3266provider = "openai"
3267
3268[llm.openai]
3269base_url = "https://api.openai.com/v1"
3270model = "gpt-4o"
3271max_tokens = 4096
3272"#;
3273 let result = migrate_llm_to_providers(src).expect("migrate");
3274 assert!(
3275 result.output.contains("type = \"openai\""),
3276 "{}",
3277 result.output
3278 );
3279 assert!(
3280 result
3281 .output
3282 .contains("base_url = \"https://api.openai.com/v1\""),
3283 "{}",
3284 result.output
3285 );
3286 }
3287
3288 #[test]
3289 fn migrate_llm_gemini_copies_fields() {
3290 let src = r#"
3291[llm]
3292provider = "gemini"
3293
3294[llm.gemini]
3295model = "gemini-2.0-flash"
3296max_tokens = 8192
3297base_url = "https://generativelanguage.googleapis.com"
3298"#;
3299 let result = migrate_llm_to_providers(src).expect("migrate");
3300 assert!(
3301 result.output.contains("type = \"gemini\""),
3302 "{}",
3303 result.output
3304 );
3305 assert!(
3306 result.output.contains("model = \"gemini-2.0-flash\""),
3307 "{}",
3308 result.output
3309 );
3310 }
3311
3312 #[test]
3313 fn migrate_llm_compatible_copies_multiple_entries() {
3314 let src = r#"
3315[llm]
3316provider = "compatible"
3317
3318[[llm.compatible]]
3319name = "proxy-a"
3320base_url = "http://proxy-a:8080/v1"
3321model = "llama3"
3322max_tokens = 4096
3323
3324[[llm.compatible]]
3325name = "proxy-b"
3326base_url = "http://proxy-b:8080/v1"
3327model = "mistral"
3328max_tokens = 2048
3329"#;
3330 let result = migrate_llm_to_providers(src).expect("migrate");
3331 let count = result.output.matches("[[llm.providers]]").count();
3333 assert_eq!(
3334 count, 2,
3335 "expected 2 [[llm.providers]] blocks:\n{}",
3336 result.output
3337 );
3338 assert!(
3339 result.output.contains("name = \"proxy-a\""),
3340 "{}",
3341 result.output
3342 );
3343 assert!(
3344 result.output.contains("name = \"proxy-b\""),
3345 "{}",
3346 result.output
3347 );
3348 }
3349
3350 #[test]
3351 fn migrate_llm_mixed_format_errors() {
3352 let src = r#"
3354[llm]
3355provider = "ollama"
3356
3357[[llm.providers]]
3358type = "ollama"
3359"#;
3360 assert!(
3361 migrate_llm_to_providers(src).is_err(),
3362 "mixed format must return error"
3363 );
3364 }
3365
3366 #[test]
3369 fn stt_migration_no_stt_section_returns_unchanged() {
3370 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
3371 let result = migrate_stt_to_provider(src).unwrap();
3372 assert_eq!(result.changed_count, 0);
3373 assert_eq!(result.output, src);
3374 }
3375
3376 #[test]
3377 fn stt_migration_no_model_or_base_url_returns_unchanged() {
3378 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
3379 let result = migrate_stt_to_provider(src).unwrap();
3380 assert_eq!(result.changed_count, 0);
3381 }
3382
3383 #[test]
3384 fn stt_migration_moves_model_to_provider_entry() {
3385 let src = r#"
3386[llm]
3387
3388[[llm.providers]]
3389type = "openai"
3390name = "quality"
3391model = "gpt-5.4"
3392
3393[llm.stt]
3394provider = "quality"
3395model = "gpt-4o-mini-transcribe"
3396language = "en"
3397"#;
3398 let result = migrate_stt_to_provider(src).unwrap();
3399 assert_eq!(result.changed_count, 1);
3400 assert!(
3402 result.output.contains("stt_model"),
3403 "stt_model must be in output"
3404 );
3405 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3408 let stt = doc
3409 .get("llm")
3410 .and_then(toml_edit::Item::as_table)
3411 .and_then(|l| l.get("stt"))
3412 .and_then(toml_edit::Item::as_table)
3413 .unwrap();
3414 assert!(
3415 stt.get("model").is_none(),
3416 "model must be removed from [llm.stt]"
3417 );
3418 assert_eq!(
3419 stt.get("provider").and_then(toml_edit::Item::as_str),
3420 Some("quality")
3421 );
3422 }
3423
3424 #[test]
3425 fn stt_migration_creates_new_provider_when_no_match() {
3426 let src = r#"
3427[llm]
3428
3429[[llm.providers]]
3430type = "ollama"
3431name = "local"
3432model = "qwen3:8b"
3433
3434[llm.stt]
3435provider = "whisper"
3436model = "whisper-1"
3437base_url = "https://api.openai.com/v1"
3438language = "en"
3439"#;
3440 let result = migrate_stt_to_provider(src).unwrap();
3441 assert!(
3442 result.output.contains("openai-stt"),
3443 "new entry name must be openai-stt"
3444 );
3445 assert!(
3446 result.output.contains("stt_model"),
3447 "stt_model must be in output"
3448 );
3449 }
3450
3451 #[test]
3452 fn stt_migration_candle_whisper_creates_candle_entry() {
3453 let src = r#"
3454[llm]
3455
3456[llm.stt]
3457provider = "candle-whisper"
3458model = "openai/whisper-tiny"
3459language = "auto"
3460"#;
3461 let result = migrate_stt_to_provider(src).unwrap();
3462 assert!(
3463 result.output.contains("local-whisper"),
3464 "candle entry name must be local-whisper"
3465 );
3466 assert!(result.output.contains("candle"), "type must be candle");
3467 }
3468
3469 #[test]
3470 fn stt_migration_w2_assigns_explicit_name() {
3471 let src = r#"
3473[llm]
3474
3475[[llm.providers]]
3476type = "openai"
3477model = "gpt-5.4"
3478
3479[llm.stt]
3480provider = "openai"
3481model = "whisper-1"
3482language = "auto"
3483"#;
3484 let result = migrate_stt_to_provider(src).unwrap();
3485 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3486 let providers = doc
3487 .get("llm")
3488 .and_then(toml_edit::Item::as_table)
3489 .and_then(|l| l.get("providers"))
3490 .and_then(toml_edit::Item::as_array_of_tables)
3491 .unwrap();
3492 let entry = providers
3493 .iter()
3494 .find(|t| t.get("stt_model").is_some())
3495 .unwrap();
3496 assert!(
3498 entry.get("name").is_some(),
3499 "migrated entry must have explicit name"
3500 );
3501 }
3502
3503 #[test]
3504 fn stt_migration_removes_base_url_from_stt_table() {
3505 let src = r#"
3507[llm]
3508
3509[[llm.providers]]
3510type = "openai"
3511name = "quality"
3512model = "gpt-5.4"
3513
3514[llm.stt]
3515provider = "quality"
3516model = "whisper-1"
3517base_url = "https://api.openai.com/v1"
3518language = "en"
3519"#;
3520 let result = migrate_stt_to_provider(src).unwrap();
3521 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
3522 let stt = doc
3523 .get("llm")
3524 .and_then(toml_edit::Item::as_table)
3525 .and_then(|l| l.get("stt"))
3526 .and_then(toml_edit::Item::as_table)
3527 .unwrap();
3528 assert!(
3529 stt.get("model").is_none(),
3530 "model must be removed from [llm.stt]"
3531 );
3532 assert!(
3533 stt.get("base_url").is_none(),
3534 "base_url must be removed from [llm.stt]"
3535 );
3536 }
3537
3538 #[test]
3539 fn migrate_planner_model_to_provider_with_field() {
3540 let input = r#"
3541[orchestration]
3542enabled = true
3543planner_model = "gpt-4o"
3544max_tasks = 20
3545"#;
3546 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
3547 assert_eq!(result.changed_count, 1, "changed_count must be 1");
3548 assert!(
3549 !result.output.contains("planner_model = "),
3550 "planner_model key must be removed from output"
3551 );
3552 assert!(
3553 result.output.contains("# planner_provider"),
3554 "commented-out planner_provider entry must be present"
3555 );
3556 assert!(
3557 result.output.contains("gpt-4o"),
3558 "old value must appear in the comment"
3559 );
3560 assert!(
3561 result.output.contains("MIGRATED"),
3562 "comment must include MIGRATED marker"
3563 );
3564 }
3565
3566 #[test]
3567 fn migrate_planner_model_to_provider_no_op() {
3568 let input = r"
3569[orchestration]
3570enabled = true
3571max_tasks = 20
3572";
3573 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
3574 assert_eq!(
3575 result.changed_count, 0,
3576 "changed_count must be 0 when field is absent"
3577 );
3578 assert_eq!(
3579 result.output, input,
3580 "output must equal input when nothing to migrate"
3581 );
3582 }
3583
3584 #[test]
3585 fn migrate_error_invalid_structure_formats_correctly() {
3586 let err = MigrateError::InvalidStructure("test sentinel");
3591 assert!(
3592 matches!(err, MigrateError::InvalidStructure(_)),
3593 "variant must match"
3594 );
3595 let msg = err.to_string();
3596 assert!(
3597 msg.contains("invalid TOML structure"),
3598 "error message must mention 'invalid TOML structure', got: {msg}"
3599 );
3600 assert!(
3601 msg.contains("test sentinel"),
3602 "message must include reason: {msg}"
3603 );
3604 }
3605
3606 #[test]
3609 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
3610 let src = r#"
3611[mcp]
3612allowed_commands = ["npx"]
3613
3614[[mcp.servers]]
3615id = "srv-a"
3616command = "npx"
3617args = ["-y", "some-mcp"]
3618
3619[[mcp.servers]]
3620id = "srv-b"
3621command = "npx"
3622args = ["-y", "other-mcp"]
3623"#;
3624 let result = migrate_mcp_trust_levels(src).expect("migrate");
3625 assert_eq!(
3626 result.changed_count, 2,
3627 "both entries must get trust_level added"
3628 );
3629 assert!(
3630 result
3631 .sections_changed
3632 .contains(&"mcp.servers.trust_level".to_owned()),
3633 "sections_changed must report mcp.servers.trust_level"
3634 );
3635 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
3637 assert_eq!(
3638 occurrences, 2,
3639 "each entry must have trust_level = \"trusted\""
3640 );
3641 }
3642
3643 #[test]
3644 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
3645 let src = r#"
3646[[mcp.servers]]
3647id = "srv-a"
3648command = "npx"
3649trust_level = "sandboxed"
3650tool_allowlist = ["read_file"]
3651
3652[[mcp.servers]]
3653id = "srv-b"
3654command = "npx"
3655"#;
3656 let result = migrate_mcp_trust_levels(src).expect("migrate");
3657 assert_eq!(
3659 result.changed_count, 1,
3660 "only entry without trust_level gets updated"
3661 );
3662 assert!(
3664 result.output.contains("trust_level = \"sandboxed\""),
3665 "existing trust_level must not be overwritten"
3666 );
3667 assert!(
3669 result.output.contains("trust_level = \"trusted\""),
3670 "entry without trust_level must get trusted"
3671 );
3672 }
3673
3674 #[test]
3675 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
3676 let src = "[agent]\nname = \"Zeph\"\n";
3677 let result = migrate_mcp_trust_levels(src).expect("migrate");
3678 assert_eq!(result.changed_count, 0);
3679 assert!(result.sections_changed.is_empty());
3680 assert_eq!(result.output, src);
3681 }
3682
3683 #[test]
3684 fn migrate_mcp_trust_levels_no_servers_is_noop() {
3685 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
3686 let result = migrate_mcp_trust_levels(src).expect("migrate");
3687 assert_eq!(result.changed_count, 0);
3688 assert!(result.sections_changed.is_empty());
3689 assert_eq!(result.output, src);
3690 }
3691
3692 #[test]
3693 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
3694 let src = r#"
3695[[mcp.servers]]
3696id = "srv-a"
3697trust_level = "trusted"
3698
3699[[mcp.servers]]
3700id = "srv-b"
3701trust_level = "untrusted"
3702"#;
3703 let result = migrate_mcp_trust_levels(src).expect("migrate");
3704 assert_eq!(result.changed_count, 0);
3705 assert!(result.sections_changed.is_empty());
3706 }
3707
3708 #[test]
3709 fn migrate_database_url_adds_comment_when_absent() {
3710 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
3711 let result = migrate_database_url(src).expect("migrate");
3712 assert_eq!(result.changed_count, 1);
3713 assert!(
3714 result
3715 .sections_changed
3716 .contains(&"memory.database_url".to_owned())
3717 );
3718 assert!(result.output.contains("# database_url = \"\""));
3719 }
3720
3721 #[test]
3722 fn migrate_database_url_is_noop_when_present() {
3723 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
3724 let result = migrate_database_url(src).expect("migrate");
3725 assert_eq!(result.changed_count, 0);
3726 assert!(result.sections_changed.is_empty());
3727 assert_eq!(result.output, src);
3728 }
3729
3730 #[test]
3731 fn migrate_database_url_creates_memory_section_when_absent() {
3732 let src = "[agent]\nname = \"Zeph\"\n";
3733 let result = migrate_database_url(src).expect("migrate");
3734 assert_eq!(result.changed_count, 1);
3735 assert!(result.output.contains("# database_url = \"\""));
3736 }
3737
3738 #[test]
3741 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
3742 let src = "[agent]\nname = \"Zeph\"\n";
3743 let result = migrate_agent_budget_hint(src).expect("migrate");
3744 assert_eq!(result.changed_count, 1);
3745 assert!(result.output.contains("budget_hint_enabled"));
3746 assert!(
3747 result
3748 .sections_changed
3749 .contains(&"agent.budget_hint_enabled".to_owned())
3750 );
3751 }
3752
3753 #[test]
3754 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
3755 let src = "[llm]\nmodel = \"gpt-4o\"\n";
3756 let result = migrate_agent_budget_hint(src).expect("migrate");
3757 assert_eq!(result.changed_count, 0);
3758 assert_eq!(result.output, src);
3759 }
3760
3761 #[test]
3762 fn migrate_agent_budget_hint_already_present_is_noop() {
3763 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
3764 let result = migrate_agent_budget_hint(src).expect("migrate");
3765 assert_eq!(result.changed_count, 0);
3766 assert_eq!(result.output, src);
3767 }
3768
3769 #[test]
3770 fn migrate_telemetry_config_empty_config_appends_comment_block() {
3771 let src = "[agent]\nname = \"Zeph\"\n";
3772 let result = migrate_telemetry_config(src).expect("migrate");
3773 assert_eq!(result.changed_count, 1);
3774 assert_eq!(result.sections_changed, vec!["telemetry"]);
3775 assert!(
3776 result.output.contains("# [telemetry]"),
3777 "expected commented-out [telemetry] block in output"
3778 );
3779 assert!(
3780 result.output.contains("enabled = false"),
3781 "expected enabled = false in telemetry comment block"
3782 );
3783 }
3784
3785 #[test]
3786 fn migrate_telemetry_config_existing_section_is_noop() {
3787 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
3788 let result = migrate_telemetry_config(src).expect("migrate");
3789 assert_eq!(result.changed_count, 0);
3790 assert_eq!(result.output, src);
3791 }
3792
3793 #[test]
3794 fn migrate_telemetry_config_existing_comment_is_noop() {
3795 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
3797 let result = migrate_telemetry_config(src).expect("migrate");
3798 assert_eq!(result.changed_count, 0);
3799 assert_eq!(result.output, src);
3800 }
3801
3802 #[test]
3805 fn migrate_otel_filter_already_present_is_noop() {
3806 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
3808 let result = migrate_otel_filter(src).expect("migrate");
3809 assert_eq!(result.changed_count, 0);
3810 assert_eq!(result.output, src);
3811 }
3812
3813 #[test]
3814 fn migrate_otel_filter_commented_key_is_noop() {
3815 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
3817 let result = migrate_otel_filter(src).expect("migrate");
3818 assert_eq!(result.changed_count, 0);
3819 assert_eq!(result.output, src);
3820 }
3821
3822 #[test]
3823 fn migrate_otel_filter_no_telemetry_section_is_noop() {
3824 let src = "[agent]\nname = \"Zeph\"\n";
3826 let result = migrate_otel_filter(src).expect("migrate");
3827 assert_eq!(result.changed_count, 0);
3828 assert_eq!(result.output, src);
3829 assert!(!result.output.contains("otel_filter"));
3830 }
3831
3832 #[test]
3833 fn migrate_otel_filter_injects_within_telemetry_section() {
3834 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
3835 let result = migrate_otel_filter(src).expect("migrate");
3836 assert_eq!(result.changed_count, 1);
3837 assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
3838 assert!(
3839 result.output.contains("otel_filter"),
3840 "otel_filter comment must appear"
3841 );
3842 let otel_pos = result
3844 .output
3845 .find("otel_filter")
3846 .expect("otel_filter present");
3847 let agent_pos = result.output.find("[agent]").expect("[agent] present");
3848 assert!(
3849 otel_pos < agent_pos,
3850 "otel_filter comment should appear before [agent] section"
3851 );
3852 }
3853
3854 #[test]
3855 fn sandbox_migration_adds_commented_section_when_absent() {
3856 let src = "[agent]\nname = \"Z\"\n";
3857 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3858 assert_eq!(result.changed_count, 1);
3859 assert!(result.output.contains("# [tools.sandbox]"));
3860 assert!(result.output.contains("# profile = \"workspace\""));
3861 }
3862
3863 #[test]
3864 fn sandbox_migration_noop_when_section_present() {
3865 let src = "[tools.sandbox]\nenabled = true\n";
3866 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3867 assert_eq!(result.changed_count, 0);
3868 }
3869
3870 #[test]
3871 fn sandbox_migration_noop_when_dotted_key_present() {
3872 let src = "[tools]\nsandbox = { enabled = true }\n";
3873 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3874 assert_eq!(result.changed_count, 0);
3875 }
3876
3877 #[test]
3878 fn sandbox_migration_false_positive_comment_does_not_block() {
3879 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
3881 let result = migrate_sandbox_config(src).expect("migrate sandbox");
3882 assert_eq!(result.changed_count, 1);
3883 }
3884
3885 #[test]
3886 fn embedded_default_mentions_tools_sandbox() {
3887 let default_src = include_str!("../config/default.toml");
3888 assert!(
3889 default_src.contains("tools.sandbox"),
3890 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
3891 );
3892 }
3893
3894 #[test]
3895 fn sandbox_migration_idempotent_on_own_output() {
3896 let base = "[agent]\nmodel = \"test\"\n";
3897 let first = migrate_sandbox_config(base).unwrap();
3898 assert_eq!(first.changed_count, 1);
3899 let second = migrate_sandbox_config(&first.output).unwrap();
3900 assert_eq!(second.changed_count, 0, "second run must not double-append");
3901 assert_eq!(second.output, first.output);
3902 }
3903
3904 #[test]
3905 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
3906 let base = "[agent]\nname = \"Zeph\"\n";
3907 let first = migrate_agent_budget_hint(base).unwrap();
3908 assert_eq!(first.changed_count, 1);
3909 let second = migrate_agent_budget_hint(&first.output).unwrap();
3910 assert_eq!(second.changed_count, 0, "second run must not double-append");
3911 assert_eq!(second.output, first.output);
3912 }
3913
3914 #[test]
3915 fn migrate_forgetting_config_idempotent_on_commented_output() {
3916 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3917 let first = migrate_forgetting_config(base).unwrap();
3918 assert_eq!(first.changed_count, 1);
3919 let second = migrate_forgetting_config(&first.output).unwrap();
3920 assert_eq!(second.changed_count, 0, "second run must not double-append");
3921 assert_eq!(second.output, first.output);
3922 }
3923
3924 #[test]
3925 fn migrate_microcompact_config_idempotent_on_commented_output() {
3926 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3927 let first = migrate_microcompact_config(base).unwrap();
3928 assert_eq!(first.changed_count, 1);
3929 let second = migrate_microcompact_config(&first.output).unwrap();
3930 assert_eq!(second.changed_count, 0, "second run must not double-append");
3931 assert_eq!(second.output, first.output);
3932 }
3933
3934 #[test]
3935 fn migrate_autodream_config_idempotent_on_commented_output() {
3936 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3937 let first = migrate_autodream_config(base).unwrap();
3938 assert_eq!(first.changed_count, 1);
3939 let second = migrate_autodream_config(&first.output).unwrap();
3940 assert_eq!(second.changed_count, 0, "second run must not double-append");
3941 assert_eq!(second.output, first.output);
3942 }
3943
3944 #[test]
3945 fn migrate_compression_predictor_strips_active_section() {
3946 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
3947 let result = migrate_compression_predictor_config(base).unwrap();
3948 assert!(!result.output.contains("[memory.compression.predictor]"));
3949 assert!(!result.output.contains("min_samples"));
3950 assert!(result.output.contains("[memory.other]"));
3951 assert_eq!(result.changed_count, 1);
3952 }
3953
3954 #[test]
3955 fn migrate_compression_predictor_strips_commented_section() {
3956 let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
3957 let result = migrate_compression_predictor_config(base).unwrap();
3958 assert!(!result.output.contains("compression.predictor"));
3959 assert!(result.output.contains("[memory.other]"));
3960 }
3961
3962 #[test]
3963 fn migrate_compression_predictor_idempotent() {
3964 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
3965 let first = migrate_compression_predictor_config(base).unwrap();
3966 let second = migrate_compression_predictor_config(&first.output).unwrap();
3967 assert_eq!(second.output, first.output);
3968 assert_eq!(second.changed_count, 0);
3969 }
3970
3971 #[test]
3972 fn migrate_compression_predictor_noop_when_absent() {
3973 let base = "[memory]\ndb_path = \"test\"\n";
3974 let result = migrate_compression_predictor_config(base).unwrap();
3975 assert_eq!(result.output, base);
3976 assert_eq!(result.changed_count, 0);
3977 }
3978
3979 #[test]
3980 fn migrate_database_url_idempotent_on_commented_output() {
3981 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
3982 let first = migrate_database_url(base).unwrap();
3983 assert_eq!(first.changed_count, 1);
3984 let second = migrate_database_url(&first.output).unwrap();
3985 assert_eq!(second.changed_count, 0, "second run must not double-append");
3986 assert_eq!(second.output, first.output);
3987 }
3988
3989 #[test]
3990 fn migrate_shell_transactional_idempotent_on_commented_output() {
3991 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
3992 let first = migrate_shell_transactional(base).unwrap();
3993 assert_eq!(first.changed_count, 1);
3994 let second = migrate_shell_transactional(&first.output).unwrap();
3995 assert_eq!(second.changed_count, 0, "second run must not double-append");
3996 assert_eq!(second.output, first.output);
3997 }
3998
3999 #[test]
4000 fn migrate_otel_filter_idempotent_on_commented_output() {
4001 let base = "[telemetry]\nenabled = true\n";
4002 let first = migrate_otel_filter(base).unwrap();
4003 assert_eq!(first.changed_count, 1);
4004 let second = migrate_otel_filter(&first.output).unwrap();
4005 assert_eq!(second.changed_count, 0, "second run must not double-append");
4006 assert_eq!(second.output, first.output);
4007 }
4008
4009 #[test]
4010 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4011 let migrator = ConfigMigrator::new();
4012 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4013 let result = migrator.migrate(src).expect("migrate");
4014 let sec_body_start = result
4015 .output
4016 .find("[security.content_isolation]")
4017 .unwrap_or(0);
4018 let sec_body = &result.output[sec_body_start..];
4019 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4020 let sec_slice = &sec_body[..next_header];
4021 assert!(
4022 sec_slice.contains("# enabled"),
4023 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4024 );
4025 }
4026
4027 #[test]
4028 fn config_migrator_idempotent_on_realistic_config() {
4029 let base = r#"
4030[agent]
4031name = "Zeph"
4032
4033[memory]
4034db_path = "~/.zeph/memory.db"
4035soft_compaction_threshold = 0.6
4036
4037[index]
4038max_chunks = 12
4039
4040[tools]
4041[tools.shell]
4042allow_list = []
4043
4044[telemetry]
4045enabled = false
4046
4047[security]
4048[security.content_isolation]
4049enabled = true
4050"#;
4051 let migrator = ConfigMigrator::new();
4052 let first = migrator.migrate(base).expect("first migrate");
4053 let second = migrator.migrate(&first.output).expect("second migrate");
4054 assert_eq!(
4055 second.changed_count, 0,
4056 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4057 second.changed_count
4058 );
4059 assert_eq!(
4060 first.output, second.output,
4061 "output must be identical on second run"
4062 );
4063 for line in first.output.lines() {
4064 if line.starts_with('[') && !line.starts_with("[[") {
4065 assert!(
4066 !line.contains('#'),
4067 "section header must not have inline comment: {line:?}"
4068 );
4069 }
4070 }
4071 }
4072
4073 #[test]
4074 fn migrate_claude_prompt_cache_ttl_1h_survives() {
4075 let src = r#"
4076[llm]
4077provider = "claude"
4078
4079[llm.cloud]
4080model = "claude-sonnet-4-6"
4081prompt_cache_ttl = "1h"
4082"#;
4083 let result = migrate_llm_to_providers(src).expect("migrate");
4084 assert!(
4085 result.output.contains("prompt_cache_ttl = \"1h\""),
4086 "1h TTL must be preserved in migrated output:\n{}",
4087 result.output
4088 );
4089 }
4090
4091 #[test]
4092 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4093 let src = r#"
4094[llm]
4095provider = "claude"
4096
4097[llm.cloud]
4098model = "claude-sonnet-4-6"
4099prompt_cache_ttl = "ephemeral"
4100"#;
4101 let result = migrate_llm_to_providers(src).expect("migrate");
4102 assert!(
4103 !result.output.contains("prompt_cache_ttl"),
4104 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4105 result.output
4106 );
4107 }
4108
4109 #[test]
4110 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4111 let src = r#"
4112[[llm.providers]]
4113type = "claude"
4114model = "claude-sonnet-4-6"
4115prompt_cache_ttl = "1h"
4116"#;
4117 let migrator = ConfigMigrator::new();
4118 let first = migrator.migrate(src).expect("first migrate");
4119 let second = migrator.migrate(&first.output).expect("second migrate");
4120 assert_eq!(
4121 first.output, second.output,
4122 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4123 );
4124 }
4125
4126 #[test]
4129 fn migrate_session_recap_adds_block_when_absent() {
4130 let src = "[agent]\nname = \"Zeph\"\n";
4131 let result = migrate_session_recap_config(src).expect("migrate");
4132 assert_eq!(result.changed_count, 1);
4133 assert!(
4134 result
4135 .sections_changed
4136 .contains(&"session.recap".to_owned())
4137 );
4138 assert!(result.output.contains("# [session.recap]"));
4139 assert!(result.output.contains("on_resume = true"));
4140 }
4141
4142 #[test]
4143 fn migrate_session_recap_idempotent_on_commented_block() {
4144 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4145 let result = migrate_session_recap_config(src).expect("migrate");
4146 assert_eq!(result.changed_count, 0);
4147 assert_eq!(result.output, src);
4148 }
4149
4150 #[test]
4151 fn migrate_session_recap_idempotent_on_active_section() {
4152 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4153 let result = migrate_session_recap_config(src).expect("migrate");
4154 assert_eq!(result.changed_count, 0);
4155 assert_eq!(result.output, src);
4156 }
4157
4158 #[test]
4161 fn migrate_mcp_elicitation_adds_keys_when_absent() {
4162 let src = "[mcp]\nallowed_commands = []\n";
4163 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4164 assert_eq!(result.changed_count, 1);
4165 assert!(
4166 result
4167 .sections_changed
4168 .contains(&"mcp.elicitation".to_owned())
4169 );
4170 assert!(result.output.contains("# elicitation_enabled = false"));
4171 assert!(result.output.contains("# elicitation_timeout = 120"));
4172 }
4173
4174 #[test]
4175 fn migrate_mcp_elicitation_idempotent_when_key_present() {
4176 let src = "[mcp]\nelicitation_enabled = true\n";
4177 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4178 assert_eq!(result.changed_count, 0);
4179 assert_eq!(result.output, src);
4180 }
4181
4182 #[test]
4183 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4184 let src = "[agent]\nname = \"Zeph\"\n";
4185 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4186 assert_eq!(result.changed_count, 0);
4187 assert_eq!(result.output, src);
4188 }
4189
4190 #[test]
4191 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4192 let src = "[mcp]";
4194 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4195 assert_eq!(result.changed_count, 0);
4196 assert_eq!(result.output, src);
4197 }
4198
4199 #[test]
4202 fn migrate_quality_adds_block_when_absent() {
4203 let src = "[agent]\nname = \"Zeph\"\n";
4204 let result = migrate_quality_config(src).expect("migrate");
4205 assert_eq!(result.changed_count, 1);
4206 assert!(result.sections_changed.contains(&"quality".to_owned()));
4207 assert!(result.output.contains("# [quality]"));
4208 assert!(result.output.contains("self_check = false"));
4209 assert!(result.output.contains("trigger = \"has_retrieval\""));
4210 }
4211
4212 #[test]
4213 fn migrate_quality_idempotent_on_commented_block() {
4214 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4215 let result = migrate_quality_config(src).expect("migrate");
4216 assert_eq!(result.changed_count, 0);
4217 assert_eq!(result.output, src);
4218 }
4219
4220 #[test]
4221 fn migrate_quality_idempotent_on_active_section() {
4222 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4223 let result = migrate_quality_config(src).expect("migrate");
4224 assert_eq!(result.changed_count, 0);
4225 assert_eq!(result.output, src);
4226 }
4227
4228 #[test]
4231 fn migrate_acp_subagents_adds_block_when_absent() {
4232 let src = "[agent]\nname = \"Zeph\"\n";
4233 let result = migrate_acp_subagents_config(src).expect("migrate");
4234 assert_eq!(result.changed_count, 1);
4235 assert!(
4236 result
4237 .sections_changed
4238 .contains(&"acp.subagents".to_owned())
4239 );
4240 assert!(result.output.contains("# [acp.subagents]"));
4241 assert!(result.output.contains("enabled = false"));
4242 }
4243
4244 #[test]
4245 fn migrate_acp_subagents_idempotent_on_existing_block() {
4246 let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4247 let result = migrate_acp_subagents_config(src).expect("migrate");
4248 assert_eq!(result.changed_count, 0);
4249 assert_eq!(result.output, src);
4250 }
4251
4252 #[test]
4255 fn migrate_hooks_permission_denied_adds_block_when_absent() {
4256 let src = "[agent]\nname = \"Zeph\"\n";
4257 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4258 assert_eq!(result.changed_count, 1);
4259 assert!(
4260 result
4261 .sections_changed
4262 .contains(&"hooks.permission_denied".to_owned())
4263 );
4264 assert!(result.output.contains("# [[hooks.permission_denied]]"));
4265 assert!(result.output.contains("ZEPH_TOOL"));
4266 }
4267
4268 #[test]
4269 fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4270 let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4271 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4272 assert_eq!(result.changed_count, 0);
4273 assert_eq!(result.output, src);
4274 }
4275
4276 #[test]
4279 fn migrate_memory_graph_adds_block_when_absent() {
4280 let src = "[agent]\nname = \"Zeph\"\n";
4281 let result = migrate_memory_graph_config(src).expect("migrate");
4282 assert_eq!(result.changed_count, 1);
4283 assert!(
4284 result
4285 .sections_changed
4286 .contains(&"memory.graph.retrieval".to_owned())
4287 );
4288 assert!(result.output.contains("retrieval_strategy"));
4289 assert!(result.output.contains("# [memory.graph.beam_search]"));
4290 }
4291
4292 #[test]
4293 fn migrate_memory_graph_idempotent_on_existing_block() {
4294 let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4295 let result = migrate_memory_graph_config(src).expect("migrate");
4296 assert_eq!(result.changed_count, 0);
4297 assert_eq!(result.output, src);
4298 }
4299
4300 #[test]
4303 fn migrate_scheduler_daemon_adds_block_when_absent() {
4304 let src = "[agent]\nname = \"Zeph\"\n";
4305 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4306 assert_eq!(result.changed_count, 1);
4307 assert!(
4308 result
4309 .sections_changed
4310 .contains(&"scheduler.daemon".to_owned())
4311 );
4312 assert!(result.output.contains("# [scheduler.daemon]"));
4313 assert!(result.output.contains("pid_file"));
4314 assert!(result.output.contains("tick_secs = 60"));
4315 assert!(result.output.contains("shutdown_grace_secs = 30"));
4316 assert!(result.output.contains("catch_up = true"));
4317 }
4318
4319 #[test]
4320 fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4321 let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4322 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4323 assert_eq!(result.changed_count, 0);
4324 assert_eq!(result.output, src);
4325 }
4326
4327 #[test]
4330 fn migrate_memory_retrieval_adds_block_when_absent() {
4331 let src = "[agent]\nname = \"Zeph\"\n";
4332 let result = migrate_memory_retrieval_config(src).expect("migrate");
4333 assert_eq!(result.changed_count, 1);
4334 assert!(
4335 result
4336 .sections_changed
4337 .contains(&"memory.retrieval".to_owned())
4338 );
4339 assert!(result.output.contains("# [memory.retrieval]"));
4340 assert!(result.output.contains("depth = 0"));
4341 assert!(result.output.contains("context_format"));
4342 }
4343
4344 #[test]
4345 fn migrate_memory_retrieval_idempotent_on_active_section() {
4346 let src = "[memory.retrieval]\ndepth = 40\n";
4347 let result = migrate_memory_retrieval_config(src).expect("migrate");
4348 assert_eq!(result.changed_count, 0);
4349 assert_eq!(result.output, src);
4350 }
4351
4352 #[test]
4353 fn migrate_memory_retrieval_idempotent_on_commented_section() {
4354 let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
4355 let result = migrate_memory_retrieval_config(src).expect("migrate");
4356 assert_eq!(result.changed_count, 0);
4357 assert_eq!(result.output, src);
4358 }
4359
4360 #[test]
4363 fn migrate_adds_pr4_acp_keys_commented() {
4364 let migrator = ConfigMigrator::new();
4365 let input = include_str!("../tests/fixtures/acp_pr4_v0_19.toml");
4366 let out = migrator.migrate(input).expect("migrate");
4367 assert!(
4368 out.output.contains("# additional_directories = []"),
4369 "expected commented additional_directories; got:\n{}",
4370 out.output
4371 );
4372 assert!(
4373 out.output.contains("# auth_methods = [\"agent\"]"),
4374 "expected commented auth_methods; got:\n{}",
4375 out.output
4376 );
4377 assert!(
4378 out.output.contains("# message_ids_enabled = true"),
4379 "expected commented message_ids_enabled; got:\n{}",
4380 out.output
4381 );
4382 }
4383
4384 #[test]
4387 fn migrate_memory_reasoning_adds_block_when_absent() {
4388 let input = "[agent]\nmodel = \"gpt-4o\"\n";
4389 let result = migrate_memory_reasoning_config(input).unwrap();
4390 assert_eq!(result.changed_count, 1);
4391 assert!(
4392 result
4393 .sections_changed
4394 .contains(&"memory.reasoning".to_owned())
4395 );
4396 assert!(result.output.contains("# [memory.reasoning]"));
4397 assert!(result.output.contains("extraction_timeout_secs = 30"));
4398 assert!(result.output.contains("max_message_chars = 2000"));
4399 }
4400
4401 #[test]
4402 fn migrate_memory_reasoning_idempotent_on_existing_block() {
4403 let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
4404 let result = migrate_memory_reasoning_config(input).unwrap();
4405 assert_eq!(result.changed_count, 0);
4406 assert!(result.sections_changed.is_empty());
4407 assert_eq!(result.output, input);
4408 }
4409
4410 #[test]
4413 fn migrate_hooks_turn_complete_adds_block_when_absent() {
4414 let input = "[agent]\nmodel = \"gpt-4o\"\n";
4415 let result = migrate_hooks_turn_complete_config(input).unwrap();
4416 assert_eq!(result.changed_count, 1);
4417 assert!(
4418 result
4419 .sections_changed
4420 .contains(&"hooks.turn_complete".to_owned())
4421 );
4422 assert!(result.output.contains("# [[hooks.turn_complete]]"));
4423 assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
4424 assert!(result.output.contains("timeout_secs = 3"));
4425 }
4426
4427 #[test]
4428 fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
4429 let input =
4430 "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
4431 let result = migrate_hooks_turn_complete_config(input).unwrap();
4432 assert_eq!(result.changed_count, 0);
4433 assert!(result.sections_changed.is_empty());
4434 assert_eq!(result.output, input);
4435 }
4436
4437 #[test]
4441 fn migrate_focus_auto_consolidate_injects_inside_section() {
4442 let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
4443 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4444 assert_eq!(result.changed_count, 1);
4445 let comment_pos = result
4446 .output
4447 .find("auto_consolidate_min_window")
4448 .expect("comment must be present");
4449 let other_pos = result
4450 .output
4451 .find("[other]")
4452 .expect("[other] must be present");
4453 assert!(
4454 comment_pos < other_pos,
4455 "auto_consolidate_min_window comment must appear before [other] section"
4456 );
4457 }
4458
4459 #[test]
4460 fn migrate_focus_auto_consolidate_idempotent() {
4461 let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
4462 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4463 assert_eq!(result.changed_count, 0);
4464 assert_eq!(result.output, input);
4465 }
4466
4467 #[test]
4468 fn migrate_focus_auto_consolidate_noop_when_section_absent() {
4469 let input = "[agent]\nname = \"zeph\"\n";
4470 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4471 assert_eq!(result.changed_count, 0);
4472 assert_eq!(result.output, input);
4473 }
4474
4475 #[test]
4476 fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
4477 let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
4478 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
4479 assert_eq!(result.changed_count, 0);
4480 assert_eq!(result.output, input);
4481 }
4482}