use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct Config {
pub agent_cmd: Option<String>,
pub routes_enabled: bool,
pub routes_auto: bool,
pub raw: BTreeMap<String, String>,
}
impl Default for Config {
fn default() -> Config {
Config {
agent_cmd: None,
routes_enabled: true,
routes_auto: true,
raw: BTreeMap::new(),
}
}
}
impl Config {
pub fn load(root: &Path) -> Config {
let Ok(text) = std::fs::read_to_string(root.join("radar.toml")) else {
return Config::default();
};
parse(&text)
}
}
fn unquote(v: &str) -> String {
let v = v.trim();
v.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v)
.to_string()
}
fn strip_comment(value: &str) -> &str {
let mut quoted = false;
let mut escaped = false;
for (index, character) in value.char_indices() {
if escaped {
escaped = false;
continue;
}
match character {
'\\' if quoted => escaped = true,
'"' => quoted = !quoted,
'#' if !quoted => return value[..index].trim_end(),
_ => {}
}
}
value.trim()
}
pub fn parse(text: &str) -> Config {
let mut raw = BTreeMap::new();
let mut section = String::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
section = name.trim().to_string();
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let full = if section.is_empty() {
key.trim().to_string()
} else {
format!("{section}.{}", key.trim())
};
let value = strip_comment(value);
raw.insert(full, unquote(value));
}
let flag = |key: &str| raw.get(key).map(|v| v != "false").unwrap_or(true);
Config {
agent_cmd: raw.get("agent.cmd").cloned().filter(|s| !s.is_empty()),
routes_enabled: flag("routes.enabled"),
routes_auto: flag("routes.auto"),
raw,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_flat_subset() {
let cfg = parse("# radar config\n[agent]\ncmd = \"claude\"\n\n[scan]\nmax = 10\n");
assert_eq!(cfg.agent_cmd.as_deref(), Some("claude"));
assert_eq!(cfg.raw.get("scan.max").map(String::as_str), Some("10"));
}
#[test]
fn route_flags_default_on_and_force_disable() {
assert!(parse("").routes_enabled && parse("").routes_auto);
let cfg = parse("[routes]\nenabled = false\n");
assert!(!cfg.routes_enabled);
let cfg = parse("[routes]\nauto = false\n");
assert!(cfg.routes_enabled && !cfg.routes_auto);
}
#[test]
fn missing_file_and_junk_lines_degrade() {
let cfg = parse("just some prose\n[weird\nkey without eq\n");
assert_eq!(cfg.agent_cmd, None);
let cfg = Config::load(Path::new("/nonexistent-gean"));
assert_eq!(cfg.agent_cmd, None);
}
#[test]
fn trailing_comments_do_not_leak_into_values() {
let cfg =
parse("[agent]\ncmd = \"runner # retained\" # removed\n[scan]\nmax = 10 # limit\n");
assert_eq!(cfg.agent_cmd.as_deref(), Some("runner # retained"));
assert_eq!(cfg.raw.get("scan.max").map(String::as_str), Some("10"));
}
}