1use regex::Regex;
11use toml_edit::{Array, DocumentMut, Item, Table, Value};
12
13mod features;
15mod infra;
16mod llm;
17mod mcp;
18mod memory;
19mod serve;
20mod session;
21mod tools;
22
23pub use features::{
24 migrate_autodream_config, migrate_caveman_config, migrate_compression_predictor_config,
25 migrate_deep_link_config, migrate_five_signal_config, migrate_goals_config,
26 migrate_knowledge_config, migrate_magic_docs_config, migrate_microcompact_config,
27 migrate_orchestration_asset_sensitivity, migrate_orchestration_ensemble,
28 migrate_orchestration_idle_timeout, migrate_orchestration_persistence,
29 migrate_skill_trust_require_check, migrate_skills_registry, migrate_tui_delights,
30 migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
31};
32pub use infra::*;
33pub(crate) use llm::migrate_gonkagate_to_gonka;
35pub use llm::*;
36pub use mcp::*;
37pub use memory::*;
38pub use serve::migrate_serve_config;
39pub use session::*;
40pub use tools::*;
41
42#[must_use]
54pub fn section_header_present(src: &str, name: &str) -> bool {
55 let escaped = regex::escape(name);
57 let pattern = format!(r"^\[{escaped}(?:\.[^\]]+)?\](?:\s*#.*)?$");
60 let re = Regex::new(&pattern).expect("regex::escape always produces a valid pattern");
61 src.lines()
62 .filter(|line| !line.trim_start().starts_with('#'))
63 .any(|line| re.is_match(line.trim()))
64}
65
66static CANONICAL_ORDER: &[&str] = &[
68 "agent",
69 "llm",
70 "skills",
71 "memory",
72 "index",
73 "tools",
74 "mcp",
75 "telegram",
76 "discord",
77 "slack",
78 "a2a",
79 "acp",
80 "gateway",
81 "metrics",
82 "daemon",
83 "scheduler",
84 "orchestration",
85 "classifiers",
86 "security",
87 "vault",
88 "timeouts",
89 "cost",
90 "debug",
91 "logging",
92 "notifications",
93 "tui",
94 "agents",
95 "experiments",
96 "lsp",
97 "telemetry",
98 "session",
99 "deep_link",
100];
101
102#[derive(Debug, thiserror::Error)]
104#[non_exhaustive]
105pub enum MigrateError {
106 #[error("failed to parse input config: {0}")]
108 Parse(#[from] toml_edit::TomlError),
109 #[error("failed to parse reference config: {0}")]
111 Reference(toml_edit::TomlError),
112 #[error("migration failed: invalid TOML structure — {0}")]
115 InvalidStructure(&'static str),
116}
117
118#[derive(Debug)]
120pub struct MigrationResult {
121 pub output: String,
123 pub changed_count: usize,
125 pub sections_changed: Vec<String>,
127}
128
129pub struct ConfigMigrator {
134 reference_src: &'static str,
135}
136
137impl Default for ConfigMigrator {
138 fn default() -> Self {
139 Self::new()
140 }
141}
142
143impl ConfigMigrator {
144 #[must_use]
146 pub fn new() -> Self {
147 Self {
148 reference_src: include_str!("../../config/default.toml"),
149 }
150 }
151
152 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
164 let reference_doc = self
165 .reference_src
166 .parse::<DocumentMut>()
167 .map_err(MigrateError::Reference)?;
168 let mut user_doc = user_toml.parse::<DocumentMut>()?;
169
170 let mut changed_count = 0usize;
171 let mut sections_changed: Vec<String> = Vec::new();
172 let mut pending_comments: Vec<(String, String)> = Vec::new();
175
176 for (key, ref_item) in reference_doc.as_table() {
178 if ref_item.is_table() {
179 let ref_table = ref_item.as_table().expect("is_table checked above");
180 if user_doc.contains_key(key) {
181 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
183 let (n, comments) =
184 merge_table_commented(user_table, ref_table, key, user_toml);
185 changed_count += n;
186 pending_comments.extend(comments);
187 }
188 } else {
189 if user_toml.contains(&format!("# [{key}]")) {
192 continue;
193 }
194 let commented = commented_table_block(key, ref_table);
195 if !commented.is_empty() {
196 sections_changed.push(key.to_owned());
197 }
198 changed_count += 1;
199 }
200 } else {
201 if !user_doc.contains_key(key) {
203 let raw = format_commented_item(key, ref_item);
204 if !raw.is_empty() {
205 sections_changed.push(format!("__scalar__{key}"));
206 changed_count += 1;
207 }
208 }
209 }
210 }
211
212 let user_str = user_doc.to_string();
214
215 let mut output = user_str;
218 for (section_key, comment_line) in &pending_comments {
219 if !section_body(&output, section_key).contains(comment_line.trim()) {
220 output = insert_after_section(&output, section_key, comment_line);
221 }
222 }
223
224 for key in §ions_changed {
226 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
227 if let Some(ref_item) = reference_doc.get(scalar_key) {
228 let raw = format_commented_item(scalar_key, ref_item);
229 if !raw.is_empty() {
230 output.push('\n');
231 output.push_str(&raw);
232 output.push('\n');
233 }
234 }
235 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
236 {
237 let block = commented_table_block(key, ref_table);
238 if !block.is_empty() {
239 output.push('\n');
240 output.push_str(&block);
241 }
242 }
243 }
244
245 output = reorder_sections(&output, CANONICAL_ORDER);
247
248 let sections_changed_clean: Vec<String> = sections_changed
250 .into_iter()
251 .filter(|k| !k.starts_with("__scalar__"))
252 .collect();
253
254 Ok(MigrationResult {
255 output,
256 changed_count,
257 sections_changed: sections_changed_clean,
258 })
259 }
260}
261
262fn merge_table_commented(
268 user_table: &mut Table,
269 ref_table: &Table,
270 section_key: &str,
271 user_toml: &str,
272) -> (usize, Vec<(String, String)>) {
273 let mut count = 0usize;
274 let mut comments: Vec<(String, String)> = Vec::new();
275 for (key, ref_item) in ref_table {
276 if ref_item.is_table() {
277 if user_table.contains_key(key) {
278 let pair = (
279 user_table.get_mut(key).and_then(Item::as_table_mut),
280 ref_item.as_table(),
281 );
282 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
283 let sub_key = format!("{section_key}.{key}");
284 let (n, c) =
285 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
286 count += n;
287 comments.extend(c);
288 }
289 } else if let Some(ref_sub_table) = ref_item.as_table() {
290 let dotted = format!("{section_key}.{key}");
292 let marker = format!("# [{dotted}]");
293 if !user_toml.contains(&marker) {
294 let block = commented_table_block(&dotted, ref_sub_table);
295 if !block.is_empty() {
296 comments.push((section_key.to_owned(), format!("\n{block}")));
297 count += 1;
298 }
299 }
300 }
301 } else if ref_item.is_array_of_tables() {
302 } else {
304 if !user_table.contains_key(key) {
306 let raw_value = ref_item
307 .as_value()
308 .map(value_to_toml_string)
309 .unwrap_or_default();
310 if !raw_value.is_empty() {
311 let comment_line = format!("# {key} = {raw_value}\n");
312 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
315 comments.push((section_key.to_owned(), comment_line));
316 count += 1;
317 }
318 }
319 }
320 }
321 }
322 (count, comments)
323}
324
325fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
331 let header = format!("[{section}]");
332 let Some(section_start) = doc.find(&header) else {
333 return "";
334 };
335 let body_start = section_start + header.len();
336 let body_end = doc[body_start..]
337 .find("\n[")
338 .map_or(doc.len(), |r| body_start + r);
339 &doc[body_start..body_end]
340}
341
342fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
348 let header = format!("[{section_name}]");
349 let Some(section_start) = raw.find(&header) else {
350 return format!("{raw}{text}");
351 };
352 let search_from = section_start + header.len();
354 let insert_pos = raw[search_from..]
356 .find("\n[")
357 .map_or(raw.len(), |rel| search_from + rel + 1);
358 let mut out = String::with_capacity(raw.len() + text.len());
359 out.push_str(&raw[..insert_pos]);
360 out.push_str(text);
361 out.push_str(&raw[insert_pos..]);
362 out
363}
364
365fn format_commented_item(key: &str, item: &Item) -> String {
367 if let Some(val) = item.as_value() {
368 let raw = value_to_toml_string(val);
369 if !raw.is_empty() {
370 return format!("# {key} = {raw}\n");
371 }
372 }
373 String::new()
374}
375
376fn commented_table_block(section_name: &str, table: &Table) -> String {
381 use std::fmt::Write as _;
382
383 let mut lines = format!("# [{section_name}]\n");
384
385 for (key, item) in table {
386 if item.is_table() {
387 if let Some(sub_table) = item.as_table() {
388 let sub_name = format!("{section_name}.{key}");
389 let sub_block = commented_table_block(&sub_name, sub_table);
390 if !sub_block.is_empty() {
391 lines.push('\n');
392 lines.push_str(&sub_block);
393 }
394 }
395 } else if item.is_array_of_tables() {
396 } else if let Some(val) = item.as_value() {
398 let raw = value_to_toml_string(val);
399 if !raw.is_empty() {
400 let _ = writeln!(lines, "# {key} = {raw}");
401 }
402 }
403 }
404
405 if lines.trim() == format!("[{section_name}]") {
407 return String::new();
408 }
409 lines
410}
411
412fn value_to_toml_string(val: &Value) -> String {
414 match val {
415 Value::String(s) => {
416 let inner = s.value();
417 format!("\"{inner}\"")
418 }
419 Value::Integer(i) => i.value().to_string(),
420 Value::Float(f) => {
421 let v = f.value();
422 if v.fract() == 0.0 {
424 format!("{v:.1}")
425 } else {
426 format!("{v}")
427 }
428 }
429 Value::Boolean(b) => b.value().to_string(),
430 Value::Array(arr) => format_array(arr),
431 Value::InlineTable(t) => {
432 let pairs: Vec<String> = t
433 .iter()
434 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
435 .collect();
436 format!("{{ {} }}", pairs.join(", "))
437 }
438 Value::Datetime(dt) => dt.value().to_string(),
439 }
440}
441
442fn format_array(arr: &Array) -> String {
443 if arr.is_empty() {
444 return "[]".to_owned();
445 }
446 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
447 format!("[{}]", items.join(", "))
448}
449
450fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
456 let sections = split_into_sections(toml_str);
457 if sections.is_empty() {
458 return toml_str.to_owned();
459 }
460
461 let preamble_block = sections
463 .iter()
464 .find(|(h, _)| h.is_empty())
465 .map_or("", |(_, c)| c.as_str());
466
467 let section_map: Vec<(&str, &str)> = sections
468 .iter()
469 .filter(|(h, _)| !h.is_empty())
470 .map(|(h, c)| (h.as_str(), c.as_str()))
471 .collect();
472
473 let mut out = String::new();
474 if !preamble_block.is_empty() {
475 out.push_str(preamble_block);
476 }
477
478 let mut emitted: Vec<bool> = vec![false; section_map.len()];
479
480 for &canon in canonical_order {
481 for (idx, &(header, content)) in section_map.iter().enumerate() {
482 let section_name = extract_section_name(header);
483 let top_level = section_name
484 .split('.')
485 .next()
486 .unwrap_or("")
487 .trim_start_matches('#')
488 .trim();
489 if top_level == canon && !emitted[idx] {
490 out.push_str(content);
491 emitted[idx] = true;
492 }
493 }
494 }
495
496 for (idx, &(_, content)) in section_map.iter().enumerate() {
498 if !emitted[idx] {
499 out.push_str(content);
500 }
501 }
502
503 out
504}
505
506fn extract_section_name(header: &str) -> &str {
508 let trimmed = header.trim().trim_start_matches("# ");
510 if trimmed.starts_with('[') && trimmed.contains(']') {
512 let inner = &trimmed[1..];
513 if let Some(end) = inner.find(']') {
514 return &inner[..end];
515 }
516 }
517 trimmed
518}
519
520fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
524 let mut sections: Vec<(String, String)> = Vec::new();
525 let mut current_header = String::new();
526 let mut current_content = String::new();
527
528 for line in toml_str.lines() {
529 let trimmed = line.trim();
530 if is_top_level_section_header(trimmed) {
531 sections.push((current_header.clone(), current_content.clone()));
532 trimmed.clone_into(&mut current_header);
533 line.clone_into(&mut current_content);
534 current_content.push('\n');
535 } else {
536 current_content.push_str(line);
537 current_content.push('\n');
538 }
539 }
540
541 if !current_header.is_empty() || !current_content.is_empty() {
543 sections.push((current_header, current_content));
544 }
545
546 sections
547}
548
549fn is_top_level_section_header(line: &str) -> bool {
554 if line.starts_with('[')
555 && !line.starts_with("[[")
556 && let Some(end) = line.find(']')
557 {
558 return !line[1..end].contains('.');
559 }
560 false
561}
562
563pub trait Migration: Send + Sync {
588 fn name(&self) -> &'static str;
590
591 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
597}
598
599mod steps;
600use steps::{
601 MigrateA2aCardTrustConfig, MigrateA2aServerRemoveInertFields, MigrateAcpAuthClientsConfig,
602 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
603 MigrateAutodreamConfig, MigrateCavemanConfig, MigrateCocoonProviderNotice,
604 MigrateCocoonShowBalance, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
605 MigrateDeepLinkConfig, MigrateDurableConfig, MigrateDurableSharedDb,
606 MigrateDurableStaleRunningAfterSecs, MigrateEgressConfig, MigrateEmbedProviderRename,
607 MigrateEvalModelToProvider, MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig,
608 MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig,
609 MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete,
610 MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig,
611 MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpRetryAndToolTimeout,
612 MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported,
613 MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread,
614 MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge,
615 MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias, MigrateMemoryTypeAwareCompose,
616 MigrateMicrocompactConfig, MigrateNliConfig, MigrateOrchestrationAssetSensitivity,
617 MigrateOrchestrationEnsemble, MigrateOrchestrationIdleTimeout, MigrateOrchestrationPersistence,
618 MigrateOrchestratorProvider, MigrateOtelFilter, MigratePiiFilterNames,
619 MigratePlannerModelToProvider, MigratePolicyProviderAndUtilityWindow,
620 MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs,
621 MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter, MigrateSchedulerDaemon,
622 MigrateSecretMaskingConfig, MigrateServeConfig, MigrateSessionPersistProviderOverrides,
623 MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
624 MigrateShadowSentinelConfig, MigrateShellCheckpointsConfig, MigrateShellTransactional,
625 MigrateSkillTrustRequireCheck, MigrateSkillsRegistry, MigrateSttToProvider,
626 MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
627 MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse, MigrateTuiThemeConfig,
628 MigrateTuiThemeDefaults, MigrateUtilityHighGainTools, MigrateVigilConfig,
629 MigrateWorktreeConfig, MigrateWorktreeGitTimeout, MigrateWorktreeQuotaFields,
630};
631
632pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
649 std::sync::LazyLock::new(|| {
650 vec![
651 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
653 Box::new(MigratePlannerModelToProvider),
654 Box::new(MigrateMcpTrustLevels),
655 Box::new(MigrateAgentRetryToToolsRetry),
656 Box::new(MigrateDatabaseUrl),
657 Box::new(MigrateShellTransactional),
658 Box::new(MigrateAgentBudgetHint),
659 Box::new(MigrateForgettingConfig),
660 Box::new(MigrateCompressionPredictorConfig),
661 Box::new(MigrateMicrocompactConfig),
662 Box::new(MigrateAutodreamConfig),
663 Box::new(MigrateMagicDocsConfig),
664 Box::new(MigrateTelemetryConfig),
665 Box::new(MigrateSupervisorConfig),
666 Box::new(MigrateOtelFilter),
667 Box::new(MigrateEgressConfig),
668 Box::new(MigrateVigilConfig),
669 Box::new(MigrateSandboxConfig),
670 Box::new(MigrateSandboxEgressFilter),
671 Box::new(MigrateOrchestrationPersistence),
672 Box::new(MigrateSessionRecapConfig),
673 Box::new(MigrateMcpElicitationConfig),
674 Box::new(MigrateQualityConfig),
675 Box::new(MigrateAcpSubagentsConfig),
676 Box::new(MigrateHooksPermissionDeniedConfig),
677 Box::new(MigrateMemoryGraph),
679 Box::new(MigrateSchedulerDaemon),
680 Box::new(MigrateMemoryRetrieval),
681 Box::new(MigrateMemoryReasoning),
682 Box::new(MigrateMemoryReasoningJudge),
683 Box::new(MigrateMemoryHebbian),
684 Box::new(MigrateMemoryHebbianConsolidation),
685 Box::new(MigrateMemoryHebbianSpread),
686 Box::new(MigrateHooksTurnComplete),
687 Box::new(MigrateFocusAutoConsolidateMinWindow),
688 Box::new(MigrateSessionProviderPersistence),
690 Box::new(MigrateMemoryRetrievalQueryBias),
691 Box::new(MigrateMemoryPersonaConfig),
692 Box::new(MigrateQdrantApiKey),
694 Box::new(MigrateMcpMaxConnectAttempts),
696 Box::new(MigrateGoalsConfig),
698 Box::new(MigrateToolsCompressionConfig),
699 Box::new(MigrateOrchestratorProvider),
701 Box::new(MigrateProviderMaxConcurrent),
703 Box::new(MigrateGonkagateToGonka),
705 Box::new(MigrateCocoonProviderNotice),
707 Box::new(MigrateTraceMetadata),
709 Box::new(MigrateFiveSignalConfig),
711 Box::new(MigrateEmbedProviderRename),
713 Box::new(MigrateMcpRetryAndToolTimeout),
715 Box::new(MigrateFidelityTimeoutDefaults),
717 Box::new(MigrateSessionPersistProviderOverrides),
719 Box::new(MigrateCocoonShowBalance),
721 Box::new(MigrateWorktreeConfig),
723 Box::new(MigrateWorktreeGitTimeout),
725 Box::new(MigrateLlmStreamLimits),
727 Box::new(MigrateDurableConfig),
729 Box::new(MigrateEvalModelToProvider),
731 Box::new(MigrateCavemanConfig),
733 Box::new(MigrateShellCheckpointsConfig),
735 Box::new(MigrateKnowledgeConfig),
737 Box::new(MigrateDeepLinkConfig),
739 Box::new(MigrateMemoryGraphRecallIncludeImported),
741 Box::new(MigratePolicyProviderAndUtilityWindow),
743 Box::new(MigrateTuiThemeConfig),
745 Box::new(MigrateTuiThemeDefaults),
747 Box::new(MigrateTuiDelights),
749 Box::new(MigrateTuiMouse),
751 Box::new(MigrateOrchestrationAssetSensitivity),
753 Box::new(MigrateSessionPersistenceConfig),
755 Box::new(MigrateServeConfig),
757 Box::new(MigrateNliConfig),
759 Box::new(MigrateSecretMaskingConfig),
761 Box::new(MigratePiiFilterNames),
764 Box::new(MigrateQdrantTimeoutSecs),
766 Box::new(MigrateUtilityHighGainTools),
768 Box::new(MigrateAcpAuthClientsConfig),
770 Box::new(MigrateSkillsRegistry),
772 Box::new(MigrateDurableSharedDb),
774 Box::new(MigrateSkillTrustRequireCheck),
777 Box::new(MigrateShadowSentinelConfig),
779 Box::new(MigrateA2aCardTrustConfig),
781 Box::new(MigrateWorktreeQuotaFields),
785 Box::new(MigrateA2aServerRemoveInertFields),
788 Box::new(MigrateMemoryTypeAwareCompose),
791 Box::new(MigrateOrchestrationEnsemble),
794 Box::new(MigrateDurableStaleRunningAfterSecs),
797 Box::new(MigrateOrchestrationIdleTimeout),
800 ]
801 });
802
803#[cfg(test)]
805fn make_formatted_str(s: &str) -> Value {
806 use toml_edit::Formatted;
807 Value::String(Formatted::new(s.to_owned()))
808}
809
810#[cfg(test)]
811mod tests;