1use regex::Regex;
11use toml_edit::{Array, DocumentMut, Item, Table, Value};
12
13#[must_use]
25pub fn section_header_present(src: &str, name: &str) -> bool {
26 let escaped = regex::escape(name);
28 let pattern = format!(r"^\[{escaped}(?:\.[^\]]+)?\](?:\s*#.*)?$");
31 let re = Regex::new(&pattern).expect("regex::escape always produces a valid pattern");
32 src.lines()
33 .filter(|line| !line.trim_start().starts_with('#'))
34 .any(|line| re.is_match(line.trim()))
35}
36
37static CANONICAL_ORDER: &[&str] = &[
39 "agent",
40 "llm",
41 "skills",
42 "memory",
43 "index",
44 "tools",
45 "mcp",
46 "telegram",
47 "discord",
48 "slack",
49 "a2a",
50 "acp",
51 "gateway",
52 "metrics",
53 "daemon",
54 "scheduler",
55 "orchestration",
56 "classifiers",
57 "security",
58 "vault",
59 "timeouts",
60 "cost",
61 "debug",
62 "logging",
63 "notifications",
64 "tui",
65 "agents",
66 "experiments",
67 "lsp",
68 "telemetry",
69 "session",
70];
71
72#[derive(Debug, thiserror::Error)]
74#[non_exhaustive]
75pub enum MigrateError {
76 #[error("failed to parse input config: {0}")]
78 Parse(#[from] toml_edit::TomlError),
79 #[error("failed to parse reference config: {0}")]
81 Reference(toml_edit::TomlError),
82 #[error("migration failed: invalid TOML structure — {0}")]
85 InvalidStructure(&'static str),
86}
87
88#[derive(Debug)]
90pub struct MigrationResult {
91 pub output: String,
93 pub changed_count: usize,
95 pub sections_changed: Vec<String>,
97}
98
99pub struct ConfigMigrator {
104 reference_src: &'static str,
105}
106
107impl Default for ConfigMigrator {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl ConfigMigrator {
114 #[must_use]
116 pub fn new() -> Self {
117 Self {
118 reference_src: include_str!("../../config/default.toml"),
119 }
120 }
121
122 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
134 let reference_doc = self
135 .reference_src
136 .parse::<DocumentMut>()
137 .map_err(MigrateError::Reference)?;
138 let mut user_doc = user_toml.parse::<DocumentMut>()?;
139
140 let mut changed_count = 0usize;
141 let mut sections_changed: Vec<String> = Vec::new();
142 let mut pending_comments: Vec<(String, String)> = Vec::new();
145
146 for (key, ref_item) in reference_doc.as_table() {
148 if ref_item.is_table() {
149 let ref_table = ref_item.as_table().expect("is_table checked above");
150 if user_doc.contains_key(key) {
151 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
153 let (n, comments) =
154 merge_table_commented(user_table, ref_table, key, user_toml);
155 changed_count += n;
156 pending_comments.extend(comments);
157 }
158 } else {
159 if user_toml.contains(&format!("# [{key}]")) {
162 continue;
163 }
164 let commented = commented_table_block(key, ref_table);
165 if !commented.is_empty() {
166 sections_changed.push(key.to_owned());
167 }
168 changed_count += 1;
169 }
170 } else {
171 if !user_doc.contains_key(key) {
173 let raw = format_commented_item(key, ref_item);
174 if !raw.is_empty() {
175 sections_changed.push(format!("__scalar__{key}"));
176 changed_count += 1;
177 }
178 }
179 }
180 }
181
182 let user_str = user_doc.to_string();
184
185 let mut output = user_str;
188 for (section_key, comment_line) in &pending_comments {
189 if !section_body(&output, section_key).contains(comment_line.trim()) {
190 output = insert_after_section(&output, section_key, comment_line);
191 }
192 }
193
194 for key in §ions_changed {
196 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
197 if let Some(ref_item) = reference_doc.get(scalar_key) {
198 let raw = format_commented_item(scalar_key, ref_item);
199 if !raw.is_empty() {
200 output.push('\n');
201 output.push_str(&raw);
202 output.push('\n');
203 }
204 }
205 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
206 {
207 let block = commented_table_block(key, ref_table);
208 if !block.is_empty() {
209 output.push('\n');
210 output.push_str(&block);
211 }
212 }
213 }
214
215 output = reorder_sections(&output, CANONICAL_ORDER);
217
218 let sections_changed_clean: Vec<String> = sections_changed
220 .into_iter()
221 .filter(|k| !k.starts_with("__scalar__"))
222 .collect();
223
224 Ok(MigrationResult {
225 output,
226 changed_count,
227 sections_changed: sections_changed_clean,
228 })
229 }
230}
231
232fn merge_table_commented(
238 user_table: &mut Table,
239 ref_table: &Table,
240 section_key: &str,
241 user_toml: &str,
242) -> (usize, Vec<(String, String)>) {
243 let mut count = 0usize;
244 let mut comments: Vec<(String, String)> = Vec::new();
245 for (key, ref_item) in ref_table {
246 if ref_item.is_table() {
247 if user_table.contains_key(key) {
248 let pair = (
249 user_table.get_mut(key).and_then(Item::as_table_mut),
250 ref_item.as_table(),
251 );
252 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
253 let sub_key = format!("{section_key}.{key}");
254 let (n, c) =
255 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
256 count += n;
257 comments.extend(c);
258 }
259 } else if let Some(ref_sub_table) = ref_item.as_table() {
260 let dotted = format!("{section_key}.{key}");
262 let marker = format!("# [{dotted}]");
263 if !user_toml.contains(&marker) {
264 let block = commented_table_block(&dotted, ref_sub_table);
265 if !block.is_empty() {
266 comments.push((section_key.to_owned(), format!("\n{block}")));
267 count += 1;
268 }
269 }
270 }
271 } else if ref_item.is_array_of_tables() {
272 } else {
274 if !user_table.contains_key(key) {
276 let raw_value = ref_item
277 .as_value()
278 .map(value_to_toml_string)
279 .unwrap_or_default();
280 if !raw_value.is_empty() {
281 let comment_line = format!("# {key} = {raw_value}\n");
282 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
285 comments.push((section_key.to_owned(), comment_line));
286 count += 1;
287 }
288 }
289 }
290 }
291 }
292 (count, comments)
293}
294
295fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
301 let header = format!("[{section}]");
302 let Some(section_start) = doc.find(&header) else {
303 return "";
304 };
305 let body_start = section_start + header.len();
306 let body_end = doc[body_start..]
307 .find("\n[")
308 .map_or(doc.len(), |r| body_start + r);
309 &doc[body_start..body_end]
310}
311
312fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
318 let header = format!("[{section_name}]");
319 let Some(section_start) = raw.find(&header) else {
320 return format!("{raw}{text}");
321 };
322 let search_from = section_start + header.len();
324 let insert_pos = raw[search_from..]
326 .find("\n[")
327 .map_or(raw.len(), |rel| search_from + rel + 1);
328 let mut out = String::with_capacity(raw.len() + text.len());
329 out.push_str(&raw[..insert_pos]);
330 out.push_str(text);
331 out.push_str(&raw[insert_pos..]);
332 out
333}
334
335fn format_commented_item(key: &str, item: &Item) -> String {
337 if let Some(val) = item.as_value() {
338 let raw = value_to_toml_string(val);
339 if !raw.is_empty() {
340 return format!("# {key} = {raw}\n");
341 }
342 }
343 String::new()
344}
345
346fn commented_table_block(section_name: &str, table: &Table) -> String {
351 use std::fmt::Write as _;
352
353 let mut lines = format!("# [{section_name}]\n");
354
355 for (key, item) in table {
356 if item.is_table() {
357 if let Some(sub_table) = item.as_table() {
358 let sub_name = format!("{section_name}.{key}");
359 let sub_block = commented_table_block(&sub_name, sub_table);
360 if !sub_block.is_empty() {
361 lines.push('\n');
362 lines.push_str(&sub_block);
363 }
364 }
365 } else if item.is_array_of_tables() {
366 } else if let Some(val) = item.as_value() {
368 let raw = value_to_toml_string(val);
369 if !raw.is_empty() {
370 let _ = writeln!(lines, "# {key} = {raw}");
371 }
372 }
373 }
374
375 if lines.trim() == format!("[{section_name}]") {
377 return String::new();
378 }
379 lines
380}
381
382fn value_to_toml_string(val: &Value) -> String {
384 match val {
385 Value::String(s) => {
386 let inner = s.value();
387 format!("\"{inner}\"")
388 }
389 Value::Integer(i) => i.value().to_string(),
390 Value::Float(f) => {
391 let v = f.value();
392 if v.fract() == 0.0 {
394 format!("{v:.1}")
395 } else {
396 format!("{v}")
397 }
398 }
399 Value::Boolean(b) => b.value().to_string(),
400 Value::Array(arr) => format_array(arr),
401 Value::InlineTable(t) => {
402 let pairs: Vec<String> = t
403 .iter()
404 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
405 .collect();
406 format!("{{ {} }}", pairs.join(", "))
407 }
408 Value::Datetime(dt) => dt.value().to_string(),
409 }
410}
411
412fn format_array(arr: &Array) -> String {
413 if arr.is_empty() {
414 return "[]".to_owned();
415 }
416 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
417 format!("[{}]", items.join(", "))
418}
419
420fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
426 let sections = split_into_sections(toml_str);
427 if sections.is_empty() {
428 return toml_str.to_owned();
429 }
430
431 let preamble_block = sections
433 .iter()
434 .find(|(h, _)| h.is_empty())
435 .map_or("", |(_, c)| c.as_str());
436
437 let section_map: Vec<(&str, &str)> = sections
438 .iter()
439 .filter(|(h, _)| !h.is_empty())
440 .map(|(h, c)| (h.as_str(), c.as_str()))
441 .collect();
442
443 let mut out = String::new();
444 if !preamble_block.is_empty() {
445 out.push_str(preamble_block);
446 }
447
448 let mut emitted: Vec<bool> = vec![false; section_map.len()];
449
450 for &canon in canonical_order {
451 for (idx, &(header, content)) in section_map.iter().enumerate() {
452 let section_name = extract_section_name(header);
453 let top_level = section_name
454 .split('.')
455 .next()
456 .unwrap_or("")
457 .trim_start_matches('#')
458 .trim();
459 if top_level == canon && !emitted[idx] {
460 out.push_str(content);
461 emitted[idx] = true;
462 }
463 }
464 }
465
466 for (idx, &(_, content)) in section_map.iter().enumerate() {
468 if !emitted[idx] {
469 out.push_str(content);
470 }
471 }
472
473 out
474}
475
476fn extract_section_name(header: &str) -> &str {
478 let trimmed = header.trim().trim_start_matches("# ");
480 if trimmed.starts_with('[') && trimmed.contains(']') {
482 let inner = &trimmed[1..];
483 if let Some(end) = inner.find(']') {
484 return &inner[..end];
485 }
486 }
487 trimmed
488}
489
490fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
494 let mut sections: Vec<(String, String)> = Vec::new();
495 let mut current_header = String::new();
496 let mut current_content = String::new();
497
498 for line in toml_str.lines() {
499 let trimmed = line.trim();
500 if is_top_level_section_header(trimmed) {
501 sections.push((current_header.clone(), current_content.clone()));
502 trimmed.clone_into(&mut current_header);
503 line.clone_into(&mut current_content);
504 current_content.push('\n');
505 } else {
506 current_content.push_str(line);
507 current_content.push('\n');
508 }
509 }
510
511 if !current_header.is_empty() || !current_content.is_empty() {
513 sections.push((current_header, current_content));
514 }
515
516 sections
517}
518
519fn is_top_level_section_header(line: &str) -> bool {
524 if line.starts_with('[')
525 && !line.starts_with("[[")
526 && let Some(end) = line.find(']')
527 {
528 return !line[1..end].contains('.');
529 }
530 false
531}
532
533#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
534fn migrate_ollama_provider(
535 llm: &toml_edit::Table,
536 model: &Option<String>,
537 base_url: &Option<String>,
538 embedding_model: &Option<String>,
539) -> Vec<String> {
540 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
541 if let Some(m) = model {
542 block.push_str(&format!("model = \"{m}\"\n"));
543 }
544 if let Some(em) = embedding_model {
545 block.push_str(&format!("embedding_model = \"{em}\"\n"));
546 }
547 if let Some(u) = base_url {
548 block.push_str(&format!("base_url = \"{u}\"\n"));
549 }
550 let _ = llm; vec![block]
552}
553
554#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
555fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
556 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
557 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
558 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
559 block.push_str(&format!("model = \"{m}\"\n"));
560 }
561 if let Some(t) = cloud
562 .get("max_tokens")
563 .and_then(toml_edit::Item::as_integer)
564 {
565 block.push_str(&format!("max_tokens = {t}\n"));
566 }
567 if cloud
568 .get("server_compaction")
569 .and_then(toml_edit::Item::as_bool)
570 == Some(true)
571 {
572 block.push_str("server_compaction = true\n");
573 }
574 if cloud
575 .get("enable_extended_context")
576 .and_then(toml_edit::Item::as_bool)
577 == Some(true)
578 {
579 block.push_str("enable_extended_context = true\n");
580 }
581 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
582 let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
583 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
584 }
585 if let Some(v) = cloud
586 .get("prompt_cache_ttl")
587 .and_then(toml_edit::Item::as_str)
588 {
589 if v != "ephemeral" {
590 block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
591 }
592 }
593 } else if let Some(m) = model {
594 block.push_str(&format!("model = \"{m}\"\n"));
595 }
596 vec![block]
597}
598
599#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
600fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
601 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
602 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
603 copy_str_field(openai, "model", &mut block);
604 copy_str_field(openai, "base_url", &mut block);
605 copy_int_field(openai, "max_tokens", &mut block);
606 copy_str_field(openai, "embedding_model", &mut block);
607 copy_str_field(openai, "reasoning_effort", &mut block);
608 } else if let Some(m) = model {
609 block.push_str(&format!("model = \"{m}\"\n"));
610 }
611 vec![block]
612}
613
614#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
615fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
616 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
617 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
618 copy_str_field(gemini, "model", &mut block);
619 copy_int_field(gemini, "max_tokens", &mut block);
620 copy_str_field(gemini, "base_url", &mut block);
621 copy_str_field(gemini, "embedding_model", &mut block);
622 copy_str_field(gemini, "thinking_level", &mut block);
623 copy_int_field(gemini, "thinking_budget", &mut block);
624 if let Some(v) = gemini
625 .get("include_thoughts")
626 .and_then(toml_edit::Item::as_bool)
627 {
628 block.push_str(&format!("include_thoughts = {v}\n"));
629 }
630 } else if let Some(m) = model {
631 block.push_str(&format!("model = \"{m}\"\n"));
632 }
633 vec![block]
634}
635
636#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
637fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
638 let mut blocks = Vec::new();
639 if let Some(compat_arr) = llm
640 .get("compatible")
641 .and_then(toml_edit::Item::as_array_of_tables)
642 {
643 for entry in compat_arr {
644 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
645 copy_str_field(entry, "name", &mut block);
646 copy_str_field(entry, "base_url", &mut block);
647 copy_str_field(entry, "model", &mut block);
648 copy_int_field(entry, "max_tokens", &mut block);
649 copy_str_field(entry, "embedding_model", &mut block);
650 blocks.push(block);
651 }
652 }
653 blocks
654}
655
656#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
658fn migrate_orchestrator_provider(
659 llm: &toml_edit::Table,
660 model: &Option<String>,
661 base_url: &Option<String>,
662 embedding_model: &Option<String>,
663) -> (Vec<String>, Option<String>) {
664 let mut blocks = Vec::new();
665 let routing = None;
666 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
667 let default_name = orch
668 .get("default")
669 .and_then(toml_edit::Item::as_str)
670 .unwrap_or("")
671 .to_owned();
672 let embed_name = orch
673 .get("embed")
674 .and_then(toml_edit::Item::as_str)
675 .unwrap_or("")
676 .to_owned();
677 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
678 for (name, pcfg_item) in providers {
679 let Some(pcfg) = pcfg_item.as_table() else {
680 continue;
681 };
682 let ptype = pcfg
683 .get("type")
684 .and_then(toml_edit::Item::as_str)
685 .unwrap_or("ollama");
686 let mut block =
687 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
688 if name == default_name {
689 block.push_str("default = true\n");
690 }
691 if name == embed_name {
692 block.push_str("embed = true\n");
693 }
694 copy_str_field(pcfg, "model", &mut block);
695 copy_str_field(pcfg, "base_url", &mut block);
696 copy_str_field(pcfg, "embedding_model", &mut block);
697 if ptype == "claude" && !pcfg.contains_key("model") {
698 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
699 copy_str_field(cloud, "model", &mut block);
700 copy_int_field(cloud, "max_tokens", &mut block);
701 }
702 }
703 if ptype == "openai" && !pcfg.contains_key("model") {
704 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
705 copy_str_field(openai, "model", &mut block);
706 copy_str_field(openai, "base_url", &mut block);
707 copy_int_field(openai, "max_tokens", &mut block);
708 copy_str_field(openai, "embedding_model", &mut block);
709 }
710 }
711 if ptype == "ollama" && !pcfg.contains_key("base_url") {
712 if let Some(u) = base_url {
713 block.push_str(&format!("base_url = \"{u}\"\n"));
714 }
715 }
716 if ptype == "ollama" && !pcfg.contains_key("model") {
717 if let Some(m) = model {
718 block.push_str(&format!("model = \"{m}\"\n"));
719 }
720 }
721 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
722 if let Some(em) = embedding_model {
723 block.push_str(&format!("embedding_model = \"{em}\"\n"));
724 }
725 }
726 blocks.push(block);
727 }
728 }
729 }
730 (blocks, routing)
731}
732
733#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
735fn migrate_router_provider(
736 llm: &toml_edit::Table,
737 model: &Option<String>,
738 base_url: &Option<String>,
739 embedding_model: &Option<String>,
740) -> (Vec<String>, Option<String>) {
741 let mut blocks = Vec::new();
742 let mut routing = None;
743 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
744 let strategy = router
745 .get("strategy")
746 .and_then(toml_edit::Item::as_str)
747 .unwrap_or("ema");
748 routing = Some(strategy.to_owned());
749 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
750 for item in chain {
751 let name = item.as_str().unwrap_or_default();
752 let ptype = infer_provider_type(name, llm);
753 let mut block =
754 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
755 match ptype {
756 "claude" => {
757 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
758 copy_str_field(cloud, "model", &mut block);
759 copy_int_field(cloud, "max_tokens", &mut block);
760 }
761 }
762 "openai" => {
763 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
764 {
765 copy_str_field(openai, "model", &mut block);
766 copy_str_field(openai, "base_url", &mut block);
767 copy_int_field(openai, "max_tokens", &mut block);
768 copy_str_field(openai, "embedding_model", &mut block);
769 } else {
770 if let Some(m) = model {
771 block.push_str(&format!("model = \"{m}\"\n"));
772 }
773 if let Some(u) = base_url {
774 block.push_str(&format!("base_url = \"{u}\"\n"));
775 }
776 }
777 }
778 "ollama" => {
779 if let Some(m) = model {
780 block.push_str(&format!("model = \"{m}\"\n"));
781 }
782 if let Some(em) = embedding_model {
783 block.push_str(&format!("embedding_model = \"{em}\"\n"));
784 }
785 if let Some(u) = base_url {
786 block.push_str(&format!("base_url = \"{u}\"\n"));
787 }
788 }
789 _ => {
790 if let Some(m) = model {
791 block.push_str(&format!("model = \"{m}\"\n"));
792 }
793 }
794 }
795 blocks.push(block);
796 }
797 }
798 }
799 (blocks, routing)
800}
801
802fn strip_task_routing_keys(toml_src: &str) -> String {
811 let mut in_routes_block = false;
812 let mut out = Vec::new();
813 for line in toml_src.lines() {
814 let trimmed = line.trim();
815 if trimmed == "[llm.routes]" {
816 in_routes_block = true;
817 continue;
818 }
819 if in_routes_block {
820 if trimmed.starts_with('[') {
822 in_routes_block = false;
823 } else {
824 continue;
825 }
826 }
827 if trimmed.starts_with("routing") && trimmed.contains("\"task\"") {
829 continue;
830 }
831 out.push(line);
832 }
833 out.join("\n")
834}
835
836#[allow(
842 clippy::too_many_lines,
843 clippy::format_push_string,
844 clippy::manual_let_else,
845 clippy::op_ref,
846 clippy::collapsible_if
847)]
848pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
849 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
850
851 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
853 Some(t) => t,
854 None => {
855 return Ok(MigrationResult {
857 output: toml_src.to_owned(),
858 changed_count: 0,
859 sections_changed: Vec::new(),
860 });
861 }
862 };
863
864 if llm.get("routing").and_then(toml_edit::Item::as_str) == Some("task") {
867 let routes_count = llm
868 .get("routes")
869 .and_then(toml_edit::Item::as_table)
870 .map_or(0, toml_edit::Table::len);
871 let msg = format!(
872 "routing = \"task\" is no longer supported and has been removed (#3248). \
873 {routes_count} route(s) in [llm.routes] will be dropped. \
874 Falling back to default single-provider routing."
875 );
876 tracing::warn!("{msg}");
877 eprintln!("WARNING: {msg}");
878 let cleaned = strip_task_routing_keys(toml_src);
880 return migrate_llm_to_providers(&cleaned);
881 }
882
883 let has_provider_field = llm.contains_key("provider");
884 let has_cloud = llm.contains_key("cloud");
885 let has_openai = llm.contains_key("openai");
886 let has_gemini = llm.contains_key("gemini");
887 let has_orchestrator = llm.contains_key("orchestrator");
888 let has_router = llm.contains_key("router");
889 let has_providers = llm.contains_key("providers");
890
891 if !has_provider_field
892 && !has_cloud
893 && !has_openai
894 && !has_orchestrator
895 && !has_router
896 && !has_gemini
897 {
898 return Ok(MigrationResult {
900 output: toml_src.to_owned(),
901 changed_count: 0,
902 sections_changed: Vec::new(),
903 });
904 }
905
906 if has_providers {
907 return Err(MigrateError::Parse(
909 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
910 .parse::<toml_edit::DocumentMut>()
911 .unwrap_err(),
912 ));
913 }
914
915 let provider_str = llm
917 .get("provider")
918 .and_then(toml_edit::Item::as_str)
919 .unwrap_or("ollama");
920 let base_url = llm
921 .get("base_url")
922 .and_then(toml_edit::Item::as_str)
923 .map(str::to_owned);
924 let model = llm
925 .get("model")
926 .and_then(toml_edit::Item::as_str)
927 .map(str::to_owned);
928 let embedding_model = llm
929 .get("embedding_model")
930 .and_then(toml_edit::Item::as_str)
931 .map(str::to_owned);
932
933 let mut provider_blocks: Vec<String> = Vec::new();
935 let mut routing: Option<String> = None;
936
937 match provider_str {
938 "ollama" => {
939 provider_blocks.extend(migrate_ollama_provider(
940 llm,
941 &model,
942 &base_url,
943 &embedding_model,
944 ));
945 }
946 "claude" => {
947 provider_blocks.extend(migrate_claude_provider(llm, &model));
948 }
949 "openai" => {
950 provider_blocks.extend(migrate_openai_provider(llm, &model));
951 }
952 "gemini" => {
953 provider_blocks.extend(migrate_gemini_provider(llm, &model));
954 }
955 "compatible" => {
956 provider_blocks.extend(migrate_compatible_provider(llm));
957 }
958 "orchestrator" => {
959 let (blocks, r) =
960 migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
961 provider_blocks.extend(blocks);
962 routing = r;
963 }
964 "router" => {
965 let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
966 provider_blocks.extend(blocks);
967 routing = r;
968 }
969 other => {
970 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
971 if let Some(ref m) = model {
972 block.push_str(&format!("model = \"{m}\"\n"));
973 }
974 provider_blocks.push(block);
975 }
976 }
977
978 if provider_blocks.is_empty() {
979 return Ok(MigrationResult {
981 output: toml_src.to_owned(),
982 changed_count: 0,
983 sections_changed: Vec::new(),
984 });
985 }
986
987 let mut new_llm = "[llm]\n".to_owned();
989 if let Some(ref r) = routing {
990 new_llm.push_str(&format!("routing = \"{r}\"\n"));
991 }
992 for key in &[
994 "response_cache_enabled",
995 "response_cache_ttl_secs",
996 "semantic_cache_enabled",
997 "semantic_cache_threshold",
998 "semantic_cache_max_candidates",
999 "summary_model",
1000 "instruction_file",
1001 ] {
1002 if let Some(val) = llm.get(key) {
1003 if let Some(v) = val.as_value() {
1004 let raw = value_to_toml_string(v);
1005 if !raw.is_empty() {
1006 new_llm.push_str(&format!("{key} = {raw}\n"));
1007 }
1008 }
1009 }
1010 }
1011 new_llm.push('\n');
1012
1013 for block in &provider_blocks {
1014 new_llm.push_str(block);
1015 new_llm.push('\n');
1016 }
1017
1018 let output = replace_llm_section(toml_src, &new_llm);
1021
1022 Ok(MigrationResult {
1023 output,
1024 changed_count: provider_blocks.len(),
1025 sections_changed: vec!["llm.providers".to_owned()],
1026 })
1027}
1028
1029fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
1031 match name {
1032 "claude" => "claude",
1033 "openai" => "openai",
1034 "gemini" => "gemini",
1035 "ollama" => "ollama",
1036 "candle" => "candle",
1037 _ => {
1038 if llm.contains_key("compatible") {
1040 "compatible"
1041 } else if llm.contains_key("openai") {
1042 "openai"
1043 } else {
1044 "ollama"
1045 }
1046 }
1047 }
1048}
1049
1050fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1051 use std::fmt::Write as _;
1052 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
1053 let _ = writeln!(out, "{key} = \"{v}\"");
1054 }
1055}
1056
1057fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1058 use std::fmt::Write as _;
1059 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
1060 let _ = writeln!(out, "{key} = {v}");
1061 }
1062}
1063
1064fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
1067 let mut out = String::new();
1068 let mut in_llm = false;
1069 let mut skip_until_next_top = false;
1070
1071 for line in toml_str.lines() {
1072 let trimmed = line.trim();
1073
1074 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
1076 && trimmed.ends_with(']')
1077 && !trimmed[1..trimmed.len() - 1].contains('.');
1078 let is_top_aot = trimmed.starts_with("[[")
1079 && trimmed.ends_with("]]")
1080 && !trimmed[2..trimmed.len() - 2].contains('.');
1081 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
1082 && (trimmed.contains(']'));
1083
1084 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
1085 in_llm = true;
1086 skip_until_next_top = true;
1087 continue;
1088 }
1089
1090 if is_top_section || is_top_aot {
1091 if skip_until_next_top {
1092 out.push_str(new_llm_section);
1094 skip_until_next_top = false;
1095 }
1096 in_llm = false;
1097 }
1098
1099 if !skip_until_next_top {
1100 out.push_str(line);
1101 out.push('\n');
1102 }
1103 }
1104
1105 if skip_until_next_top {
1107 out.push_str(new_llm_section);
1108 }
1109
1110 out
1111}
1112
1113struct SttFields {
1115 model: Option<String>,
1116 base_url: Option<String>,
1117 provider_hint: String,
1118}
1119
1120fn extract_stt_fields(doc: &toml_edit::DocumentMut) -> SttFields {
1122 let stt_table = doc
1123 .get("llm")
1124 .and_then(toml_edit::Item::as_table)
1125 .and_then(|llm| llm.get("stt"))
1126 .and_then(toml_edit::Item::as_table);
1127
1128 let model = stt_table
1129 .and_then(|stt| stt.get("model"))
1130 .and_then(toml_edit::Item::as_str)
1131 .map(ToOwned::to_owned);
1132
1133 let base_url = stt_table
1134 .and_then(|stt| stt.get("base_url"))
1135 .and_then(toml_edit::Item::as_str)
1136 .map(ToOwned::to_owned);
1137
1138 let provider_hint = stt_table
1139 .and_then(|stt| stt.get("provider"))
1140 .and_then(toml_edit::Item::as_str)
1141 .map(ToOwned::to_owned)
1142 .unwrap_or_default();
1143
1144 SttFields {
1145 model,
1146 base_url,
1147 provider_hint,
1148 }
1149}
1150
1151fn find_matching_provider_index(
1154 doc: &toml_edit::DocumentMut,
1155 target_type: &str,
1156 provider_hint: &str,
1157) -> Option<usize> {
1158 let providers = doc
1159 .get("llm")
1160 .and_then(toml_edit::Item::as_table)
1161 .and_then(|llm| llm.get("providers"))
1162 .and_then(toml_edit::Item::as_array_of_tables)?;
1163
1164 providers.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 =
1175 !provider_hint.is_empty() && (name == provider_hint || ptype == 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
1185fn attach_stt_to_existing_provider(
1188 doc: &mut toml_edit::DocumentMut,
1189 idx: usize,
1190 stt_model: &str,
1191 stt_base_url: Option<&str>,
1192) -> Result<String, MigrateError> {
1193 let llm_mut = doc
1194 .get_mut("llm")
1195 .and_then(toml_edit::Item::as_table_mut)
1196 .ok_or(MigrateError::InvalidStructure(
1197 "[llm] table not accessible for mutation",
1198 ))?;
1199 let providers_mut = llm_mut
1200 .get_mut("providers")
1201 .and_then(toml_edit::Item::as_array_of_tables_mut)
1202 .ok_or(MigrateError::InvalidStructure(
1203 "[[llm.providers]] array not accessible for mutation",
1204 ))?;
1205 let entry = providers_mut
1206 .iter_mut()
1207 .nth(idx)
1208 .ok_or(MigrateError::InvalidStructure(
1209 "[[llm.providers]] entry index out of range during mutation",
1210 ))?;
1211
1212 let existing_name = entry
1214 .get("name")
1215 .and_then(toml_edit::Item::as_str)
1216 .map(ToOwned::to_owned);
1217 let entry_name = existing_name.unwrap_or_else(|| {
1218 let t = entry
1219 .get("type")
1220 .and_then(toml_edit::Item::as_str)
1221 .unwrap_or("openai");
1222 format!("{t}-stt")
1223 });
1224 entry.insert("name", toml_edit::value(entry_name.clone()));
1225 entry.insert("stt_model", toml_edit::value(stt_model));
1226 if let Some(url) = stt_base_url
1227 && entry.get("base_url").is_none()
1228 {
1229 entry.insert("base_url", toml_edit::value(url));
1230 }
1231 Ok(entry_name)
1232}
1233
1234fn append_new_stt_provider(
1237 doc: &mut toml_edit::DocumentMut,
1238 target_type: &str,
1239 stt_model: &str,
1240 stt_base_url: Option<&str>,
1241) -> Result<String, MigrateError> {
1242 let new_name = if target_type == "candle" {
1243 "local-whisper".to_owned()
1244 } else {
1245 "openai-stt".to_owned()
1246 };
1247 let mut new_entry = toml_edit::Table::new();
1248 new_entry.insert("name", toml_edit::value(new_name.clone()));
1249 new_entry.insert("type", toml_edit::value(target_type));
1250 new_entry.insert("stt_model", toml_edit::value(stt_model));
1251 if let Some(url) = stt_base_url {
1252 new_entry.insert("base_url", toml_edit::value(url));
1253 }
1254 let llm_mut = doc
1255 .get_mut("llm")
1256 .and_then(toml_edit::Item::as_table_mut)
1257 .ok_or(MigrateError::InvalidStructure(
1258 "[llm] table not accessible for mutation",
1259 ))?;
1260 if let Some(item) = llm_mut.get_mut("providers") {
1261 if let Some(arr) = item.as_array_of_tables_mut() {
1262 arr.push(new_entry);
1263 }
1264 } else {
1265 let mut arr = toml_edit::ArrayOfTables::new();
1266 arr.push(new_entry);
1267 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1268 }
1269 Ok(new_name)
1270}
1271
1272fn rewrite_stt_section(doc: &mut toml_edit::DocumentMut, resolved_provider_name: &str) {
1274 if let Some(stt_table) = doc
1275 .get_mut("llm")
1276 .and_then(toml_edit::Item::as_table_mut)
1277 .and_then(|llm| llm.get_mut("stt"))
1278 .and_then(toml_edit::Item::as_table_mut)
1279 {
1280 stt_table.insert("provider", toml_edit::value(resolved_provider_name));
1281 stt_table.remove("model");
1282 stt_table.remove("base_url");
1283 }
1284}
1285
1286pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1305 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1306 let stt = extract_stt_fields(&doc);
1307
1308 if stt.model.is_none() && stt.base_url.is_none() {
1310 return Ok(MigrationResult {
1311 output: toml_src.to_owned(),
1312 changed_count: 0,
1313 sections_changed: Vec::new(),
1314 });
1315 }
1316
1317 let stt_model = stt.model.unwrap_or_else(|| "whisper-1".to_owned());
1318
1319 let target_type = match stt.provider_hint.as_str() {
1321 "candle-whisper" | "candle" => "candle",
1322 _ => "openai",
1323 };
1324
1325 let resolved_name = match find_matching_provider_index(&doc, target_type, &stt.provider_hint) {
1326 Some(idx) => {
1327 attach_stt_to_existing_provider(&mut doc, idx, &stt_model, stt.base_url.as_deref())?
1328 }
1329 None => {
1330 append_new_stt_provider(&mut doc, target_type, &stt_model, stt.base_url.as_deref())?
1331 }
1332 };
1333
1334 rewrite_stt_section(&mut doc, &resolved_name);
1335
1336 Ok(MigrationResult {
1337 output: doc.to_string(),
1338 changed_count: 1,
1339 sections_changed: vec!["llm.providers.stt_model".to_owned()],
1340 })
1341}
1342
1343pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1356 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1357
1358 let old_value = doc
1359 .get("orchestration")
1360 .and_then(toml_edit::Item::as_table)
1361 .and_then(|t| t.get("planner_model"))
1362 .and_then(toml_edit::Item::as_value)
1363 .and_then(toml_edit::Value::as_str)
1364 .map(ToOwned::to_owned);
1365
1366 let Some(old_model) = old_value else {
1367 return Ok(MigrationResult {
1368 output: toml_src.to_owned(),
1369 changed_count: 0,
1370 sections_changed: Vec::new(),
1371 });
1372 };
1373
1374 let commented_out = format!(
1378 "# planner_provider = \"{old_model}\" \
1379 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1380 );
1381
1382 let orch_table = doc
1383 .get_mut("orchestration")
1384 .and_then(toml_edit::Item::as_table_mut)
1385 .ok_or(MigrateError::InvalidStructure(
1386 "[orchestration] is not a table",
1387 ))?;
1388 orch_table.remove("planner_model");
1389 let decor = orch_table.decor_mut();
1390 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1391 let new_suffix = if existing_suffix.trim().is_empty() {
1393 format!("\n{commented_out}\n")
1394 } else {
1395 format!("{existing_suffix}\n{commented_out}\n")
1396 };
1397 decor.set_suffix(new_suffix);
1398
1399 eprintln!(
1400 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1401 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1402 `name` field, not a raw model name. Update or remove the commented line."
1403 );
1404
1405 Ok(MigrationResult {
1406 output: doc.to_string(),
1407 changed_count: 1,
1408 sections_changed: vec!["orchestration.planner_provider".to_owned()],
1409 })
1410}
1411
1412pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1426 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1427 let mut added = 0usize;
1428
1429 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1430 return Ok(MigrationResult {
1431 output: toml_src.to_owned(),
1432 changed_count: 0,
1433 sections_changed: Vec::new(),
1434 });
1435 };
1436
1437 let Some(servers) = mcp
1438 .get_mut("servers")
1439 .and_then(toml_edit::Item::as_array_of_tables_mut)
1440 else {
1441 return Ok(MigrationResult {
1442 output: toml_src.to_owned(),
1443 changed_count: 0,
1444 sections_changed: Vec::new(),
1445 });
1446 };
1447
1448 for entry in servers.iter_mut() {
1449 if !entry.contains_key("trust_level") {
1450 entry.insert(
1451 "trust_level",
1452 toml_edit::value(toml_edit::Value::from("trusted")),
1453 );
1454 added += 1;
1455 }
1456 }
1457
1458 if added > 0 {
1459 eprintln!(
1460 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1461 entr{} (preserving previous SSRF-skip behavior). \
1462 Review and adjust trust levels as needed.",
1463 if added == 1 { "y" } else { "ies" }
1464 );
1465 }
1466
1467 Ok(MigrationResult {
1468 output: doc.to_string(),
1469 changed_count: added,
1470 sections_changed: if added > 0 {
1471 vec!["mcp.servers.trust_level".to_owned()]
1472 } else {
1473 Vec::new()
1474 },
1475 })
1476}
1477
1478pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1489 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1490
1491 let max_retries = doc
1492 .get("agent")
1493 .and_then(toml_edit::Item::as_table)
1494 .and_then(|t| t.get("max_tool_retries"))
1495 .and_then(toml_edit::Item::as_value)
1496 .and_then(toml_edit::Value::as_integer)
1497 .map(i64::cast_unsigned);
1498
1499 let budget_secs = doc
1500 .get("agent")
1501 .and_then(toml_edit::Item::as_table)
1502 .and_then(|t| t.get("max_retry_duration_secs"))
1503 .and_then(toml_edit::Item::as_value)
1504 .and_then(toml_edit::Value::as_integer)
1505 .map(i64::cast_unsigned);
1506
1507 if max_retries.is_none() && budget_secs.is_none() {
1508 return Ok(MigrationResult {
1509 output: toml_src.to_owned(),
1510 changed_count: 0,
1511 sections_changed: Vec::new(),
1512 });
1513 }
1514
1515 if !doc.contains_key("tools") {
1517 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1518 }
1519 let tools_table = doc
1520 .get_mut("tools")
1521 .and_then(toml_edit::Item::as_table_mut)
1522 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1523
1524 if !tools_table.contains_key("retry") {
1525 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1526 }
1527 let retry_table = tools_table
1528 .get_mut("retry")
1529 .and_then(toml_edit::Item::as_table_mut)
1530 .ok_or(MigrateError::InvalidStructure(
1531 "[tools.retry] is not a table",
1532 ))?;
1533
1534 let mut changed_count = 0usize;
1535
1536 if let Some(retries) = max_retries
1537 && !retry_table.contains_key("max_attempts")
1538 {
1539 retry_table.insert(
1540 "max_attempts",
1541 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1542 );
1543 changed_count += 1;
1544 }
1545
1546 if let Some(secs) = budget_secs
1547 && !retry_table.contains_key("budget_secs")
1548 {
1549 retry_table.insert(
1550 "budget_secs",
1551 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1552 );
1553 changed_count += 1;
1554 }
1555
1556 if changed_count > 0 {
1557 eprintln!(
1558 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1559 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1560 );
1561 }
1562
1563 Ok(MigrationResult {
1564 output: doc.to_string(),
1565 changed_count,
1566 sections_changed: if changed_count > 0 {
1567 vec!["tools.retry".to_owned()]
1568 } else {
1569 Vec::new()
1570 },
1571 })
1572}
1573
1574pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1583 if toml_src.contains("database_url") {
1585 return Ok(MigrationResult {
1586 output: toml_src.to_owned(),
1587 changed_count: 0,
1588 sections_changed: Vec::new(),
1589 });
1590 }
1591
1592 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1593
1594 if !doc.contains_key("memory") {
1596 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1597 }
1598
1599 let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1600 # Leave empty and store the actual URL in the vault:\n\
1601 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1602 # database_url = \"\"\n";
1603 let raw = doc.to_string();
1604 let output = format!("{raw}{comment}");
1605
1606 Ok(MigrationResult {
1607 output,
1608 changed_count: 1,
1609 sections_changed: vec!["memory.database_url".to_owned()],
1610 })
1611}
1612
1613pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1622 if toml_src.contains("transactional") {
1624 return Ok(MigrationResult {
1625 output: toml_src.to_owned(),
1626 changed_count: 0,
1627 sections_changed: Vec::new(),
1628 });
1629 }
1630
1631 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1632
1633 let tools_shell_exists = doc
1634 .get("tools")
1635 .and_then(toml_edit::Item::as_table)
1636 .is_some_and(|t| t.contains_key("shell"));
1637 if !tools_shell_exists {
1638 return Ok(MigrationResult {
1640 output: toml_src.to_owned(),
1641 changed_count: 0,
1642 sections_changed: Vec::new(),
1643 });
1644 }
1645
1646 let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1647 # transactional = false\n\
1648 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1649 # auto_rollback = false # rollback when exit code >= 2\n\
1650 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1651 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1652 let raw = doc.to_string();
1653 let output = format!("{raw}{comment}");
1654
1655 Ok(MigrationResult {
1656 output,
1657 changed_count: 1,
1658 sections_changed: vec!["tools.shell.transactional".to_owned()],
1659 })
1660}
1661
1662pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1668 if toml_src.contains("budget_hint_enabled") {
1670 return Ok(MigrationResult {
1671 output: toml_src.to_owned(),
1672 changed_count: 0,
1673 sections_changed: Vec::new(),
1674 });
1675 }
1676
1677 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1678 if !doc.contains_key("agent") {
1679 return Ok(MigrationResult {
1680 output: toml_src.to_owned(),
1681 changed_count: 0,
1682 sections_changed: Vec::new(),
1683 });
1684 }
1685
1686 let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1687 # budget_hint_enabled = true\n";
1688 let raw = doc.to_string();
1689 let output = format!("{raw}{comment}");
1690
1691 Ok(MigrationResult {
1692 output,
1693 changed_count: 1,
1694 sections_changed: vec!["agent.budget_hint_enabled".to_owned()],
1695 })
1696}
1697
1698pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1707 if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1709 return Ok(MigrationResult {
1710 output: toml_src.to_owned(),
1711 changed_count: 0,
1712 sections_changed: Vec::new(),
1713 });
1714 }
1715
1716 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1717 if !doc.contains_key("memory") {
1718 return Ok(MigrationResult {
1719 output: toml_src.to_owned(),
1720 changed_count: 0,
1721 sections_changed: Vec::new(),
1722 });
1723 }
1724
1725 let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1726 # [memory.forgetting]\n\
1727 # enabled = false\n\
1728 # decay_rate = 0.1 # per-sweep importance decay\n\
1729 # forgetting_floor = 0.05 # prune below this score\n\
1730 # sweep_interval_secs = 7200 # run every 2 hours\n\
1731 # sweep_batch_size = 500\n\
1732 # protect_recent_hours = 24\n\
1733 # protect_min_access_count = 3\n";
1734 let raw = doc.to_string();
1735 let output = format!("{raw}{comment}");
1736
1737 Ok(MigrationResult {
1738 output,
1739 changed_count: 1,
1740 sections_changed: vec!["memory.forgetting".to_owned()],
1741 })
1742}
1743
1744pub fn migrate_compression_predictor_config(
1753 toml_src: &str,
1754) -> Result<MigrationResult, MigrateError> {
1755 let has_active = toml_src.contains("[memory.compression.predictor]");
1758 let has_commented = toml_src.contains("# [memory.compression.predictor]");
1759 if !has_active && !has_commented {
1760 return Ok(MigrationResult {
1761 output: toml_src.to_owned(),
1762 changed_count: 0,
1763 sections_changed: Vec::new(),
1764 });
1765 }
1766
1767 let mut output_lines: Vec<&str> = Vec::new();
1771 let mut in_predictor = false;
1772 for line in toml_src.lines() {
1773 let trimmed = line.trim();
1774 if trimmed == "[memory.compression.predictor]"
1776 || trimmed == "# [memory.compression.predictor]"
1777 {
1778 in_predictor = true;
1779 continue;
1780 }
1781 if in_predictor && trimmed.starts_with('[') && !trimmed.starts_with("# [") {
1783 in_predictor = false;
1784 }
1785 if !in_predictor {
1786 output_lines.push(line);
1787 }
1788 }
1789 let mut output = output_lines.join("\n");
1791 if toml_src.ends_with('\n') {
1792 output.push('\n');
1793 }
1794
1795 Ok(MigrationResult {
1796 output,
1797 changed_count: 1,
1798 sections_changed: vec!["memory.compression.predictor".to_owned()],
1799 })
1800}
1801
1802pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1808 if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1810 return Ok(MigrationResult {
1811 output: toml_src.to_owned(),
1812 changed_count: 0,
1813 sections_changed: Vec::new(),
1814 });
1815 }
1816
1817 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1818 if !doc.contains_key("memory") {
1819 return Ok(MigrationResult {
1820 output: toml_src.to_owned(),
1821 changed_count: 0,
1822 sections_changed: Vec::new(),
1823 });
1824 }
1825
1826 let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1827 # [memory.microcompact]\n\
1828 # enabled = false\n\
1829 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1830 # keep_recent = 3 # always keep this many recent outputs intact\n";
1831 let raw = doc.to_string();
1832 let output = format!("{raw}{comment}");
1833
1834 Ok(MigrationResult {
1835 output,
1836 changed_count: 1,
1837 sections_changed: vec!["memory.microcompact".to_owned()],
1838 })
1839}
1840
1841pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1847 if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1849 return Ok(MigrationResult {
1850 output: toml_src.to_owned(),
1851 changed_count: 0,
1852 sections_changed: Vec::new(),
1853 });
1854 }
1855
1856 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1857 if !doc.contains_key("memory") {
1858 return Ok(MigrationResult {
1859 output: toml_src.to_owned(),
1860 changed_count: 0,
1861 sections_changed: Vec::new(),
1862 });
1863 }
1864
1865 let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1866 # [memory.autodream]\n\
1867 # enabled = false\n\
1868 # min_sessions = 5 # sessions since last consolidation\n\
1869 # min_hours = 8 # hours since last consolidation\n\
1870 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1871 # max_iterations = 5\n";
1872 let raw = doc.to_string();
1873 let output = format!("{raw}{comment}");
1874
1875 Ok(MigrationResult {
1876 output,
1877 changed_count: 1,
1878 sections_changed: vec!["memory.autodream".to_owned()],
1879 })
1880}
1881
1882pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1888 use toml_edit::{Item, Table};
1889
1890 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1891
1892 if doc.contains_key("magic_docs") {
1893 return Ok(MigrationResult {
1894 output: toml_src.to_owned(),
1895 changed_count: 0,
1896 sections_changed: Vec::new(),
1897 });
1898 }
1899
1900 doc.insert("magic_docs", Item::Table(Table::new()));
1901 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1902 # [magic_docs]\n\
1903 # enabled = false\n\
1904 # min_turns_between_updates = 10\n\
1905 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1906 # max_iterations = 3\n";
1907 doc.remove("magic_docs");
1909 let raw = doc.to_string();
1911 let output = format!("{raw}\n{comment}");
1912
1913 Ok(MigrationResult {
1914 output,
1915 changed_count: 1,
1916 sections_changed: vec!["magic_docs".to_owned()],
1917 })
1918}
1919
1920pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1929 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1930
1931 if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1932 return Ok(MigrationResult {
1933 output: toml_src.to_owned(),
1934 changed_count: 0,
1935 sections_changed: Vec::new(),
1936 });
1937 }
1938
1939 let comment = "\n\
1940 # Profiling and distributed tracing (requires --features profiling). All\n\
1941 # instrumentation points are zero-overhead when the feature is absent.\n\
1942 # [telemetry]\n\
1943 # enabled = false\n\
1944 # backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1945 # trace_dir = \".local/traces\"\n\
1946 # include_args = false\n\
1947 # service_name = \"zeph-agent\"\n\
1948 # sample_rate = 1.0\n\
1949 # otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1950
1951 let raw = doc.to_string();
1952 let output = format!("{raw}{comment}");
1953
1954 Ok(MigrationResult {
1955 output,
1956 changed_count: 1,
1957 sections_changed: vec!["telemetry".to_owned()],
1958 })
1959}
1960
1961pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1970 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1972 return Ok(MigrationResult {
1973 output: toml_src.to_owned(),
1974 changed_count: 0,
1975 sections_changed: Vec::new(),
1976 });
1977 }
1978
1979 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1980
1981 if !doc.contains_key("agent") {
1984 return Ok(MigrationResult {
1985 output: toml_src.to_owned(),
1986 changed_count: 0,
1987 sections_changed: Vec::new(),
1988 });
1989 }
1990
1991 let comment = "\n\
1992 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1993 # [agent.supervisor]\n\
1994 # enrichment_limit = 4\n\
1995 # telemetry_limit = 8\n\
1996 # abort_enrichment_on_turn = false\n";
1997
1998 let raw = doc.to_string();
1999 let output = format!("{raw}{comment}");
2000
2001 Ok(MigrationResult {
2002 output,
2003 changed_count: 1,
2004 sections_changed: vec!["agent.supervisor".to_owned()],
2005 })
2006}
2007
2008pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2018 if toml_src.contains("otel_filter") {
2020 return Ok(MigrationResult {
2021 output: toml_src.to_owned(),
2022 changed_count: 0,
2023 sections_changed: Vec::new(),
2024 });
2025 }
2026
2027 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
2028
2029 if !doc.contains_key("telemetry") {
2032 return Ok(MigrationResult {
2033 output: toml_src.to_owned(),
2034 changed_count: 0,
2035 sections_changed: Vec::new(),
2036 });
2037 }
2038
2039 let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
2040 (tonic=warn etc.) are always appended (#2997).\n\
2041 # otel_filter = \"info\"\n";
2042 let raw = doc.to_string();
2043 let output = insert_after_section(&raw, "telemetry", comment);
2045
2046 Ok(MigrationResult {
2047 output,
2048 changed_count: 1,
2049 sections_changed: vec!["telemetry.otel_filter".to_owned()],
2050 })
2051}
2052
2053pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2059 if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
2060 return Ok(MigrationResult {
2061 output: toml_src.to_owned(),
2062 changed_count: 0,
2063 sections_changed: Vec::new(),
2064 });
2065 }
2066
2067 let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
2068 # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
2069 # [tools.egress]\n\
2070 # enabled = true # set to false to disable all egress event recording\n\
2071 # log_blocked = true # record scheme/domain/SSRF-blocked requests\n\
2072 # log_response_bytes = true\n\
2073 # log_hosts_to_tui = true\n";
2074
2075 let mut output = toml_src.to_owned();
2076 output.push_str(comment);
2077 Ok(MigrationResult {
2078 output,
2079 changed_count: 1,
2080 sections_changed: vec!["tools.egress".to_owned()],
2081 })
2082}
2083
2084pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2090 if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
2091 return Ok(MigrationResult {
2092 output: toml_src.to_owned(),
2093 changed_count: 0,
2094 sections_changed: Vec::new(),
2095 });
2096 }
2097
2098 let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2099 # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2100 # [security.vigil]\n\
2101 # enabled = true # master switch; false bypasses VIGIL entirely\n\
2102 # strict_mode = false # true: block (replace with sentinel); false: truncate+annotate\n\
2103 # sanitize_max_chars = 2048\n\
2104 # extra_patterns = [] # operator-supplied additional injection patterns (max 64)\n\
2105 # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2106
2107 let mut output = toml_src.to_owned();
2108 output.push_str(comment);
2109 Ok(MigrationResult {
2110 output,
2111 changed_count: 1,
2112 sections_changed: vec!["security.vigil".to_owned()],
2113 })
2114}
2115
2116pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2128 let doc: DocumentMut = toml_src.parse()?;
2129 let already_present = doc
2130 .get("tools")
2131 .and_then(|t| t.as_table())
2132 .and_then(|t| t.get("sandbox"))
2133 .is_some();
2134 if already_present || toml_src.contains("# [tools.sandbox]") {
2137 return Ok(MigrationResult {
2138 output: toml_src.to_owned(),
2139 changed_count: 0,
2140 sections_changed: Vec::new(),
2141 });
2142 }
2143
2144 let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2145 # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2146 # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2147 # [tools.sandbox]\n\
2148 # enabled = false # set to true to wrap shell commands\n\
2149 # profile = \"workspace\" # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2150 # backend = \"auto\" # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2151 # strict = true # fail startup if sandbox init fails (fail-closed)\n\
2152 # allow_read = [] # additional read-allowed absolute paths\n\
2153 # allow_write = [] # additional write-allowed absolute paths\n";
2154
2155 let mut output = toml_src.to_owned();
2156 output.push_str(comment);
2157 Ok(MigrationResult {
2158 output,
2159 changed_count: 1,
2160 sections_changed: vec!["tools.sandbox".to_owned()],
2161 })
2162}
2163
2164pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2173 if !toml_src.contains("[tools.sandbox]") {
2175 return Ok(MigrationResult {
2176 output: toml_src.to_owned(),
2177 changed_count: 0,
2178 sections_changed: Vec::new(),
2179 });
2180 }
2181
2182 let already_has_denied =
2183 toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
2184 let already_has_fail =
2185 toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
2186
2187 if already_has_denied && already_has_fail {
2188 return Ok(MigrationResult {
2189 output: toml_src.to_owned(),
2190 changed_count: 0,
2191 sections_changed: Vec::new(),
2192 });
2193 }
2194
2195 let mut comment = String::new();
2196 if !already_has_denied {
2197 comment.push_str(
2198 "# denied_domains = [] \
2199 # hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
2200 );
2201 }
2202 if !already_has_fail {
2203 comment.push_str(
2204 "# fail_if_unavailable = false \
2205 # abort startup when no effective OS sandbox is available\n",
2206 );
2207 }
2208
2209 let output = toml_src.replacen(
2210 "[tools.sandbox]\n",
2211 &format!("[tools.sandbox]\n{comment}"),
2212 1,
2213 );
2214 Ok(MigrationResult {
2215 output,
2216 changed_count: 1,
2217 sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
2218 })
2219}
2220
2221pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2230 if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2232 return Ok(MigrationResult {
2233 output: toml_src.to_owned(),
2234 changed_count: 0,
2235 sections_changed: Vec::new(),
2236 });
2237 }
2238
2239 if !toml_src.contains("[orchestration]") {
2241 return Ok(MigrationResult {
2242 output: toml_src.to_owned(),
2243 changed_count: 0,
2244 sections_changed: Vec::new(),
2245 });
2246 }
2247
2248 let comment = "# persistence_enabled = true \
2250 # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2251 let output = toml_src.replacen(
2252 "[orchestration]\n",
2253 &format!("[orchestration]\n{comment}"),
2254 1,
2255 );
2256 Ok(MigrationResult {
2257 output,
2258 changed_count: 1,
2259 sections_changed: vec!["orchestration.persistence_enabled".to_owned()],
2260 })
2261}
2262
2263pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2271 if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2273 return Ok(MigrationResult {
2274 output: toml_src.to_owned(),
2275 changed_count: 0,
2276 sections_changed: Vec::new(),
2277 });
2278 }
2279
2280 let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2281 # [session.recap]\n\
2282 # on_resume = true\n\
2283 # max_tokens = 200\n\
2284 # provider = \"\"\n\
2285 # max_input_messages = 20\n";
2286 let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2287 let output = format!("{raw}{comment}");
2288
2289 Ok(MigrationResult {
2290 output,
2291 changed_count: 1,
2292 sections_changed: vec!["session.recap".to_owned()],
2293 })
2294}
2295
2296pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2304 if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2306 return Ok(MigrationResult {
2307 output: toml_src.to_owned(),
2308 changed_count: 0,
2309 sections_changed: Vec::new(),
2310 });
2311 }
2312
2313 if !toml_src.contains("[mcp]") {
2315 return Ok(MigrationResult {
2316 output: toml_src.to_owned(),
2317 changed_count: 0,
2318 sections_changed: Vec::new(),
2319 });
2320 }
2321
2322 if !toml_src.contains("[mcp]\n") {
2324 return Ok(MigrationResult {
2325 output: toml_src.to_owned(),
2326 changed_count: 0,
2327 sections_changed: Vec::new(),
2328 });
2329 }
2330
2331 let comment = "# elicitation_enabled = false \
2332 # opt-in: servers may request user input mid-task (#3141)\n\
2333 # elicitation_timeout = 120 # seconds to wait for user response\n\
2334 # elicitation_queue_capacity = 16 # beyond this limit requests are auto-declined\n\
2335 # elicitation_warn_sensitive_fields = true # warn before prompting for password/token/etc.\n";
2336 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2337
2338 Ok(MigrationResult {
2339 output,
2340 changed_count: 1,
2341 sections_changed: vec!["mcp.elicitation".to_owned()],
2342 })
2343}
2344
2345pub fn migrate_mcp_max_connect_attempts(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2355 if toml_src.contains("max_connect_attempts") {
2356 return Ok(MigrationResult {
2357 output: toml_src.to_owned(),
2358 changed_count: 0,
2359 sections_changed: Vec::new(),
2360 });
2361 }
2362
2363 if !toml_src.contains("[mcp]\n") {
2364 return Ok(MigrationResult {
2365 output: toml_src.to_owned(),
2366 changed_count: 0,
2367 sections_changed: Vec::new(),
2368 });
2369 }
2370
2371 let comment = "# max_connect_attempts = 3 \
2372 # startup retry count per server (1 = no retry, 1..=10, backoff: 500ms/1s/2s/...)\n";
2373 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2374
2375 Ok(MigrationResult {
2376 output,
2377 changed_count: 1,
2378 sections_changed: vec!["mcp".to_owned()],
2379 })
2380}
2381
2382pub fn migrate_mcp_retry_and_tool_timeout(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2392 let has_backoff = toml_src.contains("startup_retry_backoff_ms");
2393 let has_timeout = toml_src.contains("tool_timeout_secs");
2394
2395 if (has_backoff && has_timeout) || !toml_src.contains("[mcp]\n") {
2396 return Ok(MigrationResult {
2397 output: toml_src.to_owned(),
2398 changed_count: 0,
2399 sections_changed: Vec::new(),
2400 });
2401 }
2402
2403 let mut output = toml_src.to_owned();
2404 let mut changed = false;
2405
2406 if !has_backoff {
2407 let comment = "# startup_retry_backoff_ms = 1000 \
2408 # base backoff ms between startup retries (doubles per attempt, cap 8000 ms)\n";
2409 output = output.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2410 changed = true;
2411 }
2412
2413 if !has_timeout {
2414 let comment = "# tool_timeout_secs = 60 \
2415 # per-call timeout for tools/call requests; when absent, per-server timeout is used\n";
2416 output = output.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2417 changed = true;
2418 }
2419
2420 if changed {
2421 Ok(MigrationResult {
2422 output,
2423 changed_count: 1,
2424 sections_changed: vec!["mcp".to_owned()],
2425 })
2426 } else {
2427 Ok(MigrationResult {
2428 output: toml_src.to_owned(),
2429 changed_count: 0,
2430 sections_changed: Vec::new(),
2431 })
2432 }
2433}
2434
2435pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2446 if toml_src
2448 .lines()
2449 .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2450 {
2451 return Ok(MigrationResult {
2452 output: toml_src.to_owned(),
2453 changed_count: 0,
2454 sections_changed: Vec::new(),
2455 });
2456 }
2457
2458 let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2459 # [quality]\n\
2460 # self_check = false # enable post-response self-check\n\
2461 # trigger = \"has_retrieval\" # has_retrieval | always | manual\n\
2462 # latency_budget_ms = 4000 # hard ceiling for the whole pipeline\n\
2463 # proposer_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2464 # checker_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2465 # min_evidence = 0.6 # 0.0..1.0; below → flag assertion\n\
2466 # async_run = false # true = fire-and-forget (non-blocking)\n\
2467 # per_call_timeout_ms = 2000 # per-LLM-call timeout\n\
2468 # max_assertions = 12 # maximum assertions extracted from one response\n\
2469 # max_response_chars = 8000 # skip pipeline when response exceeds this\n\
2470 # cache_disabled_for_checker = true # suppress prompt-cache on Checker provider\n\
2471 # flag_marker = \"[verify]\" # marker appended when assertions are flagged\n";
2472 let output = format!("{toml_src}{comment}");
2473
2474 Ok(MigrationResult {
2475 output,
2476 changed_count: 1,
2477 sections_changed: vec!["quality".to_owned()],
2478 })
2479}
2480
2481pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2492 if toml_src
2493 .lines()
2494 .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2495 {
2496 return Ok(MigrationResult {
2497 output: toml_src.to_owned(),
2498 changed_count: 0,
2499 sections_changed: Vec::new(),
2500 });
2501 }
2502
2503 let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2504 # [acp.subagents]\n\
2505 # enabled = false\n\
2506 #\n\
2507 # [[acp.subagents.presets]]\n\
2508 # name = \"inner\" # identifier used in /subagent commands\n\
2509 # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2510 # # cwd = \"/path/to/agent\" # optional working directory\n\
2511 # # handshake_timeout_secs = 30 # initialize+session/new timeout\n\
2512 # # prompt_timeout_secs = 600 # single round-trip timeout\n";
2513 let output = format!("{toml_src}{comment}");
2514
2515 Ok(MigrationResult {
2516 output,
2517 changed_count: 1,
2518 sections_changed: vec!["acp.subagents".to_owned()],
2519 })
2520}
2521
2522pub fn migrate_hooks_permission_denied_config(
2533 toml_src: &str,
2534) -> Result<MigrationResult, MigrateError> {
2535 if toml_src.lines().any(|l| {
2536 l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2537 }) {
2538 return Ok(MigrationResult {
2539 output: toml_src.to_owned(),
2540 changed_count: 0,
2541 sections_changed: Vec::new(),
2542 });
2543 }
2544
2545 let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2546 # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2547 # [[hooks.permission_denied]]\n\
2548 # [hooks.permission_denied.action]\n\
2549 # type = \"command\"\n\
2550 # command = \"echo denied: $ZEPH_TOOL\"\n";
2551 let output = format!("{toml_src}{comment}");
2552
2553 Ok(MigrationResult {
2554 output,
2555 changed_count: 1,
2556 sections_changed: vec!["hooks.permission_denied".to_owned()],
2557 })
2558}
2559
2560pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2571 if toml_src.contains("retrieval_strategy")
2572 || toml_src.contains("[memory.graph.beam_search]")
2573 || toml_src.contains("# [memory.graph.beam_search]")
2574 {
2575 return Ok(MigrationResult {
2576 output: toml_src.to_owned(),
2577 changed_count: 0,
2578 sections_changed: Vec::new(),
2579 });
2580 }
2581
2582 let comment = "\n# [memory.graph] retrieval strategy options (#3311).\n\
2583 # retrieval_strategy = \"synapse\" # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2584 #\n\
2585 # [memory.graph.beam_search] # active when retrieval_strategy = \"beam_search\"\n\
2586 # beam_width = 10 # top-K candidates kept per hop\n\
2587 #\n\
2588 # [memory.graph.watercircles] # active when retrieval_strategy = \"watercircles\"\n\
2589 # ring_limit = 0 # max facts per ring; 0 = auto\n\
2590 #\n\
2591 # [memory.graph.experience] # experience memory recording\n\
2592 # enabled = false\n\
2593 # evolution_sweep_enabled = false\n\
2594 # confidence_prune_threshold = 0.1 # prune edges below this threshold\n\
2595 # evolution_sweep_interval = 50 # turns between sweeps\n";
2596 let output = format!("{toml_src}{comment}");
2597
2598 Ok(MigrationResult {
2599 output,
2600 changed_count: 1,
2601 sections_changed: vec!["memory.graph.retrieval".to_owned()],
2602 })
2603}
2604
2605pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2616 if toml_src
2617 .lines()
2618 .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2619 {
2620 return Ok(MigrationResult {
2621 output: toml_src.to_owned(),
2622 changed_count: 0,
2623 sections_changed: Vec::new(),
2624 });
2625 }
2626
2627 let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2628 # [scheduler.daemon]\n\
2629 # pid_file = \"/tmp/zeph-scheduler.pid\" # PID file path (must be on a local filesystem)\n\
2630 # log_file = \"/tmp/zeph-scheduler.log\" # daemon log file path (append-only; rotate externally)\n\
2631 # tick_secs = 60 # scheduler tick interval in seconds (clamped 5..=3600)\n\
2632 # shutdown_grace_secs = 30 # grace period after SIGTERM before process exits\n\
2633 # catch_up = true # replay missed cron tasks on daemon restart\n";
2634 let output = format!("{toml_src}{comment}");
2635
2636 Ok(MigrationResult {
2637 output,
2638 changed_count: 1,
2639 sections_changed: vec!["scheduler.daemon".to_owned()],
2640 })
2641}
2642
2643pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2654 if toml_src
2655 .lines()
2656 .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2657 {
2658 return Ok(MigrationResult {
2659 output: toml_src.to_owned(),
2660 changed_count: 0,
2661 sections_changed: Vec::new(),
2662 });
2663 }
2664
2665 let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2666 # [memory.retrieval]\n\
2667 # depth = 0 # ANN candidates fetched from the vector store, directly.\n\
2668 # # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2669 # # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2670 # search_prompt_template = \"\" # embedding query template; {query} = raw user query; empty = identity\n\
2671 # context_format = \"structured\" # structured | plain — memory snippet rendering format\n\
2672 # query_bias_correction = true # shift first-person queries towards user profile centroid (MM-F3)\n\
2673 # query_bias_profile_weight = 0.25 # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2674 # query_bias_centroid_ttl_secs = 300 # seconds before profile centroid cache is recomputed\n";
2675 let output = format!("{toml_src}{comment}");
2676
2677 Ok(MigrationResult {
2678 output,
2679 changed_count: 1,
2680 sections_changed: vec!["memory.retrieval".to_owned()],
2681 })
2682}
2683
2684pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2695 if toml_src
2696 .lines()
2697 .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2698 {
2699 return Ok(MigrationResult {
2700 output: toml_src.to_owned(),
2701 changed_count: 0,
2702 sections_changed: Vec::new(),
2703 });
2704 }
2705
2706 let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2707 # [memory.reasoning]\n\
2708 # enabled = false\n\
2709 # extract_provider = \"\" # SLM: self-judge (JSON response) — leave blank to use primary\n\
2710 # distill_provider = \"\" # SLM: strategy distillation — leave blank to use primary\n\
2711 # top_k = 3 # strategies injected per turn\n\
2712 # store_limit = 1000 # max rows in reasoning_strategies table\n\
2713 # context_budget_tokens = 500\n\
2714 # extraction_timeout_secs = 30\n\
2715 # distill_timeout_secs = 30\n\
2716 # max_messages = 6\n\
2717 # min_messages = 2\n\
2718 # max_message_chars = 2000\n";
2719 let output = format!("{toml_src}{comment}");
2720
2721 Ok(MigrationResult {
2722 output,
2723 changed_count: 1,
2724 sections_changed: vec!["memory.reasoning".to_owned()],
2725 })
2726}
2727
2728pub fn migrate_memory_reasoning_judge_config(
2740 toml_src: &str,
2741) -> Result<MigrationResult, MigrateError> {
2742 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2743 if !has_section {
2744 return Ok(MigrationResult {
2745 output: toml_src.to_owned(),
2746 changed_count: 0,
2747 sections_changed: Vec::new(),
2748 });
2749 }
2750
2751 let has_window = toml_src.lines().any(|l| {
2753 let t = l.trim().trim_start_matches('#').trim();
2754 t.starts_with("self_judge_window")
2755 });
2756 let has_min_chars = toml_src.lines().any(|l| {
2757 let t = l.trim().trim_start_matches('#').trim();
2758 t.starts_with("min_assistant_chars")
2759 });
2760 if has_window && has_min_chars {
2761 return Ok(MigrationResult {
2762 output: toml_src.to_owned(),
2763 changed_count: 0,
2764 sections_changed: Vec::new(),
2765 });
2766 }
2767
2768 let lines: Vec<&str> = toml_src.lines().collect();
2772 let mut section_start = None;
2773 let mut insert_after = None;
2774
2775 for (i, line) in lines.iter().enumerate() {
2776 if line.trim() == "[memory.reasoning]" {
2777 section_start = Some(i);
2778 }
2779 if let Some(start) = section_start {
2780 let trimmed = line.trim();
2781 if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2783 break;
2784 }
2785 insert_after = Some(i);
2786 }
2787 }
2788
2789 let Some(insert_idx) = insert_after else {
2790 return Ok(MigrationResult {
2791 output: toml_src.to_owned(),
2792 changed_count: 0,
2793 sections_changed: Vec::new(),
2794 });
2795 };
2796
2797 let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2798 let mut additions = Vec::new();
2799 if !has_window {
2800 additions.push(
2801 "# self_judge_window = 2 # max recent messages passed to self-judge (#3383)"
2802 .to_owned(),
2803 );
2804 }
2805 if !has_min_chars {
2806 additions.push(
2807 "# min_assistant_chars = 50 # skip self-judge for short replies (#3383)".to_owned(),
2808 );
2809 }
2810 for (offset, line) in additions.iter().enumerate() {
2811 new_lines.insert(insert_idx + 1 + offset, line.clone());
2812 }
2813
2814 let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2815 Ok(MigrationResult {
2816 output,
2817 changed_count: additions.len(),
2818 sections_changed: vec!["memory.reasoning".to_owned()],
2819 })
2820}
2821
2822pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2832 if toml_src
2833 .lines()
2834 .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2835 {
2836 return Ok(MigrationResult {
2837 output: toml_src.to_owned(),
2838 changed_count: 0,
2839 sections_changed: Vec::new(),
2840 });
2841 }
2842
2843 let comment = "\n# [memory.hebbian] # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2844 # [memory.hebbian]\n\
2845 # enabled = false # opt-in master switch; no DB writes when false\n\
2846 # hebbian_lr = 0.1 # weight increment per co-activation (0.01–0.5)\n";
2847 let output = format!("{toml_src}{comment}");
2848
2849 Ok(MigrationResult {
2850 output,
2851 changed_count: 1,
2852 sections_changed: vec!["memory.hebbian".to_owned()],
2853 })
2854}
2855
2856pub fn migrate_memory_hebbian_consolidation_config(
2868 toml_src: &str,
2869) -> Result<MigrationResult, MigrateError> {
2870 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2871
2872 if !has_section {
2873 return Ok(MigrationResult {
2874 output: toml_src.to_owned(),
2875 changed_count: 0,
2876 sections_changed: Vec::new(),
2877 });
2878 }
2879
2880 let has_interval = toml_src
2882 .lines()
2883 .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2884 let has_threshold = toml_src
2885 .lines()
2886 .any(|l| l.trim().starts_with("consolidation_threshold"));
2887 let has_provider = toml_src
2888 .lines()
2889 .any(|l| l.trim().starts_with("consolidate_provider"));
2890
2891 if has_interval && has_threshold && has_provider {
2892 return Ok(MigrationResult {
2893 output: toml_src.to_owned(),
2894 changed_count: 0,
2895 sections_changed: Vec::new(),
2896 });
2897 }
2898
2899 let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2900 # consolidation_interval_secs = 3600 # how often the sweep runs (0 = disabled)\n\
2901 # consolidation_threshold = 5.0 # degree × avg_weight score to qualify\n\
2902 # consolidate_provider = \"fast\" # provider name for LLM distillation\n\
2903 # max_candidates_per_sweep = 10\n\
2904 # consolidation_cooldown_secs = 86400 # re-consolidation cooldown per entity\n\
2905 # consolidation_prompt_timeout_secs = 30\n\
2906 # consolidation_max_neighbors = 20\n";
2907
2908 let output = format!("{toml_src}{extra}");
2909 Ok(MigrationResult {
2910 output,
2911 changed_count: 1,
2912 sections_changed: vec!["memory.hebbian".to_owned()],
2913 })
2914}
2915
2916pub fn migrate_memory_hebbian_spread_config(
2928 toml_src: &str,
2929) -> Result<MigrationResult, MigrateError> {
2930 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2931
2932 if !has_section {
2933 return Ok(MigrationResult {
2934 output: toml_src.to_owned(),
2935 changed_count: 0,
2936 sections_changed: Vec::new(),
2937 });
2938 }
2939
2940 let has_spreading = toml_src
2942 .lines()
2943 .any(|l| l.trim().starts_with("spreading_activation"));
2944 let has_depth = toml_src
2945 .lines()
2946 .any(|l| l.trim().starts_with("spread_depth"));
2947 let has_budget = toml_src
2948 .lines()
2949 .any(|l| l.trim().starts_with("step_budget_ms"));
2950 let has_embed_timeout = toml_src
2951 .lines()
2952 .any(|l| l.trim().starts_with("embed_timeout_secs"));
2953
2954 if has_spreading && has_depth && has_budget && has_embed_timeout {
2955 return Ok(MigrationResult {
2956 output: toml_src.to_owned(),
2957 changed_count: 0,
2958 sections_changed: Vec::new(),
2959 });
2960 }
2961
2962 let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2963 # spreading_activation = false # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2964 # spread_depth = 2 # BFS hops, clamped [1,6]\n\
2965 # spread_edge_types = [] # MAGMA edge types to traverse; empty = all\n\
2966 # step_budget_ms = 8 # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n\
2967 # embed_timeout_secs = 5 # timeout for the initial query embedding call (0 = disabled)\n";
2968
2969 let output = format!("{toml_src}{extra}");
2970 Ok(MigrationResult {
2971 output,
2972 changed_count: 1,
2973 sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2974 })
2975}
2976
2977pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2991 if toml_src
2992 .lines()
2993 .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2994 {
2995 return Ok(MigrationResult {
2996 output: toml_src.to_owned(),
2997 changed_count: 0,
2998 sections_changed: Vec::new(),
2999 });
3000 }
3001
3002 let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
3003 # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
3004 # ZEPH_TURN_LLM_REQUESTS.\n\
3005 # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
3006 # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
3007 # [[hooks.turn_complete]]\n\
3008 # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
3009 # timeout_secs = 3\n\
3010 # fail_closed = false\n";
3011 let output = format!("{toml_src}{comment}");
3012
3013 Ok(MigrationResult {
3014 output,
3015 changed_count: 1,
3016 sections_changed: vec!["hooks.turn_complete".to_owned()],
3017 })
3018}
3019
3020pub fn migrate_focus_auto_consolidate_min_window(
3037 toml_src: &str,
3038) -> Result<MigrationResult, MigrateError> {
3039 if toml_src.contains("auto_consolidate_min_window") {
3040 return Ok(MigrationResult {
3041 output: toml_src.to_owned(),
3042 changed_count: 0,
3043 sections_changed: Vec::new(),
3044 });
3045 }
3046
3047 if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
3049 return Ok(MigrationResult {
3050 output: toml_src.to_owned(),
3051 changed_count: 0,
3052 sections_changed: Vec::new(),
3053 });
3054 }
3055
3056 let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
3057 runs (#3313).\n\
3058 # auto_consolidate_min_window = 6\n";
3059 let output = insert_after_section(toml_src, "agent.focus", comment);
3060
3061 Ok(MigrationResult {
3062 output,
3063 changed_count: 1,
3064 sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
3065 })
3066}
3067
3068pub fn migrate_session_provider_persistence(
3078 toml_src: &str,
3079) -> Result<MigrationResult, MigrateError> {
3080 if toml_src
3081 .lines()
3082 .any(|l| l.trim() == "[session]" || l.trim() == "# [session]")
3083 {
3084 return Ok(MigrationResult {
3085 output: toml_src.to_owned(),
3086 changed_count: 0,
3087 sections_changed: Vec::new(),
3088 });
3089 }
3090
3091 let comment = "\n# [session] — session-scoped user experience settings (#3308).\n\
3092 [session]\n\
3093 # Persist the last-used provider per channel across restarts.\n\
3094 # When true, the agent saves the active provider name to SQLite after each\n\
3095 # /provider switch and restores it on the next session start for the same channel.\n\
3096 provider_persistence = true\n";
3097 let output = format!("{toml_src}{comment}");
3098
3099 Ok(MigrationResult {
3100 output,
3101 changed_count: 1,
3102 sections_changed: vec!["session".to_owned()],
3103 })
3104}
3105
3106pub fn migrate_session_persist_provider_overrides(
3120 toml_src: &str,
3121) -> Result<MigrationResult, MigrateError> {
3122 if toml_src.contains("persist_provider_overrides") {
3123 return Ok(MigrationResult {
3124 output: toml_src.to_owned(),
3125 changed_count: 0,
3126 sections_changed: Vec::new(),
3127 });
3128 }
3129 if !toml_src.lines().any(|l| l.trim() == "[session]") {
3130 return Ok(MigrationResult {
3131 output: toml_src.to_owned(),
3132 changed_count: 0,
3133 sections_changed: Vec::new(),
3134 });
3135 }
3136
3137 let comment = "# persist_provider_overrides = true \
3138 # persist generation overrides (e.g. reasoning_effort) alongside provider name (#4654)\n";
3139 let output = toml_src.replacen("[session]\n", &format!("[session]\n{comment}"), 1);
3140 Ok(MigrationResult {
3141 output,
3142 changed_count: 1,
3143 sections_changed: vec!["session".to_owned()],
3144 })
3145}
3146
3147pub fn migrate_memory_retrieval_query_bias(
3159 toml_src: &str,
3160) -> Result<MigrationResult, MigrateError> {
3161 if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
3164 return Ok(MigrationResult {
3165 output: toml_src.to_owned(),
3166 changed_count: 0,
3167 sections_changed: Vec::new(),
3168 });
3169 }
3170
3171 if toml_src
3173 .lines()
3174 .any(|l| l.trim().starts_with("query_bias_correction"))
3175 {
3176 return Ok(MigrationResult {
3177 output: toml_src.to_owned(),
3178 changed_count: 0,
3179 sections_changed: Vec::new(),
3180 });
3181 }
3182
3183 let comment = "\n# MM-F3 (#3341): shift first-person queries toward the user profile centroid.\n\
3184 # No-op when the persona table is empty.\n\
3185 # query_bias_correction = true\n";
3186 let output = insert_after_section(toml_src, "memory.retrieval", comment);
3187
3188 Ok(MigrationResult {
3189 output,
3190 changed_count: 1,
3191 sections_changed: vec!["memory.retrieval.query_bias_correction".to_owned()],
3192 })
3193}
3194
3195pub fn migrate_memory_persona_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3204 if toml_src
3205 .lines()
3206 .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]")
3207 {
3208 return Ok(MigrationResult {
3209 output: toml_src.to_owned(),
3210 changed_count: 0,
3211 sections_changed: Vec::new(),
3212 });
3213 }
3214
3215 let comment = "\n# [memory.persona] — user persona profile for query-bias correction (#3341).\n\
3216 # Verified working in CI-604/CI-605. No-op when disabled.\n\
3217 # [memory.persona]\n\
3218 # enabled = true\n\
3219 # min_messages = 2 # minimum user messages before persona extraction fires\n\
3220 # min_confidence = 0.5 # minimum extraction confidence threshold (0.0–1.0)\n";
3221 let output = format!("{toml_src}{comment}");
3222
3223 Ok(MigrationResult {
3224 output,
3225 changed_count: 1,
3226 sections_changed: vec!["memory.persona".to_owned()],
3227 })
3228}
3229
3230pub fn migrate_qdrant_api_key(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3239 if toml_src.contains("qdrant_api_key") {
3240 return Ok(MigrationResult {
3241 output: toml_src.to_owned(),
3242 changed_count: 0,
3243 sections_changed: Vec::new(),
3244 });
3245 }
3246
3247 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3248
3249 if !doc.contains_key("memory") {
3250 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
3251 }
3252
3253 let comment = "\n# Qdrant API key (optional; required when connecting to remote/managed Qdrant clusters).\n\
3254 # Leave empty for local Qdrant instances. Store the actual key in the vault:\n\
3255 # zeph vault set ZEPH_QDRANT_API_KEY \"<key>\"\n\
3256 # qdrant_api_key = \"\"\n";
3257 let raw = doc.to_string();
3258 let output = format!("{raw}{comment}");
3259
3260 Ok(MigrationResult {
3261 output,
3262 changed_count: 1,
3263 sections_changed: vec!["memory.qdrant_api_key".to_owned()],
3264 })
3265}
3266
3267pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3273 if toml_src.contains("[goals]") {
3274 return Ok(MigrationResult {
3275 output: toml_src.to_owned(),
3276 changed_count: 0,
3277 sections_changed: Vec::new(),
3278 });
3279 }
3280
3281 let comment = "\n# Long-horizon goal lifecycle tracking (#3567).\n\
3282 # [goals]\n\
3283 # enabled = false\n\
3284 # inject_into_system_prompt = true\n\
3285 # max_text_chars = 2000\n\
3286 # max_history = 50\n";
3287
3288 Ok(MigrationResult {
3289 output: format!("{toml_src}{comment}"),
3290 changed_count: 1,
3291 sections_changed: vec!["goals".to_owned()],
3292 })
3293}
3294
3295pub fn migrate_tools_compression_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3301 if toml_src.contains("tools.compression")
3302 || toml_src.contains("[tools]\n") && toml_src.contains("compression")
3303 {
3304 return Ok(MigrationResult {
3305 output: toml_src.to_owned(),
3306 changed_count: 0,
3307 sections_changed: Vec::new(),
3308 });
3309 }
3310
3311 let comment = "\n# TACO self-evolving tool output compression (#3306).\n\
3312 # [tools.compression]\n\
3313 # enabled = false\n\
3314 # min_lines_to_compress = 10\n\
3315 # evolution_provider = \"\"\n\
3316 # evolution_min_interval_secs = 3600\n\
3317 # max_rules = 200\n";
3318
3319 Ok(MigrationResult {
3320 output: format!("{toml_src}{comment}"),
3321 changed_count: 1,
3322 sections_changed: vec!["tools.compression".to_owned()],
3323 })
3324}
3325
3326pub fn migrate_orchestration_orchestrator_provider(
3332 toml_src: &str,
3333) -> Result<MigrationResult, MigrateError> {
3334 if toml_src.contains("orchestrator_provider") {
3335 return Ok(MigrationResult {
3336 output: toml_src.to_owned(),
3337 changed_count: 0,
3338 sections_changed: Vec::new(),
3339 });
3340 }
3341
3342 let comment = "\n# Provider for scheduling-tier LLM calls (aggregation, predicate, verify fallback).\n\
3343 # Set to a cheap/fast model to reduce orchestration cost. Empty = primary provider.\n\
3344 # Add under the orchestration section in your config:\n\
3345 # orchestrator_provider = \"\"\n";
3346
3347 Ok(MigrationResult {
3348 output: format!("{toml_src}{comment}"),
3349 changed_count: 1,
3350 sections_changed: vec!["orchestration".to_owned()],
3351 })
3352}
3353
3354pub fn migrate_provider_max_concurrent(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3364 if toml_src.contains("max_concurrent") {
3365 return Ok(MigrationResult {
3366 output: toml_src.to_owned(),
3367 changed_count: 0,
3368 sections_changed: Vec::new(),
3369 });
3370 }
3371
3372 if !toml_src.contains("[[llm.providers]]") {
3373 return Ok(MigrationResult {
3374 output: toml_src.to_owned(),
3375 changed_count: 0,
3376 sections_changed: Vec::new(),
3377 });
3378 }
3379
3380 let comment = "\n# Optional: maximum concurrent sub-agent calls to this provider (admission control).\n\
3381 # Remove the comment to enable; omit or set to 0 for unlimited.\n\
3382 # max_concurrent = 4\n";
3383
3384 Ok(MigrationResult {
3385 output: format!("{toml_src}{comment}"),
3386 changed_count: 1,
3387 sections_changed: vec!["llm.providers".to_owned()],
3388 })
3389}
3390
3391pub trait Migration: Send + Sync {
3418 fn name(&self) -> &'static str;
3420
3421 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
3427}
3428
3429mod steps;
3430use steps::{
3431 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
3432 MigrateAutodreamConfig, MigrateCocoonProviderNotice, MigrateCocoonShowBalance,
3433 MigrateCompressionPredictorConfig, MigrateDatabaseUrl, MigrateEgressConfig,
3434 MigrateEmbedProviderRename, MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig,
3435 MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig,
3436 MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete,
3437 MigrateLlmStreamLimits, MigrateMagicDocsConfig, MigrateMcpElicitationConfig,
3438 MigrateMcpMaxConnectAttempts, MigrateMcpRetryAndToolTimeout, MigrateMcpTrustLevels,
3439 MigrateMemoryGraph, MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation,
3440 MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig, MigrateMemoryReasoning,
3441 MigrateMemoryReasoningJudge, MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias,
3442 MigrateMicrocompactConfig, MigrateOrchestrationPersistence, MigrateOrchestratorProvider,
3443 MigrateOtelFilter, MigratePlannerModelToProvider, MigrateProviderMaxConcurrent,
3444 MigrateQdrantApiKey, MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter,
3445 MigrateSchedulerDaemon, MigrateSessionPersistProviderOverrides,
3446 MigrateSessionProviderPersistence, MigrateSessionRecapConfig, MigrateShellTransactional,
3447 MigrateSttToProvider, MigrateSupervisorConfig, MigrateTelemetryConfig,
3448 MigrateToolsCompressionConfig, MigrateTraceMetadata, MigrateVigilConfig, MigrateWorktreeConfig,
3449 MigrateWorktreeGitTimeout,
3450};
3451
3452pub(crate) fn migrate_gonkagate_to_gonka(toml_src: &str) -> MigrationResult {
3458 const MARKER: &str = "# [migration] GonkaGate detected: consider migrating to type = \"gonka\"";
3460
3461 if !toml_src.contains("gonkagate") {
3462 return MigrationResult {
3463 output: toml_src.to_owned(),
3464 changed_count: 0,
3465 sections_changed: vec![],
3466 };
3467 }
3468
3469 let mut changed_count = 0;
3470 let mut lines: Vec<String> = toml_src.lines().map(str::to_owned).collect();
3471
3472 let indices: Vec<usize> = lines
3476 .iter()
3477 .enumerate()
3478 .filter(|(_, l)| l.contains("gonkagate"))
3479 .map(|(i, _)| i)
3480 .rev()
3481 .collect();
3482
3483 for gonka_idx in indices {
3484 let header_idx = (0..=gonka_idx)
3486 .rev()
3487 .find(|&i| lines[i].starts_with("[["))
3488 .unwrap_or(gonka_idx);
3489
3490 let already_marked = header_idx > 0 && lines[header_idx - 1].contains(MARKER);
3492 if already_marked {
3493 continue;
3494 }
3495
3496 lines.insert(
3497 header_idx,
3498 format!("{MARKER} (see docs/guides/gonka-native.md)"),
3499 );
3500 changed_count += 1;
3501 }
3502
3503 let output = lines.join("\n");
3504 let output = if toml_src.ends_with('\n') {
3505 format!("{output}\n")
3506 } else {
3507 output
3508 };
3509
3510 MigrationResult {
3511 output,
3512 changed_count,
3513 sections_changed: if changed_count > 0 {
3514 vec!["llm".into()]
3515 } else {
3516 vec![]
3517 },
3518 }
3519}
3520
3521pub fn migrate_cocoon_provider_notice(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3530 Ok(MigrationResult {
3531 output: toml_src.to_owned(),
3532 changed_count: 0,
3533 sections_changed: vec![],
3534 })
3535}
3536
3537pub fn migrate_trace_metadata(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3544 if toml_src.contains("trace_metadata") {
3545 return Ok(MigrationResult {
3546 output: toml_src.to_owned(),
3547 changed_count: 0,
3548 sections_changed: Vec::new(),
3549 });
3550 }
3551
3552 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3553
3554 if !doc.contains_key("telemetry") {
3555 return Ok(MigrationResult {
3556 output: toml_src.to_owned(),
3557 changed_count: 0,
3558 sections_changed: Vec::new(),
3559 });
3560 }
3561
3562 let comment = "\n# Custom key/value pairs attached as OpenTelemetry resource attributes (#4160).\n\
3563 # Appear on every exported span. Values are plaintext — do not store secrets here.\n\
3564 # [telemetry.trace_metadata]\n\
3565 # \"deployment.environment\" = \"production\"\n\
3566 # \"vcs.revision\" = \"abc1234\"\n";
3567 let raw = doc.to_string();
3568 let output = insert_after_section(&raw, "telemetry", comment);
3569
3570 Ok(MigrationResult {
3571 output,
3572 changed_count: 1,
3573 sections_changed: vec!["telemetry.trace_metadata".to_owned()],
3574 })
3575}
3576
3577pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3594 std::sync::LazyLock::new(|| {
3595 vec![
3596 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3598 Box::new(MigratePlannerModelToProvider),
3599 Box::new(MigrateMcpTrustLevels),
3600 Box::new(MigrateAgentRetryToToolsRetry),
3601 Box::new(MigrateDatabaseUrl),
3602 Box::new(MigrateShellTransactional),
3603 Box::new(MigrateAgentBudgetHint),
3604 Box::new(MigrateForgettingConfig),
3605 Box::new(MigrateCompressionPredictorConfig),
3606 Box::new(MigrateMicrocompactConfig),
3607 Box::new(MigrateAutodreamConfig),
3608 Box::new(MigrateMagicDocsConfig),
3609 Box::new(MigrateTelemetryConfig),
3610 Box::new(MigrateSupervisorConfig),
3611 Box::new(MigrateOtelFilter),
3612 Box::new(MigrateEgressConfig),
3613 Box::new(MigrateVigilConfig),
3614 Box::new(MigrateSandboxConfig),
3615 Box::new(MigrateSandboxEgressFilter),
3616 Box::new(MigrateOrchestrationPersistence),
3617 Box::new(MigrateSessionRecapConfig),
3618 Box::new(MigrateMcpElicitationConfig),
3619 Box::new(MigrateQualityConfig),
3620 Box::new(MigrateAcpSubagentsConfig),
3621 Box::new(MigrateHooksPermissionDeniedConfig),
3622 Box::new(MigrateMemoryGraph),
3624 Box::new(MigrateSchedulerDaemon),
3625 Box::new(MigrateMemoryRetrieval),
3626 Box::new(MigrateMemoryReasoning),
3627 Box::new(MigrateMemoryReasoningJudge),
3628 Box::new(MigrateMemoryHebbian),
3629 Box::new(MigrateMemoryHebbianConsolidation),
3630 Box::new(MigrateMemoryHebbianSpread),
3631 Box::new(MigrateHooksTurnComplete),
3632 Box::new(MigrateFocusAutoConsolidateMinWindow),
3633 Box::new(MigrateSessionProviderPersistence),
3635 Box::new(MigrateMemoryRetrievalQueryBias),
3636 Box::new(MigrateMemoryPersonaConfig),
3637 Box::new(MigrateQdrantApiKey),
3639 Box::new(MigrateMcpMaxConnectAttempts),
3641 Box::new(MigrateGoalsConfig),
3643 Box::new(MigrateToolsCompressionConfig),
3644 Box::new(MigrateOrchestratorProvider),
3646 Box::new(MigrateProviderMaxConcurrent),
3648 Box::new(MigrateGonkagateToGonka),
3650 Box::new(MigrateCocoonProviderNotice),
3652 Box::new(MigrateTraceMetadata),
3654 Box::new(MigrateFiveSignalConfig),
3656 Box::new(MigrateEmbedProviderRename),
3658 Box::new(MigrateMcpRetryAndToolTimeout),
3660 Box::new(MigrateFidelityTimeoutDefaults),
3662 Box::new(MigrateSessionPersistProviderOverrides),
3664 Box::new(MigrateCocoonShowBalance),
3666 Box::new(MigrateWorktreeConfig),
3668 Box::new(MigrateWorktreeGitTimeout),
3670 Box::new(MigrateLlmStreamLimits),
3672 ]
3673 });
3674
3675pub fn migrate_five_signal_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3684 if toml_src.contains("[memory.five_signal]") || toml_src.contains("# [memory.five_signal]") {
3685 return Ok(MigrationResult {
3686 output: toml_src.to_owned(),
3687 changed_count: 0,
3688 sections_changed: Vec::new(),
3689 });
3690 }
3691
3692 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3693 if !doc.contains_key("memory") {
3694 return Ok(MigrationResult {
3695 output: toml_src.to_owned(),
3696 changed_count: 0,
3697 sections_changed: Vec::new(),
3698 });
3699 }
3700
3701 let comment = "\n# Five-signal SYNAPSE retrieval (#4374). Disabled by default.\n\
3702 # [memory.five_signal]\n\
3703 # enabled = false\n\
3704 # w_recency = 0.35\n\
3705 # w_relevance = 0.35\n\
3706 # w_frequency = 0.15\n\
3707 # w_causal = 0.10\n\
3708 # w_novelty = 0.05\n\
3709 # causal_bfs_max_depth = 10\n\
3710 # neutral_causal_distance = 5\n\
3711 # novelty_decay_rate = 0.1\n\
3712 #\n\
3713 # [memory.five_signal.consolidation_daemon]\n\
3714 # enabled = false\n\
3715 # interval_seconds = 7200\n\
3716 # batch_size = 500\n\
3717 # promotion_score_threshold = 0.70\n\
3718 # demotion_score_threshold = 0.20\n\
3719 # top_k_per_run = 500\n";
3720 let raw = doc.to_string();
3721 let output = format!("{raw}{comment}");
3722
3723 Ok(MigrationResult {
3724 output,
3725 changed_count: 1,
3726 sections_changed: vec!["memory.five_signal".to_owned()],
3727 })
3728}
3729
3730pub fn migrate_embed_provider_rename(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3742 let has_old =
3744 toml_src.contains("embed_provider") || toml_src.contains("trace_extraction_embed_provider");
3745 if !has_old {
3746 return Ok(MigrationResult {
3747 output: toml_src.to_owned(),
3748 changed_count: 0,
3749 sections_changed: Vec::new(),
3750 });
3751 }
3752
3753 let mut changed_count = 0usize;
3757 let mut sections_changed = Vec::new();
3758
3759 let output = toml_src
3760 .lines()
3761 .map(|line| {
3762 let trimmed = line.trim_start();
3763 if trimmed.starts_with("trace_extraction_embed_provider") {
3765 let replaced = line.replacen(
3766 "trace_extraction_embed_provider",
3767 "trace_extraction_embedding_provider",
3768 1,
3769 );
3770 changed_count += 1;
3771 if !sections_changed.contains(&"learning".to_owned()) {
3772 sections_changed.push("learning".to_owned());
3773 }
3774 return replaced;
3775 }
3776 if trimmed.starts_with("embed_provider") {
3777 let replaced = line.replacen("embed_provider", "embedding_provider", 1);
3778 changed_count += 1;
3779 return replaced;
3780 }
3781 line.to_owned()
3782 })
3783 .collect::<Vec<_>>()
3784 .join("\n");
3785
3786 let output = if toml_src.ends_with('\n') && !output.ends_with('\n') {
3788 format!("{output}\n")
3789 } else {
3790 output
3791 };
3792
3793 Ok(MigrationResult {
3794 output,
3795 changed_count,
3796 sections_changed,
3797 })
3798}
3799
3800pub fn migrate_fidelity_timeout_defaults(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3811 let has_embed = toml_src.contains("embed_timeout_secs");
3812 let has_compress = toml_src.contains("compress_timeout_secs");
3813
3814 if (has_embed && has_compress) || !toml_src.contains("[memory.fidelity]") {
3815 return Ok(MigrationResult {
3816 output: toml_src.to_owned(),
3817 changed_count: 0,
3818 sections_changed: Vec::new(),
3819 });
3820 }
3821
3822 let mut output = toml_src.to_owned();
3823 let mut changed = false;
3824
3825 if !has_embed {
3826 let comment = "# embed_timeout_secs = 30 \
3827 # timeout in seconds for embed calls in fidelity scoring\n";
3828 output = output.replacen(
3829 "[memory.fidelity]\n",
3830 &format!("[memory.fidelity]\n{comment}"),
3831 1,
3832 );
3833 changed = true;
3834 }
3835
3836 if !has_compress {
3837 let comment = "# compress_timeout_secs = 30 \
3838 # timeout in seconds for the LLM compress call in fidelity scoring\n";
3839 output = output.replacen(
3840 "[memory.fidelity]\n",
3841 &format!("[memory.fidelity]\n{comment}"),
3842 1,
3843 );
3844 changed = true;
3845 }
3846
3847 if changed {
3848 Ok(MigrationResult {
3849 output,
3850 changed_count: 1,
3851 sections_changed: vec!["memory.fidelity".to_owned()],
3852 })
3853 } else {
3854 Ok(MigrationResult {
3855 output: toml_src.to_owned(),
3856 changed_count: 0,
3857 sections_changed: Vec::new(),
3858 })
3859 }
3860}
3861
3862pub fn migrate_cocoon_show_balance(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3874 if toml_src.contains("show_balance") {
3875 return Ok(MigrationResult {
3876 output: toml_src.to_owned(),
3877 changed_count: 0,
3878 sections_changed: Vec::new(),
3879 });
3880 }
3881
3882 let section = "\n[cocoon]\n\
3883 # show_balance = true \
3884 # set to false to redact TON balance in TUI status bar (spec §15.2) (#4649)\n";
3885 let output = format!("{toml_src}{section}");
3886 Ok(MigrationResult {
3887 output,
3888 changed_count: 1,
3889 sections_changed: vec!["cocoon".to_owned()],
3890 })
3891}
3892
3893pub fn migrate_worktree_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3904 let commented_present = toml_src.lines().any(|l| l.trim() == "# [worktree]");
3908 if section_header_present(toml_src, "worktree") || commented_present {
3909 return Ok(MigrationResult {
3910 output: toml_src.to_owned(),
3911 changed_count: 0,
3912 sections_changed: Vec::new(),
3913 });
3914 }
3915
3916 let _doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3917
3918 let block = "\n# Native worktree isolation for background sub-agents (#4679).\n\
3919 # [worktree]\n\
3920 # enabled = false\n\
3921 # base_ref = \"head\"\n\
3922 # default_branch = \"main\"\n\
3923 # root = \".claude/worktrees\"\n\
3924 # branch_prefix = \"agent/\"\n\
3925 # prune_branch_on_remove = false\n\
3926 # cleanup_on_completion = true\n\
3927 # bg_isolation = \"worktree\"\n";
3928 let output = format!("{}{}", toml_src.trim_end(), block);
3929 Ok(MigrationResult {
3930 output,
3931 changed_count: 1,
3932 sections_changed: vec!["worktree".to_owned()],
3933 })
3934}
3935
3936pub fn migrate_worktree_git_timeout(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3948 static WORKTREE_HEADER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
3952 Regex::new(r"(?m)^[ \t]*\[worktree\][ \t]*(?:#[^\r\n]*)?\r?\n").expect("static pattern")
3953 });
3954
3955 if toml_src.contains("git_timeout_secs") || !WORKTREE_HEADER_RE.is_match(toml_src) {
3956 return Ok(MigrationResult {
3957 output: toml_src.to_owned(),
3958 changed_count: 0,
3959 sections_changed: Vec::new(),
3960 });
3961 }
3962
3963 let comment = "# git_timeout_secs = 30 \
3964 # per-command timeout for git invocations (seconds)\n";
3965 let output = WORKTREE_HEADER_RE
3967 .replacen(toml_src, 1, |caps: ®ex::Captures| {
3968 format!("{}{comment}", &caps[0])
3969 })
3970 .into_owned();
3971
3972 let changed = output != toml_src;
3973 Ok(MigrationResult {
3974 output,
3975 changed_count: usize::from(changed),
3976 sections_changed: if changed {
3977 vec!["worktree".to_owned()]
3978 } else {
3979 Vec::new()
3980 },
3981 })
3982}
3983
3984pub fn migrate_llm_stream_limits(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3995 if section_header_present(toml_src, "llm.stream_limits") || !toml_src.contains("[llm]") {
3996 return Ok(MigrationResult {
3997 output: toml_src.to_owned(),
3998 changed_count: 0,
3999 sections_changed: Vec::new(),
4000 });
4001 }
4002
4003 let comment = "\n# SSE streaming buffer caps (#4750). Defaults match pre-existing behavior.\n\
4004 # [llm.stream_limits]\n\
4005 # max_tool_json_bytes = 4194304 # 4 MiB\n\
4006 # max_thinking_bytes = 1048576 # 1 MiB\n\
4007 # max_compaction_bytes = 32768 # 32 KiB\n";
4008
4009 let output = format!("{toml_src}{comment}");
4010 Ok(MigrationResult {
4011 output,
4012 changed_count: 1,
4013 sections_changed: vec!["llm.stream_limits".to_owned()],
4014 })
4015}
4016
4017#[cfg(test)]
4019fn make_formatted_str(s: &str) -> Value {
4020 use toml_edit::Formatted;
4021 Value::String(Formatted::new(s.to_owned()))
4022}
4023
4024#[cfg(test)]
4025mod tests {
4026 use super::*;
4027
4028 #[test]
4029 fn migrations_registry_has_all_steps() {
4030 assert_eq!(
4031 MIGRATIONS.len(),
4032 56,
4033 "MIGRATIONS registry must contain all 56 sequential steps"
4034 );
4035 for m in MIGRATIONS.iter() {
4036 assert!(
4037 !m.name().is_empty(),
4038 "each migration must have a non-empty name"
4039 );
4040 }
4041 }
4042
4043 #[test]
4044 fn migrations_registry_applies_to_empty_config() {
4045 let mut toml = String::new();
4046 for m in MIGRATIONS.iter() {
4047 toml = m
4048 .apply(&toml)
4049 .expect("migration must not fail on empty config")
4050 .output;
4051 }
4052 toml.parse::<toml_edit::DocumentMut>()
4054 .expect("registry output must be valid TOML");
4055 }
4056
4057 #[test]
4058 fn empty_config_gets_sections_as_comments() {
4059 let migrator = ConfigMigrator::new();
4060 let result = migrator.migrate("").expect("migrate empty");
4061 assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
4063 assert!(
4065 result.output.contains("[agent]") || result.output.contains("# [agent]"),
4066 "expected agent section in output, got:\n{}",
4067 result.output
4068 );
4069 }
4070
4071 #[test]
4072 fn existing_values_not_overwritten() {
4073 let user = r#"
4074[agent]
4075name = "MyAgent"
4076max_tool_iterations = 5
4077"#;
4078 let migrator = ConfigMigrator::new();
4079 let result = migrator.migrate(user).expect("migrate");
4080 assert!(
4082 result.output.contains("name = \"MyAgent\""),
4083 "user value should be preserved"
4084 );
4085 assert!(
4086 result.output.contains("max_tool_iterations = 5"),
4087 "user value should be preserved"
4088 );
4089 assert!(
4091 !result.output.contains("# max_tool_iterations = 10"),
4092 "already-set key should not appear as comment"
4093 );
4094 }
4095
4096 #[test]
4097 fn missing_nested_key_added_as_comment() {
4098 let user = r#"
4100[memory]
4101sqlite_path = ".zeph/data/zeph.db"
4102"#;
4103 let migrator = ConfigMigrator::new();
4104 let result = migrator.migrate(user).expect("migrate");
4105 assert!(
4107 result.output.contains("# history_limit"),
4108 "missing key should be added as comment, got:\n{}",
4109 result.output
4110 );
4111 }
4112
4113 #[test]
4114 fn unknown_user_keys_preserved() {
4115 let user = r#"
4116[agent]
4117name = "Test"
4118my_custom_key = "preserved"
4119"#;
4120 let migrator = ConfigMigrator::new();
4121 let result = migrator.migrate(user).expect("migrate");
4122 assert!(
4123 result.output.contains("my_custom_key = \"preserved\""),
4124 "custom user keys must not be removed"
4125 );
4126 }
4127
4128 #[test]
4129 fn idempotent() {
4130 let migrator = ConfigMigrator::new();
4131 let first = migrator
4132 .migrate("[agent]\nname = \"Zeph\"\n")
4133 .expect("first migrate");
4134 let second = migrator.migrate(&first.output).expect("second migrate");
4135 assert_eq!(
4136 first.output, second.output,
4137 "idempotent: full output must be identical on second run"
4138 );
4139 }
4140
4141 #[test]
4142 fn malformed_input_returns_error() {
4143 let migrator = ConfigMigrator::new();
4144 let err = migrator
4145 .migrate("[[invalid toml [[[")
4146 .expect_err("should error");
4147 assert!(
4148 matches!(err, MigrateError::Parse(_)),
4149 "expected Parse error"
4150 );
4151 }
4152
4153 #[test]
4154 fn array_of_tables_preserved() {
4155 let user = r#"
4156[mcp]
4157allowed_commands = ["npx"]
4158
4159[[mcp.servers]]
4160id = "my-server"
4161command = "npx"
4162args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
4163"#;
4164 let migrator = ConfigMigrator::new();
4165 let result = migrator.migrate(user).expect("migrate");
4166 assert!(
4168 result.output.contains("[[mcp.servers]]"),
4169 "array-of-tables entries must be preserved"
4170 );
4171 assert!(result.output.contains("id = \"my-server\""));
4172 }
4173
4174 #[test]
4175 fn canonical_ordering_applied() {
4176 let user = r#"
4178[memory]
4179sqlite_path = ".zeph/data/zeph.db"
4180
4181[agent]
4182name = "Test"
4183"#;
4184 let migrator = ConfigMigrator::new();
4185 let result = migrator.migrate(user).expect("migrate");
4186 let agent_pos = result.output.find("[agent]");
4188 let memory_pos = result.output.find("[memory]");
4189 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
4190 assert!(a < m, "agent section should precede memory section");
4191 }
4192 }
4193
4194 #[test]
4195 fn value_to_toml_string_formats_correctly() {
4196 use toml_edit::Formatted;
4197
4198 let s = make_formatted_str("hello");
4199 assert_eq!(value_to_toml_string(&s), "\"hello\"");
4200
4201 let i = Value::Integer(Formatted::new(42_i64));
4202 assert_eq!(value_to_toml_string(&i), "42");
4203
4204 let b = Value::Boolean(Formatted::new(true));
4205 assert_eq!(value_to_toml_string(&b), "true");
4206
4207 let f = Value::Float(Formatted::new(1.0_f64));
4208 assert_eq!(value_to_toml_string(&f), "1.0");
4209
4210 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
4211 assert_eq!(value_to_toml_string(&f2), "3.14");
4212
4213 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
4214 let arr_val = Value::Array(arr);
4215 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
4216
4217 let empty_arr = Value::Array(Array::new());
4218 assert_eq!(value_to_toml_string(&empty_arr), "[]");
4219 }
4220
4221 #[test]
4222 fn idempotent_full_output_unchanged() {
4223 let migrator = ConfigMigrator::new();
4225 let first = migrator
4226 .migrate("[agent]\nname = \"Zeph\"\n")
4227 .expect("first migrate");
4228 let second = migrator.migrate(&first.output).expect("second migrate");
4229 assert_eq!(
4230 first.output, second.output,
4231 "full output string must be identical after second migration pass"
4232 );
4233 }
4234
4235 #[test]
4236 fn full_config_produces_zero_additions() {
4237 let reference = include_str!("../../config/default.toml");
4239 let migrator = ConfigMigrator::new();
4240 let result = migrator.migrate(reference).expect("migrate reference");
4241 assert_eq!(
4242 result.changed_count, 0,
4243 "migrating the canonical reference should add nothing (changed_count = {})",
4244 result.changed_count
4245 );
4246 assert!(
4247 result.sections_changed.is_empty(),
4248 "migrating the canonical reference should report no sections_changed: {:?}",
4249 result.sections_changed
4250 );
4251 }
4252
4253 #[test]
4254 fn empty_config_changed_count_is_positive() {
4255 let migrator = ConfigMigrator::new();
4257 let result = migrator.migrate("").expect("migrate empty");
4258 assert!(
4259 result.changed_count > 0,
4260 "empty config must report changed_count > 0"
4261 );
4262 }
4263
4264 #[test]
4267 fn security_without_guardrail_gets_guardrail_commented() {
4268 let user = "[security]\nredact_secrets = true\n";
4269 let migrator = ConfigMigrator::new();
4270 let result = migrator.migrate(user).expect("migrate");
4271 assert!(
4273 result.output.contains("guardrail"),
4274 "migration must add guardrail keys for configs without [security.guardrail]: \
4275 got:\n{}",
4276 result.output
4277 );
4278 }
4279
4280 #[test]
4281 fn migrate_reference_contains_tools_policy() {
4282 let reference = include_str!("../../config/default.toml");
4287 assert!(
4288 reference.contains("[tools.policy]"),
4289 "default.toml must contain [tools.policy] section so migrate-config can surface it"
4290 );
4291 assert!(
4292 reference.contains("enabled = false"),
4293 "tools.policy section must include enabled = false default"
4294 );
4295 }
4296
4297 #[test]
4298 fn migrate_reference_contains_probe_section() {
4299 let reference = include_str!("../../config/default.toml");
4302 assert!(
4303 reference.contains("[memory.compression.probe]"),
4304 "default.toml must contain [memory.compression.probe] section comment"
4305 );
4306 assert!(
4307 reference.contains("hard_fail_threshold"),
4308 "probe section must include hard_fail_threshold default"
4309 );
4310 }
4311
4312 #[test]
4315 fn migrate_llm_no_llm_section_is_noop() {
4316 let src = "[agent]\nname = \"Zeph\"\n";
4317 let result = migrate_llm_to_providers(src).expect("migrate");
4318 assert_eq!(result.changed_count, 0);
4319 assert_eq!(result.output, src);
4320 }
4321
4322 #[test]
4323 fn migrate_llm_already_new_format_is_noop() {
4324 let src = r#"
4325[llm]
4326[[llm.providers]]
4327type = "ollama"
4328model = "qwen3:8b"
4329"#;
4330 let result = migrate_llm_to_providers(src).expect("migrate");
4331 assert_eq!(result.changed_count, 0);
4332 }
4333
4334 #[test]
4335 fn migrate_llm_ollama_produces_providers_block() {
4336 let src = r#"
4337[llm]
4338provider = "ollama"
4339model = "qwen3:8b"
4340base_url = "http://localhost:11434"
4341embedding_model = "nomic-embed-text"
4342"#;
4343 let result = migrate_llm_to_providers(src).expect("migrate");
4344 assert!(
4345 result.output.contains("[[llm.providers]]"),
4346 "should contain [[llm.providers]]:\n{}",
4347 result.output
4348 );
4349 assert!(
4350 result.output.contains("type = \"ollama\""),
4351 "{}",
4352 result.output
4353 );
4354 assert!(
4355 result.output.contains("model = \"qwen3:8b\""),
4356 "{}",
4357 result.output
4358 );
4359 }
4360
4361 #[test]
4362 fn migrate_llm_claude_produces_providers_block() {
4363 let src = r#"
4364[llm]
4365provider = "claude"
4366
4367[llm.cloud]
4368model = "claude-sonnet-4-6"
4369max_tokens = 8192
4370server_compaction = true
4371"#;
4372 let result = migrate_llm_to_providers(src).expect("migrate");
4373 assert!(
4374 result.output.contains("[[llm.providers]]"),
4375 "{}",
4376 result.output
4377 );
4378 assert!(
4379 result.output.contains("type = \"claude\""),
4380 "{}",
4381 result.output
4382 );
4383 assert!(
4384 result.output.contains("model = \"claude-sonnet-4-6\""),
4385 "{}",
4386 result.output
4387 );
4388 assert!(
4389 result.output.contains("server_compaction = true"),
4390 "{}",
4391 result.output
4392 );
4393 }
4394
4395 #[test]
4396 fn migrate_llm_openai_copies_fields() {
4397 let src = r#"
4398[llm]
4399provider = "openai"
4400
4401[llm.openai]
4402base_url = "https://api.openai.com/v1"
4403model = "gpt-4o"
4404max_tokens = 4096
4405"#;
4406 let result = migrate_llm_to_providers(src).expect("migrate");
4407 assert!(
4408 result.output.contains("type = \"openai\""),
4409 "{}",
4410 result.output
4411 );
4412 assert!(
4413 result
4414 .output
4415 .contains("base_url = \"https://api.openai.com/v1\""),
4416 "{}",
4417 result.output
4418 );
4419 }
4420
4421 #[test]
4422 fn migrate_llm_gemini_copies_fields() {
4423 let src = r#"
4424[llm]
4425provider = "gemini"
4426
4427[llm.gemini]
4428model = "gemini-2.0-flash"
4429max_tokens = 8192
4430base_url = "https://generativelanguage.googleapis.com"
4431"#;
4432 let result = migrate_llm_to_providers(src).expect("migrate");
4433 assert!(
4434 result.output.contains("type = \"gemini\""),
4435 "{}",
4436 result.output
4437 );
4438 assert!(
4439 result.output.contains("model = \"gemini-2.0-flash\""),
4440 "{}",
4441 result.output
4442 );
4443 }
4444
4445 #[test]
4446 fn migrate_llm_compatible_copies_multiple_entries() {
4447 let src = r#"
4448[llm]
4449provider = "compatible"
4450
4451[[llm.compatible]]
4452name = "proxy-a"
4453base_url = "http://proxy-a:8080/v1"
4454model = "llama3"
4455max_tokens = 4096
4456
4457[[llm.compatible]]
4458name = "proxy-b"
4459base_url = "http://proxy-b:8080/v1"
4460model = "mistral"
4461max_tokens = 2048
4462"#;
4463 let result = migrate_llm_to_providers(src).expect("migrate");
4464 let count = result.output.matches("[[llm.providers]]").count();
4466 assert_eq!(
4467 count, 2,
4468 "expected 2 [[llm.providers]] blocks:\n{}",
4469 result.output
4470 );
4471 assert!(
4472 result.output.contains("name = \"proxy-a\""),
4473 "{}",
4474 result.output
4475 );
4476 assert!(
4477 result.output.contains("name = \"proxy-b\""),
4478 "{}",
4479 result.output
4480 );
4481 }
4482
4483 #[test]
4484 fn migrate_llm_mixed_format_errors() {
4485 let src = r#"
4487[llm]
4488provider = "ollama"
4489
4490[[llm.providers]]
4491type = "ollama"
4492"#;
4493 assert!(
4494 migrate_llm_to_providers(src).is_err(),
4495 "mixed format must return error"
4496 );
4497 }
4498
4499 #[test]
4502 fn stt_migration_no_stt_section_returns_unchanged() {
4503 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
4504 let result = migrate_stt_to_provider(src).unwrap();
4505 assert_eq!(result.changed_count, 0);
4506 assert_eq!(result.output, src);
4507 }
4508
4509 #[test]
4510 fn stt_migration_no_model_or_base_url_returns_unchanged() {
4511 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
4512 let result = migrate_stt_to_provider(src).unwrap();
4513 assert_eq!(result.changed_count, 0);
4514 }
4515
4516 #[test]
4517 fn stt_migration_moves_model_to_provider_entry() {
4518 let src = r#"
4519[llm]
4520
4521[[llm.providers]]
4522type = "openai"
4523name = "quality"
4524model = "gpt-5.4"
4525
4526[llm.stt]
4527provider = "quality"
4528model = "gpt-4o-mini-transcribe"
4529language = "en"
4530"#;
4531 let result = migrate_stt_to_provider(src).unwrap();
4532 assert_eq!(result.changed_count, 1);
4533 assert!(
4535 result.output.contains("stt_model"),
4536 "stt_model must be in output"
4537 );
4538 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4541 let stt = doc
4542 .get("llm")
4543 .and_then(toml_edit::Item::as_table)
4544 .and_then(|l| l.get("stt"))
4545 .and_then(toml_edit::Item::as_table)
4546 .unwrap();
4547 assert!(
4548 stt.get("model").is_none(),
4549 "model must be removed from [llm.stt]"
4550 );
4551 assert_eq!(
4552 stt.get("provider").and_then(toml_edit::Item::as_str),
4553 Some("quality")
4554 );
4555 }
4556
4557 #[test]
4558 fn stt_migration_creates_new_provider_when_no_match() {
4559 let src = r#"
4560[llm]
4561
4562[[llm.providers]]
4563type = "ollama"
4564name = "local"
4565model = "qwen3:8b"
4566
4567[llm.stt]
4568provider = "whisper"
4569model = "whisper-1"
4570base_url = "https://api.openai.com/v1"
4571language = "en"
4572"#;
4573 let result = migrate_stt_to_provider(src).unwrap();
4574 assert!(
4575 result.output.contains("openai-stt"),
4576 "new entry name must be openai-stt"
4577 );
4578 assert!(
4579 result.output.contains("stt_model"),
4580 "stt_model must be in output"
4581 );
4582 }
4583
4584 #[test]
4585 fn stt_migration_candle_whisper_creates_candle_entry() {
4586 let src = r#"
4587[llm]
4588
4589[llm.stt]
4590provider = "candle-whisper"
4591model = "openai/whisper-tiny"
4592language = "auto"
4593"#;
4594 let result = migrate_stt_to_provider(src).unwrap();
4595 assert!(
4596 result.output.contains("local-whisper"),
4597 "candle entry name must be local-whisper"
4598 );
4599 assert!(result.output.contains("candle"), "type must be candle");
4600 }
4601
4602 #[test]
4603 fn stt_migration_w2_assigns_explicit_name() {
4604 let src = r#"
4606[llm]
4607
4608[[llm.providers]]
4609type = "openai"
4610model = "gpt-5.4"
4611
4612[llm.stt]
4613provider = "openai"
4614model = "whisper-1"
4615language = "auto"
4616"#;
4617 let result = migrate_stt_to_provider(src).unwrap();
4618 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4619 let providers = doc
4620 .get("llm")
4621 .and_then(toml_edit::Item::as_table)
4622 .and_then(|l| l.get("providers"))
4623 .and_then(toml_edit::Item::as_array_of_tables)
4624 .unwrap();
4625 let entry = providers
4626 .iter()
4627 .find(|t| t.get("stt_model").is_some())
4628 .unwrap();
4629 assert!(
4631 entry.get("name").is_some(),
4632 "migrated entry must have explicit name"
4633 );
4634 }
4635
4636 #[test]
4637 fn stt_migration_removes_base_url_from_stt_table() {
4638 let src = r#"
4640[llm]
4641
4642[[llm.providers]]
4643type = "openai"
4644name = "quality"
4645model = "gpt-5.4"
4646
4647[llm.stt]
4648provider = "quality"
4649model = "whisper-1"
4650base_url = "https://api.openai.com/v1"
4651language = "en"
4652"#;
4653 let result = migrate_stt_to_provider(src).unwrap();
4654 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4655 let stt = doc
4656 .get("llm")
4657 .and_then(toml_edit::Item::as_table)
4658 .and_then(|l| l.get("stt"))
4659 .and_then(toml_edit::Item::as_table)
4660 .unwrap();
4661 assert!(
4662 stt.get("model").is_none(),
4663 "model must be removed from [llm.stt]"
4664 );
4665 assert!(
4666 stt.get("base_url").is_none(),
4667 "base_url must be removed from [llm.stt]"
4668 );
4669 }
4670
4671 #[test]
4672 fn migrate_planner_model_to_provider_with_field() {
4673 let input = r#"
4674[orchestration]
4675enabled = true
4676planner_model = "gpt-4o"
4677max_tasks = 20
4678"#;
4679 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4680 assert_eq!(result.changed_count, 1, "changed_count must be 1");
4681 assert!(
4682 !result.output.contains("planner_model = "),
4683 "planner_model key must be removed from output"
4684 );
4685 assert!(
4686 result.output.contains("# planner_provider"),
4687 "commented-out planner_provider entry must be present"
4688 );
4689 assert!(
4690 result.output.contains("gpt-4o"),
4691 "old value must appear in the comment"
4692 );
4693 assert!(
4694 result.output.contains("MIGRATED"),
4695 "comment must include MIGRATED marker"
4696 );
4697 }
4698
4699 #[test]
4700 fn migrate_planner_model_to_provider_no_op() {
4701 let input = r"
4702[orchestration]
4703enabled = true
4704max_tasks = 20
4705";
4706 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4707 assert_eq!(
4708 result.changed_count, 0,
4709 "changed_count must be 0 when field is absent"
4710 );
4711 assert_eq!(
4712 result.output, input,
4713 "output must equal input when nothing to migrate"
4714 );
4715 }
4716
4717 #[test]
4718 fn migrate_error_invalid_structure_formats_correctly() {
4719 let err = MigrateError::InvalidStructure("test sentinel");
4724 assert!(
4725 matches!(err, MigrateError::InvalidStructure(_)),
4726 "variant must match"
4727 );
4728 let msg = err.to_string();
4729 assert!(
4730 msg.contains("invalid TOML structure"),
4731 "error message must mention 'invalid TOML structure', got: {msg}"
4732 );
4733 assert!(
4734 msg.contains("test sentinel"),
4735 "message must include reason: {msg}"
4736 );
4737 }
4738
4739 #[test]
4742 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
4743 let src = r#"
4744[mcp]
4745allowed_commands = ["npx"]
4746
4747[[mcp.servers]]
4748id = "srv-a"
4749command = "npx"
4750args = ["-y", "some-mcp"]
4751
4752[[mcp.servers]]
4753id = "srv-b"
4754command = "npx"
4755args = ["-y", "other-mcp"]
4756"#;
4757 let result = migrate_mcp_trust_levels(src).expect("migrate");
4758 assert_eq!(
4759 result.changed_count, 2,
4760 "both entries must get trust_level added"
4761 );
4762 assert!(
4763 result
4764 .sections_changed
4765 .contains(&"mcp.servers.trust_level".to_owned()),
4766 "sections_changed must report mcp.servers.trust_level"
4767 );
4768 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
4770 assert_eq!(
4771 occurrences, 2,
4772 "each entry must have trust_level = \"trusted\""
4773 );
4774 }
4775
4776 #[test]
4777 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
4778 let src = r#"
4779[[mcp.servers]]
4780id = "srv-a"
4781command = "npx"
4782trust_level = "sandboxed"
4783tool_allowlist = ["read_file"]
4784
4785[[mcp.servers]]
4786id = "srv-b"
4787command = "npx"
4788"#;
4789 let result = migrate_mcp_trust_levels(src).expect("migrate");
4790 assert_eq!(
4792 result.changed_count, 1,
4793 "only entry without trust_level gets updated"
4794 );
4795 assert!(
4797 result.output.contains("trust_level = \"sandboxed\""),
4798 "existing trust_level must not be overwritten"
4799 );
4800 assert!(
4802 result.output.contains("trust_level = \"trusted\""),
4803 "entry without trust_level must get trusted"
4804 );
4805 }
4806
4807 #[test]
4808 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4809 let src = "[agent]\nname = \"Zeph\"\n";
4810 let result = migrate_mcp_trust_levels(src).expect("migrate");
4811 assert_eq!(result.changed_count, 0);
4812 assert!(result.sections_changed.is_empty());
4813 assert_eq!(result.output, src);
4814 }
4815
4816 #[test]
4817 fn migrate_mcp_trust_levels_no_servers_is_noop() {
4818 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4819 let result = migrate_mcp_trust_levels(src).expect("migrate");
4820 assert_eq!(result.changed_count, 0);
4821 assert!(result.sections_changed.is_empty());
4822 assert_eq!(result.output, src);
4823 }
4824
4825 #[test]
4826 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4827 let src = r#"
4828[[mcp.servers]]
4829id = "srv-a"
4830trust_level = "trusted"
4831
4832[[mcp.servers]]
4833id = "srv-b"
4834trust_level = "untrusted"
4835"#;
4836 let result = migrate_mcp_trust_levels(src).expect("migrate");
4837 assert_eq!(result.changed_count, 0);
4838 assert!(result.sections_changed.is_empty());
4839 }
4840
4841 #[test]
4842 fn migrate_database_url_adds_comment_when_absent() {
4843 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4844 let result = migrate_database_url(src).expect("migrate");
4845 assert_eq!(result.changed_count, 1);
4846 assert!(
4847 result
4848 .sections_changed
4849 .contains(&"memory.database_url".to_owned())
4850 );
4851 assert!(result.output.contains("# database_url = \"\""));
4852 }
4853
4854 #[test]
4855 fn migrate_database_url_is_noop_when_present() {
4856 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4857 let result = migrate_database_url(src).expect("migrate");
4858 assert_eq!(result.changed_count, 0);
4859 assert!(result.sections_changed.is_empty());
4860 assert_eq!(result.output, src);
4861 }
4862
4863 #[test]
4864 fn migrate_database_url_creates_memory_section_when_absent() {
4865 let src = "[agent]\nname = \"Zeph\"\n";
4866 let result = migrate_database_url(src).expect("migrate");
4867 assert_eq!(result.changed_count, 1);
4868 assert!(result.output.contains("# database_url = \"\""));
4869 }
4870
4871 #[test]
4874 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4875 let src = "[agent]\nname = \"Zeph\"\n";
4876 let result = migrate_agent_budget_hint(src).expect("migrate");
4877 assert_eq!(result.changed_count, 1);
4878 assert!(result.output.contains("budget_hint_enabled"));
4879 assert!(
4880 result
4881 .sections_changed
4882 .contains(&"agent.budget_hint_enabled".to_owned())
4883 );
4884 }
4885
4886 #[test]
4887 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4888 let src = "[llm]\nmodel = \"gpt-4o\"\n";
4889 let result = migrate_agent_budget_hint(src).expect("migrate");
4890 assert_eq!(result.changed_count, 0);
4891 assert_eq!(result.output, src);
4892 }
4893
4894 #[test]
4895 fn migrate_agent_budget_hint_already_present_is_noop() {
4896 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4897 let result = migrate_agent_budget_hint(src).expect("migrate");
4898 assert_eq!(result.changed_count, 0);
4899 assert_eq!(result.output, src);
4900 }
4901
4902 #[test]
4903 fn migrate_telemetry_config_empty_config_appends_comment_block() {
4904 let src = "[agent]\nname = \"Zeph\"\n";
4905 let result = migrate_telemetry_config(src).expect("migrate");
4906 assert_eq!(result.changed_count, 1);
4907 assert_eq!(result.sections_changed, vec!["telemetry"]);
4908 assert!(
4909 result.output.contains("# [telemetry]"),
4910 "expected commented-out [telemetry] block in output"
4911 );
4912 assert!(
4913 result.output.contains("enabled = false"),
4914 "expected enabled = false in telemetry comment block"
4915 );
4916 }
4917
4918 #[test]
4919 fn migrate_telemetry_config_existing_section_is_noop() {
4920 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4921 let result = migrate_telemetry_config(src).expect("migrate");
4922 assert_eq!(result.changed_count, 0);
4923 assert_eq!(result.output, src);
4924 }
4925
4926 #[test]
4927 fn migrate_telemetry_config_existing_comment_is_noop() {
4928 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4930 let result = migrate_telemetry_config(src).expect("migrate");
4931 assert_eq!(result.changed_count, 0);
4932 assert_eq!(result.output, src);
4933 }
4934
4935 #[test]
4938 fn migrate_otel_filter_already_present_is_noop() {
4939 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4941 let result = migrate_otel_filter(src).expect("migrate");
4942 assert_eq!(result.changed_count, 0);
4943 assert_eq!(result.output, src);
4944 }
4945
4946 #[test]
4947 fn migrate_otel_filter_commented_key_is_noop() {
4948 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4950 let result = migrate_otel_filter(src).expect("migrate");
4951 assert_eq!(result.changed_count, 0);
4952 assert_eq!(result.output, src);
4953 }
4954
4955 #[test]
4956 fn migrate_otel_filter_no_telemetry_section_is_noop() {
4957 let src = "[agent]\nname = \"Zeph\"\n";
4959 let result = migrate_otel_filter(src).expect("migrate");
4960 assert_eq!(result.changed_count, 0);
4961 assert_eq!(result.output, src);
4962 assert!(!result.output.contains("otel_filter"));
4963 }
4964
4965 #[test]
4966 fn migrate_otel_filter_injects_within_telemetry_section() {
4967 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4968 let result = migrate_otel_filter(src).expect("migrate");
4969 assert_eq!(result.changed_count, 1);
4970 assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4971 assert!(
4972 result.output.contains("otel_filter"),
4973 "otel_filter comment must appear"
4974 );
4975 let otel_pos = result
4977 .output
4978 .find("otel_filter")
4979 .expect("otel_filter present");
4980 let agent_pos = result.output.find("[agent]").expect("[agent] present");
4981 assert!(
4982 otel_pos < agent_pos,
4983 "otel_filter comment should appear before [agent] section"
4984 );
4985 }
4986
4987 #[test]
4988 fn sandbox_migration_adds_commented_section_when_absent() {
4989 let src = "[agent]\nname = \"Z\"\n";
4990 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4991 assert_eq!(result.changed_count, 1);
4992 assert!(result.output.contains("# [tools.sandbox]"));
4993 assert!(result.output.contains("# profile = \"workspace\""));
4994 }
4995
4996 #[test]
4997 fn sandbox_migration_noop_when_section_present() {
4998 let src = "[tools.sandbox]\nenabled = true\n";
4999 let result = migrate_sandbox_config(src).expect("migrate sandbox");
5000 assert_eq!(result.changed_count, 0);
5001 }
5002
5003 #[test]
5004 fn sandbox_migration_noop_when_dotted_key_present() {
5005 let src = "[tools]\nsandbox = { enabled = true }\n";
5006 let result = migrate_sandbox_config(src).expect("migrate sandbox");
5007 assert_eq!(result.changed_count, 0);
5008 }
5009
5010 #[test]
5011 fn sandbox_migration_false_positive_comment_does_not_block() {
5012 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
5014 let result = migrate_sandbox_config(src).expect("migrate sandbox");
5015 assert_eq!(result.changed_count, 1);
5016 }
5017
5018 #[test]
5019 fn embedded_default_mentions_tools_sandbox() {
5020 let default_src = include_str!("../../config/default.toml");
5021 assert!(
5022 default_src.contains("tools.sandbox"),
5023 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
5024 );
5025 }
5026
5027 #[test]
5028 fn sandbox_migration_idempotent_on_own_output() {
5029 let base = "[agent]\nmodel = \"test\"\n";
5030 let first = migrate_sandbox_config(base).unwrap();
5031 assert_eq!(first.changed_count, 1);
5032 let second = migrate_sandbox_config(&first.output).unwrap();
5033 assert_eq!(second.changed_count, 0, "second run must not double-append");
5034 assert_eq!(second.output, first.output);
5035 }
5036
5037 #[test]
5038 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
5039 let base = "[agent]\nname = \"Zeph\"\n";
5040 let first = migrate_agent_budget_hint(base).unwrap();
5041 assert_eq!(first.changed_count, 1);
5042 let second = migrate_agent_budget_hint(&first.output).unwrap();
5043 assert_eq!(second.changed_count, 0, "second run must not double-append");
5044 assert_eq!(second.output, first.output);
5045 }
5046
5047 #[test]
5048 fn migrate_forgetting_config_idempotent_on_commented_output() {
5049 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
5050 let first = migrate_forgetting_config(base).unwrap();
5051 assert_eq!(first.changed_count, 1);
5052 let second = migrate_forgetting_config(&first.output).unwrap();
5053 assert_eq!(second.changed_count, 0, "second run must not double-append");
5054 assert_eq!(second.output, first.output);
5055 }
5056
5057 #[test]
5058 fn migrate_microcompact_config_idempotent_on_commented_output() {
5059 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
5060 let first = migrate_microcompact_config(base).unwrap();
5061 assert_eq!(first.changed_count, 1);
5062 let second = migrate_microcompact_config(&first.output).unwrap();
5063 assert_eq!(second.changed_count, 0, "second run must not double-append");
5064 assert_eq!(second.output, first.output);
5065 }
5066
5067 #[test]
5068 fn migrate_autodream_config_idempotent_on_commented_output() {
5069 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
5070 let first = migrate_autodream_config(base).unwrap();
5071 assert_eq!(first.changed_count, 1);
5072 let second = migrate_autodream_config(&first.output).unwrap();
5073 assert_eq!(second.changed_count, 0, "second run must not double-append");
5074 assert_eq!(second.output, first.output);
5075 }
5076
5077 #[test]
5078 fn migrate_compression_predictor_strips_active_section() {
5079 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
5080 let result = migrate_compression_predictor_config(base).unwrap();
5081 assert!(!result.output.contains("[memory.compression.predictor]"));
5082 assert!(!result.output.contains("min_samples"));
5083 assert!(result.output.contains("[memory.other]"));
5084 assert_eq!(result.changed_count, 1);
5085 }
5086
5087 #[test]
5088 fn migrate_compression_predictor_strips_commented_section() {
5089 let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
5090 let result = migrate_compression_predictor_config(base).unwrap();
5091 assert!(!result.output.contains("compression.predictor"));
5092 assert!(result.output.contains("[memory.other]"));
5093 }
5094
5095 #[test]
5096 fn migrate_compression_predictor_idempotent() {
5097 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
5098 let first = migrate_compression_predictor_config(base).unwrap();
5099 let second = migrate_compression_predictor_config(&first.output).unwrap();
5100 assert_eq!(second.output, first.output);
5101 assert_eq!(second.changed_count, 0);
5102 }
5103
5104 #[test]
5105 fn migrate_compression_predictor_noop_when_absent() {
5106 let base = "[memory]\ndb_path = \"test\"\n";
5107 let result = migrate_compression_predictor_config(base).unwrap();
5108 assert_eq!(result.output, base);
5109 assert_eq!(result.changed_count, 0);
5110 }
5111
5112 #[test]
5113 fn migrate_database_url_idempotent_on_commented_output() {
5114 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
5115 let first = migrate_database_url(base).unwrap();
5116 assert_eq!(first.changed_count, 1);
5117 let second = migrate_database_url(&first.output).unwrap();
5118 assert_eq!(second.changed_count, 0, "second run must not double-append");
5119 assert_eq!(second.output, first.output);
5120 }
5121
5122 #[test]
5123 fn migrate_shell_transactional_idempotent_on_commented_output() {
5124 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
5125 let first = migrate_shell_transactional(base).unwrap();
5126 assert_eq!(first.changed_count, 1);
5127 let second = migrate_shell_transactional(&first.output).unwrap();
5128 assert_eq!(second.changed_count, 0, "second run must not double-append");
5129 assert_eq!(second.output, first.output);
5130 }
5131
5132 #[test]
5133 fn migrate_otel_filter_idempotent_on_commented_output() {
5134 let base = "[telemetry]\nenabled = true\n";
5135 let first = migrate_otel_filter(base).unwrap();
5136 assert_eq!(first.changed_count, 1);
5137 let second = migrate_otel_filter(&first.output).unwrap();
5138 assert_eq!(second.changed_count, 0, "second run must not double-append");
5139 assert_eq!(second.output, first.output);
5140 }
5141
5142 #[test]
5143 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
5144 let migrator = ConfigMigrator::new();
5145 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
5146 let result = migrator.migrate(src).expect("migrate");
5147 let sec_body_start = result
5148 .output
5149 .find("[security.content_isolation]")
5150 .unwrap_or(0);
5151 let sec_body = &result.output[sec_body_start..];
5152 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
5153 let sec_slice = &sec_body[..next_header];
5154 assert!(
5155 sec_slice.contains("# enabled"),
5156 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
5157 );
5158 }
5159
5160 #[test]
5161 fn config_migrator_idempotent_on_realistic_config() {
5162 let base = r#"
5163[agent]
5164name = "Zeph"
5165
5166[memory]
5167db_path = "~/.zeph/memory.db"
5168soft_compaction_threshold = 0.6
5169
5170[index]
5171max_chunks = 12
5172
5173[tools]
5174[tools.shell]
5175allow_list = []
5176
5177[telemetry]
5178enabled = false
5179
5180[security]
5181[security.content_isolation]
5182enabled = true
5183"#;
5184 let migrator = ConfigMigrator::new();
5185 let first = migrator.migrate(base).expect("first migrate");
5186 let second = migrator.migrate(&first.output).expect("second migrate");
5187 assert_eq!(
5188 second.changed_count, 0,
5189 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
5190 second.changed_count
5191 );
5192 assert_eq!(
5193 first.output, second.output,
5194 "output must be identical on second run"
5195 );
5196 for line in first.output.lines() {
5197 if line.starts_with('[') && !line.starts_with("[[") {
5198 assert!(
5199 !line.contains('#'),
5200 "section header must not have inline comment: {line:?}"
5201 );
5202 }
5203 }
5204 }
5205
5206 #[test]
5207 fn migrate_claude_prompt_cache_ttl_1h_survives() {
5208 let src = r#"
5209[llm]
5210provider = "claude"
5211
5212[llm.cloud]
5213model = "claude-sonnet-4-6"
5214prompt_cache_ttl = "1h"
5215"#;
5216 let result = migrate_llm_to_providers(src).expect("migrate");
5217 assert!(
5218 result.output.contains("prompt_cache_ttl = \"1h\""),
5219 "1h TTL must be preserved in migrated output:\n{}",
5220 result.output
5221 );
5222 }
5223
5224 #[test]
5225 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
5226 let src = r#"
5227[llm]
5228provider = "claude"
5229
5230[llm.cloud]
5231model = "claude-sonnet-4-6"
5232prompt_cache_ttl = "ephemeral"
5233"#;
5234 let result = migrate_llm_to_providers(src).expect("migrate");
5235 assert!(
5236 !result.output.contains("prompt_cache_ttl"),
5237 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
5238 result.output
5239 );
5240 }
5241
5242 #[test]
5243 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
5244 let src = r#"
5245[[llm.providers]]
5246type = "claude"
5247model = "claude-sonnet-4-6"
5248prompt_cache_ttl = "1h"
5249"#;
5250 let migrator = ConfigMigrator::new();
5251 let first = migrator.migrate(src).expect("first migrate");
5252 let second = migrator.migrate(&first.output).expect("second migrate");
5253 assert_eq!(
5254 first.output, second.output,
5255 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
5256 );
5257 }
5258
5259 #[test]
5262 fn migrate_session_recap_adds_block_when_absent() {
5263 let src = "[agent]\nname = \"Zeph\"\n";
5264 let result = migrate_session_recap_config(src).expect("migrate");
5265 assert_eq!(result.changed_count, 1);
5266 assert!(
5267 result
5268 .sections_changed
5269 .contains(&"session.recap".to_owned())
5270 );
5271 assert!(result.output.contains("# [session.recap]"));
5272 assert!(result.output.contains("on_resume = true"));
5273 }
5274
5275 #[test]
5276 fn migrate_session_recap_idempotent_on_commented_block() {
5277 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
5278 let result = migrate_session_recap_config(src).expect("migrate");
5279 assert_eq!(result.changed_count, 0);
5280 assert_eq!(result.output, src);
5281 }
5282
5283 #[test]
5284 fn migrate_session_recap_idempotent_on_active_section() {
5285 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
5286 let result = migrate_session_recap_config(src).expect("migrate");
5287 assert_eq!(result.changed_count, 0);
5288 assert_eq!(result.output, src);
5289 }
5290
5291 #[test]
5294 fn migrate_mcp_elicitation_adds_keys_when_absent() {
5295 let src = "[mcp]\nallowed_commands = []\n";
5296 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5297 assert_eq!(result.changed_count, 1);
5298 assert!(
5299 result
5300 .sections_changed
5301 .contains(&"mcp.elicitation".to_owned())
5302 );
5303 assert!(result.output.contains("# elicitation_enabled = false"));
5304 assert!(result.output.contains("# elicitation_timeout = 120"));
5305 }
5306
5307 #[test]
5308 fn migrate_mcp_elicitation_idempotent_when_key_present() {
5309 let src = "[mcp]\nelicitation_enabled = true\n";
5310 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5311 assert_eq!(result.changed_count, 0);
5312 assert_eq!(result.output, src);
5313 }
5314
5315 #[test]
5316 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
5317 let src = "[agent]\nname = \"Zeph\"\n";
5318 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5319 assert_eq!(result.changed_count, 0);
5320 assert_eq!(result.output, src);
5321 }
5322
5323 #[test]
5324 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
5325 let src = "[mcp]";
5327 let result = migrate_mcp_elicitation_config(src).expect("migrate");
5328 assert_eq!(result.changed_count, 0);
5329 assert_eq!(result.output, src);
5330 }
5331
5332 #[test]
5335 fn migrate_quality_adds_block_when_absent() {
5336 let src = "[agent]\nname = \"Zeph\"\n";
5337 let result = migrate_quality_config(src).expect("migrate");
5338 assert_eq!(result.changed_count, 1);
5339 assert!(result.sections_changed.contains(&"quality".to_owned()));
5340 assert!(result.output.contains("# [quality]"));
5341 assert!(result.output.contains("self_check = false"));
5342 assert!(result.output.contains("trigger = \"has_retrieval\""));
5343 }
5344
5345 #[test]
5346 fn migrate_quality_idempotent_on_commented_block() {
5347 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
5348 let result = migrate_quality_config(src).expect("migrate");
5349 assert_eq!(result.changed_count, 0);
5350 assert_eq!(result.output, src);
5351 }
5352
5353 #[test]
5354 fn migrate_quality_idempotent_on_active_section() {
5355 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
5356 let result = migrate_quality_config(src).expect("migrate");
5357 assert_eq!(result.changed_count, 0);
5358 assert_eq!(result.output, src);
5359 }
5360
5361 #[test]
5364 fn migrate_acp_subagents_adds_block_when_absent() {
5365 let src = "[agent]\nname = \"Zeph\"\n";
5366 let result = migrate_acp_subagents_config(src).expect("migrate");
5367 assert_eq!(result.changed_count, 1);
5368 assert!(
5369 result
5370 .sections_changed
5371 .contains(&"acp.subagents".to_owned())
5372 );
5373 assert!(result.output.contains("# [acp.subagents]"));
5374 assert!(result.output.contains("enabled = false"));
5375 }
5376
5377 #[test]
5378 fn migrate_acp_subagents_idempotent_on_existing_block() {
5379 let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
5380 let result = migrate_acp_subagents_config(src).expect("migrate");
5381 assert_eq!(result.changed_count, 0);
5382 assert_eq!(result.output, src);
5383 }
5384
5385 #[test]
5388 fn migrate_hooks_permission_denied_adds_block_when_absent() {
5389 let src = "[agent]\nname = \"Zeph\"\n";
5390 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
5391 assert_eq!(result.changed_count, 1);
5392 assert!(
5393 result
5394 .sections_changed
5395 .contains(&"hooks.permission_denied".to_owned())
5396 );
5397 assert!(result.output.contains("# [[hooks.permission_denied]]"));
5398 assert!(result.output.contains("ZEPH_TOOL"));
5399 }
5400
5401 #[test]
5402 fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
5403 let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
5404 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
5405 assert_eq!(result.changed_count, 0);
5406 assert_eq!(result.output, src);
5407 }
5408
5409 #[test]
5412 fn migrate_memory_graph_adds_block_when_absent() {
5413 let src = "[agent]\nname = \"Zeph\"\n";
5414 let result = migrate_memory_graph_config(src).expect("migrate");
5415 assert_eq!(result.changed_count, 1);
5416 assert!(
5417 result
5418 .sections_changed
5419 .contains(&"memory.graph.retrieval".to_owned())
5420 );
5421 assert!(result.output.contains("retrieval_strategy"));
5422 assert!(result.output.contains("# [memory.graph.beam_search]"));
5423 }
5424
5425 #[test]
5426 fn migrate_memory_graph_idempotent_on_existing_block() {
5427 let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
5428 let result = migrate_memory_graph_config(src).expect("migrate");
5429 assert_eq!(result.changed_count, 0);
5430 assert_eq!(result.output, src);
5431 }
5432
5433 #[test]
5436 fn migrate_scheduler_daemon_adds_block_when_absent() {
5437 let src = "[agent]\nname = \"Zeph\"\n";
5438 let result = migrate_scheduler_daemon_config(src).expect("migrate");
5439 assert_eq!(result.changed_count, 1);
5440 assert!(
5441 result
5442 .sections_changed
5443 .contains(&"scheduler.daemon".to_owned())
5444 );
5445 assert!(result.output.contains("# [scheduler.daemon]"));
5446 assert!(result.output.contains("pid_file"));
5447 assert!(result.output.contains("tick_secs = 60"));
5448 assert!(result.output.contains("shutdown_grace_secs = 30"));
5449 assert!(result.output.contains("catch_up = true"));
5450 }
5451
5452 #[test]
5453 fn migrate_scheduler_daemon_idempotent_on_existing_block() {
5454 let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
5455 let result = migrate_scheduler_daemon_config(src).expect("migrate");
5456 assert_eq!(result.changed_count, 0);
5457 assert_eq!(result.output, src);
5458 }
5459
5460 #[test]
5463 fn migrate_memory_retrieval_adds_block_when_absent() {
5464 let src = "[agent]\nname = \"Zeph\"\n";
5465 let result = migrate_memory_retrieval_config(src).expect("migrate");
5466 assert_eq!(result.changed_count, 1);
5467 assert!(
5468 result
5469 .sections_changed
5470 .contains(&"memory.retrieval".to_owned())
5471 );
5472 assert!(result.output.contains("# [memory.retrieval]"));
5473 assert!(result.output.contains("depth = 0"));
5474 assert!(result.output.contains("context_format"));
5475 }
5476
5477 #[test]
5478 fn migrate_memory_retrieval_idempotent_on_active_section() {
5479 let src = "[memory.retrieval]\ndepth = 40\n";
5480 let result = migrate_memory_retrieval_config(src).expect("migrate");
5481 assert_eq!(result.changed_count, 0);
5482 assert_eq!(result.output, src);
5483 }
5484
5485 #[test]
5486 fn migrate_memory_retrieval_idempotent_on_commented_section() {
5487 let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
5488 let result = migrate_memory_retrieval_config(src).expect("migrate");
5489 assert_eq!(result.changed_count, 0);
5490 assert_eq!(result.output, src);
5491 }
5492
5493 #[test]
5496 fn migrate_adds_pr4_acp_keys_commented() {
5497 let migrator = ConfigMigrator::new();
5498 let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
5499 let out = migrator.migrate(input).expect("migrate");
5500 assert!(
5501 out.output.contains("# additional_directories = []"),
5502 "expected commented additional_directories; got:\n{}",
5503 out.output
5504 );
5505 assert!(
5506 out.output.contains("# auth_methods = [\"agent\"]"),
5507 "expected commented auth_methods; got:\n{}",
5508 out.output
5509 );
5510 assert!(
5511 out.output.contains("# message_ids_enabled = true"),
5512 "expected commented message_ids_enabled; got:\n{}",
5513 out.output
5514 );
5515 }
5516
5517 #[test]
5520 fn migrate_memory_reasoning_adds_block_when_absent() {
5521 let input = "[agent]\nmodel = \"gpt-4o\"\n";
5522 let result = migrate_memory_reasoning_config(input).unwrap();
5523 assert_eq!(result.changed_count, 1);
5524 assert!(
5525 result
5526 .sections_changed
5527 .contains(&"memory.reasoning".to_owned())
5528 );
5529 assert!(result.output.contains("# [memory.reasoning]"));
5530 assert!(result.output.contains("extraction_timeout_secs = 30"));
5531 assert!(result.output.contains("max_message_chars = 2000"));
5532 }
5533
5534 #[test]
5535 fn migrate_memory_reasoning_idempotent_on_existing_block() {
5536 let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
5537 let result = migrate_memory_reasoning_config(input).unwrap();
5538 assert_eq!(result.changed_count, 0);
5539 assert!(result.sections_changed.is_empty());
5540 assert_eq!(result.output, input);
5541 }
5542
5543 #[test]
5546 fn migrate_hooks_turn_complete_adds_block_when_absent() {
5547 let input = "[agent]\nmodel = \"gpt-4o\"\n";
5548 let result = migrate_hooks_turn_complete_config(input).unwrap();
5549 assert_eq!(result.changed_count, 1);
5550 assert!(
5551 result
5552 .sections_changed
5553 .contains(&"hooks.turn_complete".to_owned())
5554 );
5555 assert!(result.output.contains("# [[hooks.turn_complete]]"));
5556 assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
5557 assert!(result.output.contains("timeout_secs = 3"));
5558 }
5559
5560 #[test]
5561 fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
5562 let input =
5563 "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
5564 let result = migrate_hooks_turn_complete_config(input).unwrap();
5565 assert_eq!(result.changed_count, 0);
5566 assert!(result.sections_changed.is_empty());
5567 assert_eq!(result.output, input);
5568 }
5569
5570 #[test]
5574 fn migrate_focus_auto_consolidate_injects_inside_section() {
5575 let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
5576 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5577 assert_eq!(result.changed_count, 1);
5578 let comment_pos = result
5579 .output
5580 .find("auto_consolidate_min_window")
5581 .expect("comment must be present");
5582 let other_pos = result
5583 .output
5584 .find("[other]")
5585 .expect("[other] must be present");
5586 assert!(
5587 comment_pos < other_pos,
5588 "auto_consolidate_min_window comment must appear before [other] section"
5589 );
5590 }
5591
5592 #[test]
5593 fn migrate_focus_auto_consolidate_idempotent() {
5594 let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
5595 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5596 assert_eq!(result.changed_count, 0);
5597 assert_eq!(result.output, input);
5598 }
5599
5600 #[test]
5601 fn migrate_focus_auto_consolidate_noop_when_section_absent() {
5602 let input = "[agent]\nname = \"zeph\"\n";
5603 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5604 assert_eq!(result.changed_count, 0);
5605 assert_eq!(result.output, input);
5606 }
5607
5608 #[test]
5609 fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
5610 let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
5611 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5612 assert_eq!(result.changed_count, 0);
5613 assert_eq!(result.output, input);
5614 }
5615
5616 #[test]
5619 fn registry_has_fifty_entries() {
5620 assert_eq!(MIGRATIONS.len(), 56);
5621 }
5622
5623 #[test]
5624 fn registry_names_are_unique_and_non_empty() {
5625 let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5626 for name in &names {
5627 assert!(!name.is_empty(), "migration name must not be empty");
5628 }
5629 let mut deduped = names.clone();
5630 deduped.sort_unstable();
5631 deduped.dedup();
5632 assert_eq!(deduped.len(), names.len(), "migration names must be unique");
5633 }
5634
5635 #[test]
5636 fn registry_is_idempotent_on_empty_input() {
5637 const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
5640
5641 let mut toml = String::new();
5642 for m in MIGRATIONS.iter() {
5643 let result = m.apply(&toml).expect("registry migration must not fail");
5644 toml = result.output;
5645 }
5646 for m in MIGRATIONS.iter() {
5647 if COMMENT_ONLY.contains(&m.name()) {
5648 continue;
5649 }
5650 let result = m
5651 .apply(&toml)
5652 .expect("registry migration must not fail on second pass");
5653 assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
5654 }
5655 }
5656
5657 #[test]
5658 fn registry_preserves_order_matches_dispatch() {
5659 let expected = [
5661 "migrate_stt_to_provider",
5662 "migrate_planner_model_to_provider",
5663 "migrate_mcp_trust_levels",
5664 "migrate_agent_retry_to_tools_retry",
5665 "migrate_database_url",
5666 "migrate_shell_transactional",
5667 "migrate_agent_budget_hint",
5668 "migrate_forgetting_config",
5669 "migrate_compression_predictor_config",
5670 "migrate_microcompact_config",
5671 "migrate_autodream_config",
5672 "migrate_magic_docs_config",
5673 "migrate_telemetry_config",
5674 "migrate_supervisor_config",
5675 "migrate_otel_filter",
5676 "migrate_egress_config",
5677 "migrate_vigil_config",
5678 "migrate_sandbox_config",
5679 "migrate_sandbox_egress_filter",
5680 "migrate_orchestration_persistence",
5681 "migrate_session_recap_config",
5682 "migrate_mcp_elicitation_config",
5683 "migrate_quality_config",
5684 "migrate_acp_subagents_config",
5685 "migrate_hooks_permission_denied_config",
5686 "migrate_memory_graph_config",
5687 "migrate_scheduler_daemon_config",
5688 "migrate_memory_retrieval_config",
5689 "migrate_memory_reasoning_config",
5690 "migrate_memory_reasoning_judge_config",
5691 "migrate_memory_hebbian_config",
5692 "migrate_memory_hebbian_consolidation_config",
5693 "migrate_memory_hebbian_spread_config",
5694 "migrate_hooks_turn_complete_config",
5695 "migrate_focus_auto_consolidate_min_window",
5696 "migrate_session_provider_persistence",
5697 "migrate_memory_retrieval_query_bias",
5698 "migrate_memory_persona_config",
5699 "migrate_qdrant_api_key",
5700 "migrate_mcp_max_connect_attempts",
5701 "migrate_goals_config",
5702 "migrate_tools_compression_config",
5703 "migrate_orchestrator_provider",
5704 "migrate_provider_max_concurrent",
5705 "migrate_gonkagate_to_gonka",
5706 "migrate_cocoon_provider_notice",
5707 "migrate_trace_metadata",
5708 "migrate_five_signal_config",
5709 "migrate_embed_provider_rename",
5710 "migrate_mcp_retry_and_tool_timeout",
5711 "migrate_fidelity_timeout_defaults",
5712 "migrate_session_persist_provider_overrides",
5713 "migrate_cocoon_show_balance",
5714 "migrate_worktree_config",
5715 "migrate_worktree_git_timeout",
5716 "migrate_llm_stream_limits",
5717 ];
5718 let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5719 assert_eq!(actual, expected);
5720 }
5721
5722 #[test]
5725 fn migrate_trace_metadata_noop_when_already_present() {
5726 let src = "[telemetry]\nenabled = true\n\n[telemetry.trace_metadata]\n\"env\" = \"prod\"\n";
5727 let result = migrate_trace_metadata(src).unwrap();
5728 assert_eq!(result.changed_count, 0);
5729 assert_eq!(result.output, src);
5730 }
5731
5732 #[test]
5733 fn migrate_trace_metadata_noop_when_no_telemetry_section() {
5734 let src = "[agent]\nmax_turns = 10\n";
5735 let result = migrate_trace_metadata(src).unwrap();
5736 assert_eq!(result.changed_count, 0);
5737 assert_eq!(result.output, src);
5738 }
5739
5740 #[test]
5741 fn migrate_trace_metadata_injects_comment_when_telemetry_present() {
5742 let src = "[telemetry]\nenabled = true\nservice_name = \"zeph\"\n";
5743 let result = migrate_trace_metadata(src).unwrap();
5744 assert_eq!(result.changed_count, 1);
5745 assert!(result.output.contains("trace_metadata"));
5746 assert!(
5747 result
5748 .sections_changed
5749 .contains(&"telemetry.trace_metadata".to_owned())
5750 );
5751 let result2 = migrate_trace_metadata(&result.output).unwrap();
5753 assert_eq!(result2.changed_count, 0);
5754 }
5755
5756 #[test]
5759 fn migrate_qdrant_api_key_adds_comment_when_absent() {
5760 let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5761 let result = migrate_qdrant_api_key(src).expect("migrate");
5762 assert_eq!(result.changed_count, 1);
5763 assert!(
5764 result
5765 .sections_changed
5766 .contains(&"memory.qdrant_api_key".to_owned())
5767 );
5768 assert!(result.output.contains("# qdrant_api_key = \"\""));
5769 }
5770
5771 #[test]
5772 fn migrate_qdrant_api_key_is_noop_when_present() {
5773 let src =
5774 "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
5775 let result = migrate_qdrant_api_key(src).expect("migrate");
5776 assert_eq!(result.changed_count, 0);
5777 assert!(result.sections_changed.is_empty());
5778 assert_eq!(result.output, src);
5779 }
5780
5781 #[test]
5782 fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
5783 let src = "[agent]\nname = \"Zeph\"\n";
5784 let result = migrate_qdrant_api_key(src).expect("migrate");
5785 assert_eq!(result.changed_count, 1);
5786 assert!(result.output.contains("# qdrant_api_key = \"\""));
5787 }
5788
5789 #[test]
5790 fn migrate_qdrant_api_key_idempotent_on_commented_output() {
5791 let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5792 let first = migrate_qdrant_api_key(base).unwrap();
5793 assert_eq!(first.changed_count, 1);
5794 let second = migrate_qdrant_api_key(&first.output).unwrap();
5795 assert_eq!(second.changed_count, 0, "second run must not double-append");
5796 assert_eq!(second.output, first.output);
5797 }
5798
5799 #[test]
5800 fn migrate_mcp_max_connect_attempts_adds_comment_when_absent() {
5801 let src = "[mcp]\nallowed_commands = []\n";
5802 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5803 assert_eq!(result.changed_count, 1);
5804 assert!(
5805 result.output.contains("max_connect_attempts"),
5806 "output must mention max_connect_attempts"
5807 );
5808 }
5809
5810 #[test]
5811 fn migrate_mcp_max_connect_attempts_idempotent_when_present() {
5812 let src = "[mcp]\n# max_connect_attempts = 3\nallowed_commands = []\n";
5813 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5814 assert_eq!(
5815 result.changed_count, 0,
5816 "must not modify already-present key"
5817 );
5818 assert_eq!(result.output, src);
5819 }
5820
5821 #[test]
5822 fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() {
5823 let src = "[agent]\nname = \"Zeph\"\n";
5824 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5825 assert_eq!(result.changed_count, 0);
5826 assert_eq!(result.output, src);
5827 }
5828
5829 #[test]
5832 fn migrate_mcp_retry_and_tool_timeout_adds_both_keys_when_absent() {
5833 let src = "[mcp]\nallowed_commands = []\n";
5834 let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate");
5835 assert_eq!(result.changed_count, 1);
5836 assert!(
5837 result.output.contains("startup_retry_backoff_ms"),
5838 "output must include startup_retry_backoff_ms"
5839 );
5840 assert!(
5841 result.output.contains("tool_timeout_secs"),
5842 "output must include tool_timeout_secs"
5843 );
5844 }
5845
5846 #[test]
5847 fn migrate_mcp_retry_and_tool_timeout_idempotent_when_both_present() {
5848 let src = "[mcp]\n# startup_retry_backoff_ms = 1000\n# tool_timeout_secs = 60\n";
5849 let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate");
5850 assert_eq!(result.changed_count, 0);
5851 assert_eq!(result.output, src);
5852 }
5853
5854 #[test]
5855 fn migrate_mcp_retry_and_tool_timeout_skips_when_no_mcp_section() {
5856 let src = "[agent]\nname = \"Zeph\"\n";
5857 let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate");
5858 assert_eq!(result.changed_count, 0);
5859 assert_eq!(result.output, src);
5860 }
5861
5862 #[test]
5865 fn step43_adds_orchestrator_provider_comment_when_absent() {
5866 let src = "[orchestration]\nenabled = true\n";
5867 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5868 assert_eq!(result.changed_count, 1);
5869 assert!(
5870 result.output.contains("orchestrator_provider"),
5871 "migration must inject orchestrator_provider hint"
5872 );
5873 }
5874
5875 #[test]
5876 fn step43_noop_when_orchestrator_provider_already_present() {
5877 let src = "[orchestration]\nenabled = true\norchestrator_provider = \"\"\n";
5878 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5879 assert_eq!(
5880 result.changed_count, 0,
5881 "must not modify already-present key"
5882 );
5883 assert_eq!(result.output, src);
5884 }
5885
5886 #[test]
5889 fn step44_adds_max_concurrent_comment_when_providers_present() {
5890 let src = "[[llm.providers]]\nname = \"quality\"\ntype = \"openai\"\n";
5891 let result = migrate_provider_max_concurrent(src).expect("migrate");
5892 assert_eq!(result.changed_count, 1);
5893 assert!(
5894 result.output.contains("max_concurrent"),
5895 "migration must inject max_concurrent hint"
5896 );
5897 }
5898
5899 #[test]
5900 fn step44_noop_when_max_concurrent_already_present() {
5901 let src = "[[llm.providers]]\nname = \"quality\"\nmax_concurrent = 4\n";
5902 let result = migrate_provider_max_concurrent(src).expect("migrate");
5903 assert_eq!(
5904 result.changed_count, 0,
5905 "must not modify already-present key"
5906 );
5907 assert_eq!(result.output, src);
5908 }
5909
5910 #[test]
5911 fn step44_noop_when_no_providers_section() {
5912 let src = "[agent]\nname = \"Zeph\"\n";
5913 let result = migrate_provider_max_concurrent(src).expect("migrate");
5914 assert_eq!(result.changed_count, 0);
5915 assert_eq!(result.output, src);
5916 }
5917
5918 #[test]
5921 fn step45_adds_advisory_comment_when_gonkagate_present() {
5922 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5923 let result = migrate_gonkagate_to_gonka(src);
5924 assert!(result.changed_count > 0, "must detect gonkagate entry");
5925 assert!(
5926 result.output.contains("[migration] GonkaGate detected"),
5927 "advisory comment must be added"
5928 );
5929 let comment_pos = result
5931 .output
5932 .find("[migration] GonkaGate detected")
5933 .unwrap();
5934 let header_pos = result.output.find("[[llm.providers]]").unwrap();
5935 assert!(
5936 comment_pos < header_pos,
5937 "advisory comment must precede the [[llm.providers]] header"
5938 );
5939 }
5940
5941 #[test]
5942 fn step45_noop_when_no_gonkagate() {
5943 let src = "[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n";
5944 let result = migrate_gonkagate_to_gonka(src);
5945 assert_eq!(result.changed_count, 0);
5946 assert_eq!(result.output, src);
5947 }
5948
5949 #[test]
5950 fn step45_does_not_double_insert_comment() {
5951 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5952 let first = migrate_gonkagate_to_gonka(src);
5953 let second = migrate_gonkagate_to_gonka(&first.output);
5954 assert_eq!(second.changed_count, 0, "idempotent on second run");
5956 }
5957
5958 #[test]
5961 fn migrate_cocoon_noop_empty_config() {
5962 let src = "";
5963 let result = migrate_cocoon_provider_notice(src).unwrap();
5964 assert_eq!(result.changed_count, 0);
5965 assert_eq!(result.output, src);
5966 }
5967
5968 #[test]
5969 fn migrate_cocoon_noop_existing_config() {
5970 let src = "[agent]\nname = \"zeph\"\n\n[[llm.providers]]\ntype = \"ollama\"\n";
5971 let result = migrate_cocoon_provider_notice(src).unwrap();
5972 assert_eq!(result.changed_count, 0);
5973 assert_eq!(result.output, src);
5974 }
5975
5976 #[test]
5977 fn migrate_cocoon_idempotent() {
5978 let src = "[[llm.providers]]\ntype = \"cocoon\"\nname = \"tee\"\n";
5979 let first = migrate_cocoon_provider_notice(src).unwrap();
5980 let second = migrate_cocoon_provider_notice(&first.output).unwrap();
5981 assert_eq!(second.output, first.output);
5982 assert_eq!(second.changed_count, 0);
5983 }
5984
5985 #[test]
5988 fn migrate_five_signal_config_noop_when_already_present() {
5989 let src = "[memory]\nenabled = true\n\n[memory.five_signal]\nenabled = false\n";
5990 let result = migrate_five_signal_config(src).unwrap();
5991 assert_eq!(result.changed_count, 0);
5992 assert_eq!(result.output, src);
5993 }
5994
5995 #[test]
5996 fn migrate_five_signal_config_noop_when_no_memory_section() {
5997 let src = "[agent]\nmax_turns = 10\n";
5998 let result = migrate_five_signal_config(src).unwrap();
5999 assert_eq!(result.changed_count, 0);
6000 assert_eq!(result.output, src);
6001 }
6002
6003 #[test]
6004 fn migrate_five_signal_config_injects_comment_when_memory_present() {
6005 let src = "[memory]\nenabled = true\n";
6006 let result = migrate_five_signal_config(src).unwrap();
6007 assert_eq!(result.changed_count, 1);
6008 assert!(result.output.contains("five_signal"));
6009 assert!(
6010 result
6011 .sections_changed
6012 .contains(&"memory.five_signal".to_owned())
6013 );
6014 }
6015
6016 #[test]
6017 fn migrate_five_signal_config_idempotent_on_commented_output() {
6018 let base = "[memory]\nenabled = true\n";
6019 let first = migrate_five_signal_config(base).unwrap();
6020 let second = migrate_five_signal_config(&first.output).unwrap();
6021 assert_eq!(second.output, first.output);
6022 assert_eq!(second.changed_count, 0);
6023 }
6024
6025 #[test]
6028 fn migrate_embed_provider_rename_renames_all_four_keys() {
6029 let src = "\
6030[memory.semantic]\n\
6031embed_provider = \"ollama-embed\"\n\
6032\n\
6033[index]\n\
6034embed_provider = \"ollama-embed\"\n\
6035\n\
6036[llm.coe]\n\
6037embed_provider = \"\"\n\
6038\n\
6039[learning]\n\
6040trace_extraction_embed_provider = \"embed-fast\"\n";
6041 let result = migrate_embed_provider_rename(src).unwrap();
6042 assert_eq!(result.changed_count, 4);
6043 assert!(
6044 result
6045 .output
6046 .contains("embedding_provider = \"ollama-embed\"")
6047 );
6048 assert!(
6049 result
6050 .output
6051 .contains("trace_extraction_embedding_provider = \"embed-fast\"")
6052 );
6053 assert!(!result.output.contains("trace_extraction_embed_provider ="));
6054 assert!(!result.output.contains("\nembed_provider ="));
6055 }
6056
6057 #[test]
6058 fn migrate_embed_provider_rename_idempotent_on_own_output() {
6059 let src = "\
6060[memory.semantic]\n\
6061embed_provider = \"ollama-embed\"\n\
6062\n\
6063[learning]\n\
6064trace_extraction_embed_provider = \"embed-fast\"\n";
6065 let first = migrate_embed_provider_rename(src).unwrap();
6066 assert_eq!(first.changed_count, 2);
6067 let second = migrate_embed_provider_rename(&first.output).unwrap();
6068 assert_eq!(second.changed_count, 0, "second run must be a no-op");
6069 assert_eq!(second.output, first.output);
6070 }
6071
6072 #[test]
6073 fn migrate_embed_provider_rename_noop_when_no_old_keys() {
6074 let src = "\
6075[memory.semantic]\n\
6076embedding_provider = \"ollama-embed\"\n\
6077\n\
6078[learning]\n\
6079trace_extraction_embedding_provider = \"embed-fast\"\n";
6080 let result = migrate_embed_provider_rename(src).unwrap();
6081 assert_eq!(result.changed_count, 0);
6082 assert_eq!(result.output, src);
6083 }
6084
6085 #[test]
6086 fn migrate_embed_provider_rename_preserves_commented_lines() {
6087 let src = "# embed_provider = \"old-key\" # this is a comment\n\
6090trace_extraction_embed_provider = \"live\"\n";
6091 let result = migrate_embed_provider_rename(src).unwrap();
6092 assert_eq!(result.changed_count, 1);
6094 assert!(result.output.contains("# embed_provider = \"old-key\""));
6095 assert!(
6096 result
6097 .output
6098 .contains("trace_extraction_embedding_provider = \"live\"")
6099 );
6100 }
6101
6102 #[test]
6105 fn migrate_fidelity_timeout_defaults_adds_both_comments_when_absent() {
6106 let src = "[memory.fidelity]\nenabled = true\n";
6107 let result = migrate_fidelity_timeout_defaults(src).expect("migrate");
6108 assert_eq!(result.changed_count, 1);
6109 assert!(result.output.contains("embed_timeout_secs"));
6110 assert!(result.output.contains("compress_timeout_secs"));
6111 assert!(
6112 result
6113 .sections_changed
6114 .contains(&"memory.fidelity".to_owned())
6115 );
6116 }
6117
6118 #[test]
6119 fn migrate_fidelity_timeout_defaults_idempotent_when_both_present() {
6120 let src = "[memory.fidelity]\nembed_timeout_secs = 30\ncompress_timeout_secs = 30\n";
6121 let result = migrate_fidelity_timeout_defaults(src).expect("migrate");
6122 assert_eq!(result.changed_count, 0);
6123 }
6124
6125 #[test]
6126 fn migrate_fidelity_timeout_defaults_skips_when_no_fidelity_section() {
6127 let src = "[agent]\nname = \"test\"\n";
6128 let result = migrate_fidelity_timeout_defaults(src).expect("migrate");
6129 assert_eq!(result.changed_count, 0);
6130 assert_eq!(result.output, src);
6131 }
6132
6133 #[test]
6134 fn migrate_fidelity_timeout_defaults_adds_only_missing_key() {
6135 let src = "[memory.fidelity]\nenabled = true\nembed_timeout_secs = 60\n";
6136 let result = migrate_fidelity_timeout_defaults(src).expect("migrate");
6137 assert_eq!(result.changed_count, 1);
6138 assert!(result.output.contains("compress_timeout_secs"));
6139 assert_eq!(
6141 result.output.matches("embed_timeout_secs").count(),
6142 1,
6143 "embed_timeout_secs must appear exactly once"
6144 );
6145 }
6146
6147 #[test]
6150 fn migrate_cocoon_show_balance_adds_section_when_absent() {
6151 let src = "[agent]\nname = \"Zeph\"\n";
6152 let result = migrate_cocoon_show_balance(src).expect("migrate");
6153 assert_eq!(result.changed_count, 1);
6154 assert!(
6155 result.output.contains("show_balance"),
6156 "output must mention show_balance"
6157 );
6158 assert!(
6159 result.output.contains("[cocoon]"),
6160 "output must contain [cocoon] section"
6161 );
6162 }
6163
6164 #[test]
6165 fn migrate_cocoon_show_balance_idempotent_when_key_present() {
6166 let src = "[cocoon]\n# show_balance = true\n";
6167 let result = migrate_cocoon_show_balance(src).expect("migrate");
6168 assert_eq!(
6169 result.changed_count, 0,
6170 "must not modify config that already has show_balance"
6171 );
6172 assert_eq!(result.output, src);
6173 }
6174
6175 #[test]
6176 fn migrate_cocoon_show_balance_idempotent_when_active_key_present() {
6177 let src = "[cocoon]\nshow_balance = false\n";
6178 let result = migrate_cocoon_show_balance(src).expect("migrate");
6179 assert_eq!(result.changed_count, 0);
6180 assert_eq!(result.output, src);
6181 }
6182
6183 #[test]
6186 fn step_54_inserts_worktree_section_on_fresh_config() {
6187 let input = "[agent]\nmax_turns = 10\n";
6188 let result = migrate_worktree_config(input).unwrap();
6189 assert_eq!(result.changed_count, 1);
6190 assert!(
6191 result.output.contains("[worktree]"),
6192 "should insert [worktree] section"
6193 );
6194 assert!(
6195 result.output.contains("# enabled = false"),
6196 "should include default fields"
6197 );
6198 }
6199
6200 #[test]
6201 fn step_54_is_idempotent_when_worktree_present() {
6202 let input = "[worktree]\nenabled = true\n";
6203 let result = migrate_worktree_config(input).unwrap();
6204 assert_eq!(result.changed_count, 0);
6205 assert_eq!(
6206 result.output.matches("[worktree]").count(),
6207 1,
6208 "should not duplicate [worktree]"
6209 );
6210 }
6211
6212 #[test]
6213 fn step_54_is_idempotent_when_worktree_commented() {
6214 let input = "# [worktree]\n[agent]\nmax_turns = 10\n";
6216 let result = migrate_worktree_config(input).unwrap();
6217 assert_eq!(
6218 result.changed_count, 0,
6219 "commented [worktree] counts as present"
6220 );
6221 }
6222
6223 #[test]
6224 fn step_54_does_not_skip_when_worktree_in_value() {
6225 let input = "[agent]\ndescription = \"uses [worktree] isolation\"\n";
6227 let result = migrate_worktree_config(input).unwrap();
6228 assert_eq!(
6229 result.changed_count, 1,
6230 "[worktree] in a value must not suppress migration"
6231 );
6232 assert!(
6233 result.output.contains("# [worktree]"),
6234 "output should contain the inserted worktree comment block"
6235 );
6236 }
6237
6238 #[test]
6241 fn step_55_inserts_git_timeout_comment_when_worktree_present() {
6242 let input = "[worktree]\nenabled = true\n";
6243 let result = migrate_worktree_git_timeout(input).unwrap();
6244 assert_eq!(result.changed_count, 1);
6245 assert!(
6246 result.output.contains("git_timeout_secs"),
6247 "should insert git_timeout_secs comment"
6248 );
6249 assert_eq!(result.sections_changed, vec!["worktree"]);
6250 }
6251
6252 #[test]
6253 fn step_55_is_noop_when_no_worktree_section() {
6254 let input = "[agent]\nmax_turns = 10\n";
6255 let result = migrate_worktree_git_timeout(input).unwrap();
6256 assert_eq!(result.changed_count, 0);
6257 assert_eq!(result.output, input);
6258 }
6259
6260 #[test]
6261 fn step_55_is_idempotent_when_git_timeout_already_present() {
6262 let input = "[worktree]\ngit_timeout_secs = 60\n";
6263 let result = migrate_worktree_git_timeout(input).unwrap();
6264 assert_eq!(result.changed_count, 0);
6265 assert_eq!(result.output, input);
6266 }
6267
6268 #[test]
6269 fn step_55_is_idempotent_when_git_timeout_commented() {
6270 let input = "[worktree]\n# git_timeout_secs = 30\n";
6271 let result = migrate_worktree_git_timeout(input).unwrap();
6272 assert_eq!(result.changed_count, 0);
6273 }
6274
6275 #[test]
6276 fn step_55_handles_crlf_line_endings() {
6277 let input = "[worktree]\r\nenabled = true\r\n";
6278 let result = migrate_worktree_git_timeout(input).unwrap();
6279 assert_eq!(result.changed_count, 1);
6280 assert!(
6281 result.output.contains("git_timeout_secs"),
6282 "CRLF input should still receive the git_timeout_secs comment"
6283 );
6284 }
6285
6286 #[test]
6289 fn section_header_present_exact_match() {
6290 assert!(section_header_present(
6291 "[worktree]\nenabled = true\n",
6292 "worktree"
6293 ));
6294 }
6295
6296 #[test]
6297 fn section_header_present_inline_comment() {
6298 assert!(section_header_present(
6299 "[worktree] # some comment\nenabled = true\n",
6300 "worktree"
6301 ));
6302 }
6303
6304 #[test]
6305 fn section_header_present_subtable_implies_parent() {
6306 assert!(section_header_present(
6307 "[worktree.git]\ntimeout = 30\n",
6308 "worktree"
6309 ));
6310 }
6311
6312 #[test]
6313 fn section_header_present_commented_header_returns_false() {
6314 assert!(!section_header_present(
6315 "# [worktree]\nenabled = true\n",
6316 "worktree"
6317 ));
6318 }
6319
6320 #[test]
6321 fn section_header_present_no_match() {
6322 assert!(!section_header_present(
6323 "[agent]\nmax_turns = 10\n",
6324 "worktree"
6325 ));
6326 }
6327
6328 #[test]
6329 fn section_header_present_does_not_match_value_containing_header() {
6330 assert!(!section_header_present(
6332 "[agent]\npath = \"[worktree]\"\n",
6333 "worktree"
6334 ));
6335 }
6336
6337 #[test]
6340 fn step_55_subtable_only_is_noop() {
6341 let input = "[worktree.git]\ntimeout = 30\n";
6345 let result = migrate_worktree_git_timeout(input).unwrap();
6346 assert_eq!(
6347 result.changed_count, 0,
6348 "subtable-only header must be a no-op"
6349 );
6350 assert_eq!(result.output, input);
6351 }
6352
6353 #[test]
6354 fn step_55_inline_comment_header_gets_comment_injected() {
6355 let input = "[worktree] # remark\nenabled = true\n";
6357 let result = migrate_worktree_git_timeout(input).unwrap();
6358 assert_eq!(result.changed_count, 1);
6359 assert!(
6360 result.output.contains("git_timeout_secs"),
6361 "inline-comment header must still receive the git_timeout_secs comment"
6362 );
6363 assert!(
6364 result.output.contains("[worktree] # remark"),
6365 "original header line must be preserved"
6366 );
6367 }
6368
6369 #[test]
6370 fn step_55_value_substring_is_noop() {
6371 let input = "[agent]\npath = \"[worktree]\"\n";
6373 let result = migrate_worktree_git_timeout(input).unwrap();
6374 assert_eq!(
6375 result.changed_count, 0,
6376 "value substring must not trigger replacement"
6377 );
6378 assert_eq!(result.output, input);
6379 }
6380}