use std::sync::OnceLock;
use crate::config::Config;
pub const CONFIG_SECTIONS: &[&str] = &[
"agent",
"a2a",
"brain",
"browser",
"channels",
"cron",
"daemon",
"database",
"debug",
"doctor",
"image",
"logging",
"memory",
"provider_registry",
"providers",
"tui",
];
pub const SECTION_PARENTS: &[(&str, &str)] = &[
("telegram", "channels"),
("discord", "channels"),
("slack", "channels"),
("whatsapp", "channels"),
("trello", "channels"),
("stt", "providers"),
("tts", "providers"),
("fallback", "providers"),
("custom", "providers"),
];
pub fn known_sections() -> &'static [String] {
static KNOWN: OnceLock<Vec<String>> = OnceLock::new();
KNOWN.get_or_init(|| {
let mut names: Vec<String> = serde_json::to_value(Config::default())
.expect("Config::default serializes")
.as_object()
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default();
names.sort();
names
})
}
pub fn resolve_section(requested: &str) -> Option<String> {
let want = requested.trim().trim_matches('.').to_lowercase();
if want.is_empty() {
return None;
}
let head = want.split('.').next().unwrap_or(&want);
if head == "gateway" {
return Some("a2a".to_string());
}
if known_sections().iter().any(|s| s == head) {
return Some(head.to_string());
}
SECTION_PARENTS
.iter()
.find(|(child, _)| *child == head)
.map(|(_, parent)| (*parent).to_string())
}
pub fn ignored_key_paths(content: &str) -> Result<Vec<String>, String> {
let de = toml::Deserializer::new(content);
let mut ignored: Vec<String> = Vec::new();
let res: Result<Config, _> = serde_ignored::deserialize(de, |path| {
ignored.push(path.to_string());
});
res.map_err(|e| e.to_string())?;
Ok(ignored)
}
pub fn unknown_top_level_sections(content: &str) -> Result<Vec<String>, String> {
Ok(ignored_key_paths(content)?
.into_iter()
.filter(|p| !p.contains('.'))
.collect())
}
pub fn write_guard(section: &str, key: &str, candidate: &str) -> Result<(), String> {
reject_bad_shape(section)?;
let section = section.trim().trim_matches('.');
let target = format!("{section}.{key}");
let ignored = ignored_key_paths(candidate)?;
let blocked = ignored
.iter()
.any(|p| p == &target || target.starts_with(&format!("{p}.")));
if !blocked {
return Ok(());
}
let head = section.split('.').next().unwrap_or(section);
if head == "voice" {
return Err(
"'voice' is a derived, read-only view over providers.stt/providers.tts — \
it cannot be written. Set the underlying keys instead (e.g. \
providers.stt.model, providers.tts.voice)."
.to_string(),
);
}
let suggestion = SECTION_PARENTS
.iter()
.find(|(child, _)| *child == head)
.map(|(_, parent)| format!(" — did you mean '{parent}.{section}'?"))
.or_else(|| suggest(head).map(|s| format!(" — did you mean '{s}'?")))
.unwrap_or_default();
Err(format!(
"unknown config path '{target}': the Config struct has no such section or key{suggestion}. \
Writing it would create a table/key serde ignores on load, so the value would silently \
never apply (#1199). The file was NOT changed. Known sections: {}.",
known_sections().join(", ")
))
}
fn reject_bad_shape(section: &str) -> Result<(), String> {
let trimmed = section.trim();
if trimmed.is_empty() {
return Err("config section is empty".to_string());
}
if trimmed != section {
return Err(format!(
"config section '{section}' has leading/trailing whitespace — it would name a table \
the Config struct ignores on load. Use '{trimmed}'."
));
}
if section.split('.').any(|p| p.is_empty()) {
return Err(format!(
"config section '{section}' has an empty segment — it would name a table the Config \
struct ignores on load. Use one dot between segments."
));
}
Ok(())
}
fn suggest(unknown: &str) -> Option<String> {
let unknown = unknown.to_lowercase();
known_sections()
.iter()
.map(|s| (edit_distance(&unknown, s), s.as_str()))
.filter(|(d, _)| *d <= 2)
.min_by_key(|(d, _)| *d)
.map(|(_, s)| s.to_string())
}
fn edit_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut cur = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
cur.push(
(prev[j + 1] + 1)
.min(cur[j] + 1)
.min(prev[j] + usize::from(ca != cb)),
);
}
prev = cur;
}
prev[b.len()]
}