use super::types::{AgentConfigToml, DEFAULT_AGENTS_TOML};
use crate::agents::ccs_env::CcsEnvVarsError;
use crate::agents::fallback::FallbackConfig;
use crate::agents::fallback::ResolvedDrainConfig;
use crate::workspace::{Workspace, WorkspaceFs};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
pub struct AgentsConfigFile {
#[serde(default)]
pub agents: HashMap<String, AgentConfigToml>,
#[serde(default)]
pub agent_chains: HashMap<String, Vec<String>>,
#[serde(default)]
pub agent_drains: HashMap<String, String>,
#[serde(default, rename = "agent_chain")]
pub fallback: Option<FallbackConfig>,
#[serde(skip)]
raw_toml: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum AgentConfigError {
#[error("Failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse TOML: {0}")]
Toml(#[from] toml::de::Error),
#[error("Built-in agents.toml template is invalid TOML: {0}")]
DefaultTemplateToml(toml::de::Error),
#[error("Invalid agent drain configuration: {0}")]
InvalidDrainConfig(String),
#[error("{0}")]
CcsEnvVars(#[from] CcsEnvVarsError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigInitResult {
AlreadyExists,
Created,
}
impl AgentsConfigFile {
pub fn resolve_drains_checked(&self) -> Result<Option<ResolvedDrainConfig>, AgentConfigError> {
if let Some(raw_toml) = &self.raw_toml {
let parsed: crate::config::UnifiedConfig = toml::from_str(raw_toml)?;
return crate::config::UnifiedConfig::default()
.merge_with_content(raw_toml, &parsed)
.resolve_agent_drains_checked()
.map_err(|err| AgentConfigError::InvalidDrainConfig(err.to_string()));
}
crate::config::UnifiedConfig {
agent_chains: self.agent_chains.clone(),
agent_drains: self.agent_drains.clone(),
agent_chain: self.fallback.clone(),
..crate::config::UnifiedConfig::default()
}
.resolve_agent_drains_checked()
.map_err(|err| AgentConfigError::InvalidDrainConfig(err.to_string()))
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
let path = path.as_ref();
let workspace = WorkspaceFs::new(PathBuf::from("."));
if !workspace.exists(path) {
return Ok(None);
}
let contents = workspace.read(path)?;
let config: Self = toml::from_str(&contents)?;
Ok(Some(Self {
raw_toml: Some(contents),
..config
}))
}
pub fn load_from_file_with_workspace(
path: &Path,
workspace: &dyn Workspace,
) -> Result<Option<Self>, AgentConfigError> {
if !workspace.exists(path) {
return Ok(None);
}
let contents = workspace
.read(path)
.map_err(|e| AgentConfigError::Io(std::io::Error::other(e)))?;
let config: Self = toml::from_str(&contents)?;
Ok(Some(Self {
raw_toml: Some(contents),
..config
}))
}
pub fn ensure_config_exists<P: AsRef<Path>>(
path: P,
) -> Result<ConfigInitResult, std::io::Error> {
let path = path.as_ref();
let workspace = WorkspaceFs::new(PathBuf::from("."));
if workspace.exists(path) {
return Ok(ConfigInitResult::AlreadyExists);
}
if let Some(parent) = path.parent() {
workspace.create_dir_all(parent)?;
}
workspace.write(path, DEFAULT_AGENTS_TOML)?;
Ok(ConfigInitResult::Created)
}
pub fn ensure_config_exists_with_workspace(
path: &Path,
workspace: &dyn Workspace,
) -> Result<ConfigInitResult, std::io::Error> {
if workspace.exists(path) {
return Ok(ConfigInitResult::AlreadyExists);
}
if let Some(parent) = path.parent() {
workspace.create_dir_all(parent)?;
}
workspace.write(path, DEFAULT_AGENTS_TOML)?;
Ok(ConfigInitResult::Created)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::MemoryWorkspace;
#[test]
fn load_from_file_with_workspace_returns_none_when_missing() {
let workspace = MemoryWorkspace::new_test();
let path = Path::new(".agent/agents.toml");
let Ok(result) = AgentsConfigFile::load_from_file_with_workspace(path, &workspace) else {
panic!("load_from_file_with_workspace failed");
};
assert!(result.is_none());
}
#[test]
fn load_from_file_with_workspace_parses_valid_config() {
let workspace =
MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
let path = Path::new(".agent/agents.toml");
let Ok(Some(config)) = AgentsConfigFile::load_from_file_with_workspace(path, &workspace)
else {
panic!("load_from_file_with_workspace failed or returned None");
};
assert!(config.agents.contains_key("claude"));
}
#[test]
fn ensure_config_exists_with_workspace_creates_file_when_missing() {
let workspace = MemoryWorkspace::new_test();
let path = Path::new(".agent/agents.toml");
let Ok(result) = AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace)
else {
panic!("ensure_config_exists_with_workspace failed");
};
assert!(matches!(result, ConfigInitResult::Created));
assert!(workspace.exists(path));
let Ok(contents) = workspace.read(path) else {
panic!("failed to read created file");
};
assert_eq!(contents, DEFAULT_AGENTS_TOML);
}
#[test]
fn ensure_config_exists_with_workspace_does_not_overwrite_existing() {
let workspace =
MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
let path = Path::new(".agent/agents.toml");
let Ok(result) = AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace)
else {
panic!("ensure_config_exists_with_workspace failed");
};
assert!(matches!(result, ConfigInitResult::AlreadyExists));
let Ok(contents) = workspace.read(path) else {
panic!("failed to read file");
};
assert_eq!(contents, "# custom config");
}
#[test]
fn default_template_uses_named_chain_and_drain_schema() {
let uncommented_lines = DEFAULT_AGENTS_TOML
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect::<Vec<_>>();
assert!(
!uncommented_lines.contains(&"[agent_chain]"),
"default template should no longer use legacy [agent_chain] as the primary schema"
);
assert!(
uncommented_lines.contains(&"[agent_chains]"),
"default template should define reusable named chains"
);
assert!(
uncommented_lines.contains(&"[agent_drains]"),
"default template should bind built-in drains to named chains"
);
}
}