use regex::Regex;
use toml_edit::{Array, DocumentMut, Item, Table, Value};
mod features;
mod infra;
mod llm;
mod mcp;
mod memory;
mod serve;
mod session;
mod tools;
pub use features::{
migrate_autodream_config, migrate_caveman_config, migrate_compression_predictor_config,
migrate_deep_link_config, migrate_five_signal_config, migrate_goals_config,
migrate_knowledge_config, migrate_magic_docs_config, migrate_microcompact_config,
migrate_orchestration_asset_sensitivity, migrate_orchestration_persistence,
migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
};
pub use infra::*;
pub(crate) use llm::migrate_gonkagate_to_gonka;
pub use llm::*;
pub use mcp::*;
pub use memory::*;
pub use serve::migrate_serve_config;
pub use session::*;
pub use tools::*;
#[must_use]
pub fn section_header_present(src: &str, name: &str) -> bool {
let escaped = regex::escape(name);
let pattern = format!(r"^\[{escaped}(?:\.[^\]]+)?\](?:\s*#.*)?$");
let re = Regex::new(&pattern).expect("regex::escape always produces a valid pattern");
src.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.any(|line| re.is_match(line.trim()))
}
static CANONICAL_ORDER: &[&str] = &[
"agent",
"llm",
"skills",
"memory",
"index",
"tools",
"mcp",
"telegram",
"discord",
"slack",
"a2a",
"acp",
"gateway",
"metrics",
"daemon",
"scheduler",
"orchestration",
"classifiers",
"security",
"vault",
"timeouts",
"cost",
"debug",
"logging",
"notifications",
"tui",
"agents",
"experiments",
"lsp",
"telemetry",
"session",
"deep_link",
];
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MigrateError {
#[error("failed to parse input config: {0}")]
Parse(#[from] toml_edit::TomlError),
#[error("failed to parse reference config: {0}")]
Reference(toml_edit::TomlError),
#[error("migration failed: invalid TOML structure — {0}")]
InvalidStructure(&'static str),
}
#[derive(Debug)]
pub struct MigrationResult {
pub output: String,
pub changed_count: usize,
pub sections_changed: Vec<String>,
}
pub struct ConfigMigrator {
reference_src: &'static str,
}
impl Default for ConfigMigrator {
fn default() -> Self {
Self::new()
}
}
impl ConfigMigrator {
#[must_use]
pub fn new() -> Self {
Self {
reference_src: include_str!("../../config/default.toml"),
}
}
pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
let reference_doc = self
.reference_src
.parse::<DocumentMut>()
.map_err(MigrateError::Reference)?;
let mut user_doc = user_toml.parse::<DocumentMut>()?;
let mut changed_count = 0usize;
let mut sections_changed: Vec<String> = Vec::new();
let mut pending_comments: Vec<(String, String)> = Vec::new();
for (key, ref_item) in reference_doc.as_table() {
if ref_item.is_table() {
let ref_table = ref_item.as_table().expect("is_table checked above");
if user_doc.contains_key(key) {
if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
let (n, comments) =
merge_table_commented(user_table, ref_table, key, user_toml);
changed_count += n;
pending_comments.extend(comments);
}
} else {
if user_toml.contains(&format!("# [{key}]")) {
continue;
}
let commented = commented_table_block(key, ref_table);
if !commented.is_empty() {
sections_changed.push(key.to_owned());
}
changed_count += 1;
}
} else {
if !user_doc.contains_key(key) {
let raw = format_commented_item(key, ref_item);
if !raw.is_empty() {
sections_changed.push(format!("__scalar__{key}"));
changed_count += 1;
}
}
}
}
let user_str = user_doc.to_string();
let mut output = user_str;
for (section_key, comment_line) in &pending_comments {
if !section_body(&output, section_key).contains(comment_line.trim()) {
output = insert_after_section(&output, section_key, comment_line);
}
}
for key in §ions_changed {
if let Some(scalar_key) = key.strip_prefix("__scalar__") {
if let Some(ref_item) = reference_doc.get(scalar_key) {
let raw = format_commented_item(scalar_key, ref_item);
if !raw.is_empty() {
output.push('\n');
output.push_str(&raw);
output.push('\n');
}
}
} else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
{
let block = commented_table_block(key, ref_table);
if !block.is_empty() {
output.push('\n');
output.push_str(&block);
}
}
}
output = reorder_sections(&output, CANONICAL_ORDER);
let sections_changed_clean: Vec<String> = sections_changed
.into_iter()
.filter(|k| !k.starts_with("__scalar__"))
.collect();
Ok(MigrationResult {
output,
changed_count,
sections_changed: sections_changed_clean,
})
}
}
fn merge_table_commented(
user_table: &mut Table,
ref_table: &Table,
section_key: &str,
user_toml: &str,
) -> (usize, Vec<(String, String)>) {
let mut count = 0usize;
let mut comments: Vec<(String, String)> = Vec::new();
for (key, ref_item) in ref_table {
if ref_item.is_table() {
if user_table.contains_key(key) {
let pair = (
user_table.get_mut(key).and_then(Item::as_table_mut),
ref_item.as_table(),
);
if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
let sub_key = format!("{section_key}.{key}");
let (n, c) =
merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
count += n;
comments.extend(c);
}
} else if let Some(ref_sub_table) = ref_item.as_table() {
let dotted = format!("{section_key}.{key}");
let marker = format!("# [{dotted}]");
if !user_toml.contains(&marker) {
let block = commented_table_block(&dotted, ref_sub_table);
if !block.is_empty() {
comments.push((section_key.to_owned(), format!("\n{block}")));
count += 1;
}
}
}
} else if ref_item.is_array_of_tables() {
} else {
if !user_table.contains_key(key) {
let raw_value = ref_item
.as_value()
.map(value_to_toml_string)
.unwrap_or_default();
if !raw_value.is_empty() {
let comment_line = format!("# {key} = {raw_value}\n");
if !section_body(user_toml, section_key).contains(comment_line.trim()) {
comments.push((section_key.to_owned(), comment_line));
count += 1;
}
}
}
}
}
(count, comments)
}
fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
let header = format!("[{section}]");
let Some(section_start) = doc.find(&header) else {
return "";
};
let body_start = section_start + header.len();
let body_end = doc[body_start..]
.find("\n[")
.map_or(doc.len(), |r| body_start + r);
&doc[body_start..body_end]
}
fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
let header = format!("[{section_name}]");
let Some(section_start) = raw.find(&header) else {
return format!("{raw}{text}");
};
let search_from = section_start + header.len();
let insert_pos = raw[search_from..]
.find("\n[")
.map_or(raw.len(), |rel| search_from + rel + 1);
let mut out = String::with_capacity(raw.len() + text.len());
out.push_str(&raw[..insert_pos]);
out.push_str(text);
out.push_str(&raw[insert_pos..]);
out
}
fn format_commented_item(key: &str, item: &Item) -> String {
if let Some(val) = item.as_value() {
let raw = value_to_toml_string(val);
if !raw.is_empty() {
return format!("# {key} = {raw}\n");
}
}
String::new()
}
fn commented_table_block(section_name: &str, table: &Table) -> String {
use std::fmt::Write as _;
let mut lines = format!("# [{section_name}]\n");
for (key, item) in table {
if item.is_table() {
if let Some(sub_table) = item.as_table() {
let sub_name = format!("{section_name}.{key}");
let sub_block = commented_table_block(&sub_name, sub_table);
if !sub_block.is_empty() {
lines.push('\n');
lines.push_str(&sub_block);
}
}
} else if item.is_array_of_tables() {
} else if let Some(val) = item.as_value() {
let raw = value_to_toml_string(val);
if !raw.is_empty() {
let _ = writeln!(lines, "# {key} = {raw}");
}
}
}
if lines.trim() == format!("[{section_name}]") {
return String::new();
}
lines
}
fn value_to_toml_string(val: &Value) -> String {
match val {
Value::String(s) => {
let inner = s.value();
format!("\"{inner}\"")
}
Value::Integer(i) => i.value().to_string(),
Value::Float(f) => {
let v = f.value();
if v.fract() == 0.0 {
format!("{v:.1}")
} else {
format!("{v}")
}
}
Value::Boolean(b) => b.value().to_string(),
Value::Array(arr) => format_array(arr),
Value::InlineTable(t) => {
let pairs: Vec<String> = t
.iter()
.map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
.collect();
format!("{{ {} }}", pairs.join(", "))
}
Value::Datetime(dt) => dt.value().to_string(),
}
}
fn format_array(arr: &Array) -> String {
if arr.is_empty() {
return "[]".to_owned();
}
let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
format!("[{}]", items.join(", "))
}
fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
let sections = split_into_sections(toml_str);
if sections.is_empty() {
return toml_str.to_owned();
}
let preamble_block = sections
.iter()
.find(|(h, _)| h.is_empty())
.map_or("", |(_, c)| c.as_str());
let section_map: Vec<(&str, &str)> = sections
.iter()
.filter(|(h, _)| !h.is_empty())
.map(|(h, c)| (h.as_str(), c.as_str()))
.collect();
let mut out = String::new();
if !preamble_block.is_empty() {
out.push_str(preamble_block);
}
let mut emitted: Vec<bool> = vec![false; section_map.len()];
for &canon in canonical_order {
for (idx, &(header, content)) in section_map.iter().enumerate() {
let section_name = extract_section_name(header);
let top_level = section_name
.split('.')
.next()
.unwrap_or("")
.trim_start_matches('#')
.trim();
if top_level == canon && !emitted[idx] {
out.push_str(content);
emitted[idx] = true;
}
}
}
for (idx, &(_, content)) in section_map.iter().enumerate() {
if !emitted[idx] {
out.push_str(content);
}
}
out
}
fn extract_section_name(header: &str) -> &str {
let trimmed = header.trim().trim_start_matches("# ");
if trimmed.starts_with('[') && trimmed.contains(']') {
let inner = &trimmed[1..];
if let Some(end) = inner.find(']') {
return &inner[..end];
}
}
trimmed
}
fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
let mut sections: Vec<(String, String)> = Vec::new();
let mut current_header = String::new();
let mut current_content = String::new();
for line in toml_str.lines() {
let trimmed = line.trim();
if is_top_level_section_header(trimmed) {
sections.push((current_header.clone(), current_content.clone()));
trimmed.clone_into(&mut current_header);
line.clone_into(&mut current_content);
current_content.push('\n');
} else {
current_content.push_str(line);
current_content.push('\n');
}
}
if !current_header.is_empty() || !current_content.is_empty() {
sections.push((current_header, current_content));
}
sections
}
fn is_top_level_section_header(line: &str) -> bool {
if line.starts_with('[')
&& !line.starts_with("[[")
&& let Some(end) = line.find(']')
{
return !line[1..end].contains('.');
}
false
}
pub trait Migration: Send + Sync {
fn name(&self) -> &'static str;
fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
}
mod steps;
use steps::{
MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
MigrateAutodreamConfig, MigrateCavemanConfig, MigrateCocoonProviderNotice,
MigrateCocoonShowBalance, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
MigrateDeepLinkConfig, MigrateDurableConfig, MigrateEgressConfig, MigrateEmbedProviderRename,
MigrateEvalModelToProvider, MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig,
MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig,
MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete,
MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig,
MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpRetryAndToolTimeout,
MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported,
MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread,
MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge,
MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig,
MigrateNliConfig, MigrateOrchestrationAssetSensitivity, MigrateOrchestrationPersistence,
MigrateOrchestratorProvider, MigrateOtelFilter, MigratePiiFilterNames,
MigratePlannerModelToProvider, MigratePolicyProviderAndUtilityWindow,
MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs,
MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter, MigrateSchedulerDaemon,
MigrateSecretMaskingConfig, MigrateServeConfig, MigrateSessionPersistProviderOverrides,
MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
MigrateShellCheckpointsConfig, MigrateShellTransactional, MigrateSttToProvider,
MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse, MigrateTuiThemeConfig,
MigrateTuiThemeDefaults, MigrateUtilityHighGainTools, MigrateVigilConfig,
MigrateWorktreeConfig, MigrateWorktreeGitTimeout,
};
pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
std::sync::LazyLock::new(|| {
vec![
Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
Box::new(MigratePlannerModelToProvider),
Box::new(MigrateMcpTrustLevels),
Box::new(MigrateAgentRetryToToolsRetry),
Box::new(MigrateDatabaseUrl),
Box::new(MigrateShellTransactional),
Box::new(MigrateAgentBudgetHint),
Box::new(MigrateForgettingConfig),
Box::new(MigrateCompressionPredictorConfig),
Box::new(MigrateMicrocompactConfig),
Box::new(MigrateAutodreamConfig),
Box::new(MigrateMagicDocsConfig),
Box::new(MigrateTelemetryConfig),
Box::new(MigrateSupervisorConfig),
Box::new(MigrateOtelFilter),
Box::new(MigrateEgressConfig),
Box::new(MigrateVigilConfig),
Box::new(MigrateSandboxConfig),
Box::new(MigrateSandboxEgressFilter),
Box::new(MigrateOrchestrationPersistence),
Box::new(MigrateSessionRecapConfig),
Box::new(MigrateMcpElicitationConfig),
Box::new(MigrateQualityConfig),
Box::new(MigrateAcpSubagentsConfig),
Box::new(MigrateHooksPermissionDeniedConfig),
Box::new(MigrateMemoryGraph),
Box::new(MigrateSchedulerDaemon),
Box::new(MigrateMemoryRetrieval),
Box::new(MigrateMemoryReasoning),
Box::new(MigrateMemoryReasoningJudge),
Box::new(MigrateMemoryHebbian),
Box::new(MigrateMemoryHebbianConsolidation),
Box::new(MigrateMemoryHebbianSpread),
Box::new(MigrateHooksTurnComplete),
Box::new(MigrateFocusAutoConsolidateMinWindow),
Box::new(MigrateSessionProviderPersistence),
Box::new(MigrateMemoryRetrievalQueryBias),
Box::new(MigrateMemoryPersonaConfig),
Box::new(MigrateQdrantApiKey),
Box::new(MigrateMcpMaxConnectAttempts),
Box::new(MigrateGoalsConfig),
Box::new(MigrateToolsCompressionConfig),
Box::new(MigrateOrchestratorProvider),
Box::new(MigrateProviderMaxConcurrent),
Box::new(MigrateGonkagateToGonka),
Box::new(MigrateCocoonProviderNotice),
Box::new(MigrateTraceMetadata),
Box::new(MigrateFiveSignalConfig),
Box::new(MigrateEmbedProviderRename),
Box::new(MigrateMcpRetryAndToolTimeout),
Box::new(MigrateFidelityTimeoutDefaults),
Box::new(MigrateSessionPersistProviderOverrides),
Box::new(MigrateCocoonShowBalance),
Box::new(MigrateWorktreeConfig),
Box::new(MigrateWorktreeGitTimeout),
Box::new(MigrateLlmStreamLimits),
Box::new(MigrateDurableConfig),
Box::new(MigrateEvalModelToProvider),
Box::new(MigrateCavemanConfig),
Box::new(MigrateShellCheckpointsConfig),
Box::new(MigrateKnowledgeConfig),
Box::new(MigrateDeepLinkConfig),
Box::new(MigrateMemoryGraphRecallIncludeImported),
Box::new(MigratePolicyProviderAndUtilityWindow),
Box::new(MigrateTuiThemeConfig),
Box::new(MigrateTuiThemeDefaults),
Box::new(MigrateTuiDelights),
Box::new(MigrateTuiMouse),
Box::new(MigrateOrchestrationAssetSensitivity),
Box::new(MigrateSessionPersistenceConfig),
Box::new(MigrateServeConfig),
Box::new(MigrateNliConfig),
Box::new(MigrateSecretMaskingConfig),
Box::new(MigratePiiFilterNames),
Box::new(MigrateQdrantTimeoutSecs),
Box::new(MigrateUtilityHighGainTools),
]
});
#[cfg(test)]
fn make_formatted_str(s: &str) -> Value {
use toml_edit::Formatted;
Value::String(Formatted::new(s.to_owned()))
}
#[cfg(test)]
mod tests;