use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use anyhow::{Context, Result, anyhow};
use serde::Deserialize;
use crate::agent::AgentSpec;
#[derive(Debug, Default, Deserialize)]
pub struct AgentSettingsToml {
#[serde(default)]
pub default: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct AgentToml {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub prompt_args: Option<Vec<String>>,
}
pub struct AgentRegistry {
pub agents: Vec<AgentSpec>,
pub default: Option<String>,
}
impl AgentRegistry {
pub fn build(user: HashMap<String, AgentToml>, settings: AgentSettingsToml) -> Self {
let mut by_name: BTreeMap<String, AgentSpec> = crate::agent::builtin_agents()
.into_iter()
.map(|a| (a.name.clone(), a))
.collect();
for (name, t) in user {
by_name.insert(
name.clone(),
AgentSpec {
name,
command: t.command,
args: t.args,
prompt_args: t
.prompt_args
.unwrap_or_else(crate::agent::default_prompt_args),
},
);
}
AgentRegistry {
agents: by_name.into_values().collect(),
default: settings.default,
}
}
pub fn find(&self, name: &str) -> Option<&AgentSpec> {
self.agents.iter().find(|a| a.name == name)
}
}
pub fn persist_default_agent(name: &str) -> Result<PathBuf> {
let path = super::default_path().ok_or_else(|| anyhow!("no config path (is $HOME set?)"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating config dir {}", parent.display()))?;
}
let existing = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
let updated = insert_default_agent(&existing, name);
std::fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
fn insert_default_agent(existing: &str, name: &str) -> String {
let line = format!("default = {}", toml_basic_string(name));
if let Some(header_idx) = existing.lines().position(|l| l.trim() == "[agent]") {
let mut out: Vec<String> = existing.lines().map(str::to_string).collect();
out.insert(header_idx + 1, line);
let mut s = out.join("\n");
if existing.ends_with('\n') {
s.push('\n');
}
return s;
}
let mut s = existing.to_string();
if !s.is_empty() && !s.ends_with('\n') {
s.push('\n');
}
if !s.is_empty() {
s.push('\n');
}
s.push_str("[agent]\n");
s.push_str(&line);
s.push('\n');
s
}
pub(super) fn toml_basic_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{0C}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builtins_present_and_sorted() {
let reg = AgentRegistry::build(HashMap::new(), AgentSettingsToml::default());
let names: Vec<&str> = reg.agents.iter().map(|a| a.name.as_str()).collect();
assert!(names.contains(&"claude"));
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
assert!(reg.default.is_none());
}
#[test]
fn user_entry_overrides_builtin_and_adds_new() {
let mut user = HashMap::new();
user.insert(
"claude".to_string(),
AgentToml {
command: "claude".into(),
args: vec!["--model".into(), "opus".into()],
prompt_args: None,
},
);
user.insert(
"mybot".to_string(),
AgentToml {
command: "/usr/local/bin/mybot".into(),
args: vec![],
prompt_args: Some(vec!["-m".into(), "{prompt}".into()]),
},
);
let reg = AgentRegistry::build(
user,
AgentSettingsToml {
default: Some("claude".into()),
},
);
let claude = reg.find("claude").unwrap();
assert_eq!(claude.args, vec!["--model", "opus"]);
assert_eq!(claude.prompt_args, vec!["{prompt}"]);
let mybot = reg.find("mybot").unwrap();
assert_eq!(mybot.command, "/usr/local/bin/mybot");
assert_eq!(mybot.prompt_args, vec!["-m", "{prompt}"]);
assert_eq!(reg.default.as_deref(), Some("claude"));
}
#[test]
fn insert_appends_fresh_table_to_empty() {
assert_eq!(
insert_default_agent("", "claude"),
"[agent]\ndefault = \"claude\"\n"
);
}
#[test]
fn insert_appends_after_existing_content_preserving_it() {
let existing = "[editor]\ntab_width = 4\n";
let out = insert_default_agent(existing, "codex");
assert_eq!(
out,
"[editor]\ntab_width = 4\n\n[agent]\ndefault = \"codex\"\n"
);
}
#[test]
fn insert_escapes_special_chars_in_name() {
let out = insert_default_agent("", "od\"d\\name");
assert_eq!(out, "[agent]\ndefault = \"od\\\"d\\\\name\"\n");
let parsed: toml::Value = toml::from_str(&out).unwrap();
assert_eq!(parsed["agent"]["default"].as_str(), Some("od\"d\\name"));
}
#[test]
fn insert_into_existing_agent_table_keeps_comments() {
let existing = "# my config\n[agent]\n# pick later\n[editor]\ntab_width = 2\n";
let out = insert_default_agent(existing, "gemini");
assert_eq!(
out,
"# my config\n[agent]\ndefault = \"gemini\"\n# pick later\n[editor]\ntab_width = 2\n"
);
}
}