ralph_workflow/agents/config/
file.rs1use super::types::{AgentConfigToml, DEFAULT_AGENTS_TOML};
2use crate::agents::ccs_env::CcsEnvVarsError;
3use crate::agents::fallback::FallbackConfig;
4use crate::agents::fallback::ResolvedDrainConfig;
5use crate::workspace::{Workspace, WorkspaceFs};
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::io;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Deserialize)]
16pub struct AgentsConfigFile {
17 #[serde(default)]
19 pub agents: HashMap<String, AgentConfigToml>,
20 #[serde(default)]
22 pub agent_chains: HashMap<String, Vec<String>>,
23 #[serde(default)]
25 pub agent_drains: HashMap<String, String>,
26 #[serde(default, rename = "agent_chain")]
28 pub fallback: Option<FallbackConfig>,
29 #[serde(skip)]
30 raw_toml: Option<String>,
31}
32
33#[derive(Debug, thiserror::Error)]
35pub enum AgentConfigError {
36 #[error("Failed to read config file: {0}")]
37 Io(#[from] io::Error),
38 #[error("Failed to parse TOML: {0}")]
39 Toml(#[from] toml::de::Error),
40 #[error("Built-in agents.toml template is invalid TOML: {0}")]
41 DefaultTemplateToml(toml::de::Error),
42 #[error("Invalid agent drain configuration: {0}")]
43 InvalidDrainConfig(String),
44 #[error("{0}")]
45 CcsEnvVars(#[from] CcsEnvVarsError),
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ConfigInitResult {
51 AlreadyExists,
53 Created,
55}
56
57impl AgentsConfigFile {
58 pub fn resolve_drains_checked(&self) -> Result<Option<ResolvedDrainConfig>, AgentConfigError> {
67 if let Some(raw_toml) = &self.raw_toml {
68 let parsed: crate::config::UnifiedConfig = toml::from_str(raw_toml)?;
69 return crate::config::UnifiedConfig::default()
70 .merge_with_content(raw_toml, &parsed)
71 .resolve_agent_drains_checked()
72 .map_err(AgentConfigError::InvalidDrainConfig);
73 }
74
75 crate::config::UnifiedConfig {
76 agent_chains: self.agent_chains.clone(),
77 agent_drains: self.agent_drains.clone(),
78 agent_chain: self.fallback.clone(),
79 ..crate::config::UnifiedConfig::default()
80 }
81 .resolve_agent_drains_checked()
82 .map_err(AgentConfigError::InvalidDrainConfig)
83 }
84
85 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
93 let path = path.as_ref();
94 let workspace = WorkspaceFs::new(PathBuf::from("."));
95
96 if !workspace.exists(path) {
97 return Ok(None);
98 }
99
100 let contents = workspace.read(path)?;
101 let mut config: Self = toml::from_str(&contents)?;
102 config.raw_toml = Some(contents);
103 Ok(Some(config))
104 }
105
106 pub fn load_from_file_with_workspace(
118 path: &Path,
119 workspace: &dyn Workspace,
120 ) -> Result<Option<Self>, AgentConfigError> {
121 if !workspace.exists(path) {
122 return Ok(None);
123 }
124
125 let contents = workspace
126 .read(path)
127 .map_err(|e| AgentConfigError::Io(io::Error::other(e)))?;
128 let mut config: Self = toml::from_str(&contents)?;
129 config.raw_toml = Some(contents);
130 Ok(Some(config))
131 }
132
133 pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
141 let path = path.as_ref();
142 let workspace = WorkspaceFs::new(PathBuf::from("."));
143
144 if workspace.exists(path) {
145 return Ok(ConfigInitResult::AlreadyExists);
146 }
147
148 if let Some(parent) = path.parent() {
150 workspace.create_dir_all(parent)?;
151 }
152
153 workspace.write(path, DEFAULT_AGENTS_TOML)?;
155
156 Ok(ConfigInitResult::Created)
157 }
158
159 pub fn ensure_config_exists_with_workspace(
171 path: &Path,
172 workspace: &dyn Workspace,
173 ) -> io::Result<ConfigInitResult> {
174 if workspace.exists(path) {
175 return Ok(ConfigInitResult::AlreadyExists);
176 }
177
178 if let Some(parent) = path.parent() {
180 workspace.create_dir_all(parent)?;
181 }
182
183 workspace.write(path, DEFAULT_AGENTS_TOML)?;
185
186 Ok(ConfigInitResult::Created)
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::workspace::MemoryWorkspace;
194
195 #[test]
196 fn load_from_file_with_workspace_returns_none_when_missing() {
197 let workspace = MemoryWorkspace::new_test();
198 let path = Path::new(".agent/agents.toml");
199
200 let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
201 assert!(result.is_none());
202 }
203
204 #[test]
205 fn load_from_file_with_workspace_parses_valid_config() {
206 let workspace =
207 MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
208 let path = Path::new(".agent/agents.toml");
209
210 let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
211 assert!(result.is_some());
212 assert!(result.unwrap().agents.contains_key("claude"));
213 }
214
215 #[test]
216 fn ensure_config_exists_with_workspace_creates_file_when_missing() {
217 let workspace = MemoryWorkspace::new_test();
218 let path = Path::new(".agent/agents.toml");
219
220 let result =
221 AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
222 assert!(matches!(result, ConfigInitResult::Created));
223 assert!(workspace.exists(path));
224 assert_eq!(workspace.read(path).unwrap(), DEFAULT_AGENTS_TOML);
225 }
226
227 #[test]
228 fn ensure_config_exists_with_workspace_does_not_overwrite_existing() {
229 let workspace =
230 MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
231 let path = Path::new(".agent/agents.toml");
232
233 let result =
234 AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
235 assert!(matches!(result, ConfigInitResult::AlreadyExists));
236 assert_eq!(workspace.read(path).unwrap(), "# custom config");
237 }
238
239 #[test]
240 fn default_template_uses_named_chain_and_drain_schema() {
241 let uncommented_lines = DEFAULT_AGENTS_TOML
242 .lines()
243 .map(str::trim)
244 .filter(|line| !line.is_empty() && !line.starts_with('#'))
245 .collect::<Vec<_>>();
246
247 assert!(
248 !uncommented_lines.contains(&"[agent_chain]"),
249 "default template should no longer use legacy [agent_chain] as the primary schema"
250 );
251 assert!(
252 uncommented_lines.contains(&"[agent_chains]"),
253 "default template should define reusable named chains"
254 );
255 assert!(
256 uncommented_lines.contains(&"[agent_drains]"),
257 "default template should bind built-in drains to named chains"
258 );
259 }
260}