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::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Deserialize)]
15pub struct AgentsConfigFile {
16 #[serde(default)]
18 pub agents: HashMap<String, AgentConfigToml>,
19 #[serde(default)]
21 pub agent_chains: HashMap<String, Vec<String>>,
22 #[serde(default)]
24 pub agent_drains: HashMap<String, String>,
25 #[serde(default, rename = "agent_chain")]
27 pub fallback: Option<FallbackConfig>,
28 #[serde(skip)]
29 raw_toml: Option<String>,
30}
31
32#[derive(Debug, thiserror::Error)]
34pub enum AgentConfigError {
35 #[error("Failed to read config file: {0}")]
36 Io(#[from] std::io::Error),
37 #[error("Failed to parse TOML: {0}")]
38 Toml(#[from] toml::de::Error),
39 #[error("Built-in agents.toml template is invalid TOML: {0}")]
40 DefaultTemplateToml(toml::de::Error),
41 #[error("Invalid agent drain configuration: {0}")]
42 InvalidDrainConfig(String),
43 #[error("{0}")]
44 CcsEnvVars(#[from] CcsEnvVarsError),
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ConfigInitResult {
50 AlreadyExists,
52 Created,
54}
55
56impl AgentsConfigFile {
57 pub fn resolve_drains_checked(&self) -> Result<Option<ResolvedDrainConfig>, AgentConfigError> {
66 if let Some(raw_toml) = &self.raw_toml {
67 let parsed: crate::config::UnifiedConfig = toml::from_str(raw_toml)?;
68 return crate::config::UnifiedConfig::default()
69 .merge_with_content(raw_toml, &parsed)
70 .resolve_agent_drains_checked()
71 .map_err(|err| AgentConfigError::InvalidDrainConfig(err.to_string()));
72 }
73
74 crate::config::UnifiedConfig {
75 agent_chains: self.agent_chains.clone(),
76 agent_drains: self.agent_drains.clone(),
77 agent_chain: self.fallback.clone(),
78 ..crate::config::UnifiedConfig::default()
79 }
80 .resolve_agent_drains_checked()
81 .map_err(|err| AgentConfigError::InvalidDrainConfig(err.to_string()))
82 }
83
84 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
92 let path = path.as_ref();
93 let workspace = WorkspaceFs::new(PathBuf::from("."));
94
95 if !workspace.exists(path) {
96 return Ok(None);
97 }
98
99 let contents = workspace.read(path)?;
100 let config: Self = toml::from_str(&contents)?;
101 Ok(Some(Self {
102 raw_toml: Some(contents),
103 ..config
104 }))
105 }
106
107 pub fn load_from_file_with_workspace(
119 path: &Path,
120 workspace: &dyn Workspace,
121 ) -> Result<Option<Self>, AgentConfigError> {
122 if !workspace.exists(path) {
123 return Ok(None);
124 }
125
126 let contents = workspace
127 .read(path)
128 .map_err(|e| AgentConfigError::Io(std::io::Error::other(e)))?;
129 let config: Self = toml::from_str(&contents)?;
130 Ok(Some(Self {
131 raw_toml: Some(contents),
132 ..config
133 }))
134 }
135
136 pub fn ensure_config_exists<P: AsRef<Path>>(
144 path: P,
145 ) -> Result<ConfigInitResult, std::io::Error> {
146 let path = path.as_ref();
147 let workspace = WorkspaceFs::new(PathBuf::from("."));
148
149 if workspace.exists(path) {
150 return Ok(ConfigInitResult::AlreadyExists);
151 }
152
153 if let Some(parent) = path.parent() {
155 workspace.create_dir_all(parent)?;
156 }
157
158 workspace.write(path, DEFAULT_AGENTS_TOML)?;
160
161 Ok(ConfigInitResult::Created)
162 }
163
164 pub fn ensure_config_exists_with_workspace(
176 path: &Path,
177 workspace: &dyn Workspace,
178 ) -> Result<ConfigInitResult, std::io::Error> {
179 if workspace.exists(path) {
180 return Ok(ConfigInitResult::AlreadyExists);
181 }
182
183 if let Some(parent) = path.parent() {
185 workspace.create_dir_all(parent)?;
186 }
187
188 workspace.write(path, DEFAULT_AGENTS_TOML)?;
190
191 Ok(ConfigInitResult::Created)
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::workspace::MemoryWorkspace;
199
200 #[test]
201 fn load_from_file_with_workspace_returns_none_when_missing() {
202 let workspace = MemoryWorkspace::new_test();
203 let path = Path::new(".agent/agents.toml");
204
205 let Ok(result) = AgentsConfigFile::load_from_file_with_workspace(path, &workspace) else {
206 panic!("load_from_file_with_workspace failed");
207 };
208 assert!(result.is_none());
209 }
210
211 #[test]
212 fn load_from_file_with_workspace_parses_valid_config() {
213 let workspace =
214 MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
215 let path = Path::new(".agent/agents.toml");
216
217 let Ok(Some(config)) = AgentsConfigFile::load_from_file_with_workspace(path, &workspace)
218 else {
219 panic!("load_from_file_with_workspace failed or returned None");
220 };
221 assert!(config.agents.contains_key("claude"));
222 }
223
224 #[test]
225 fn ensure_config_exists_with_workspace_creates_file_when_missing() {
226 let workspace = MemoryWorkspace::new_test();
227 let path = Path::new(".agent/agents.toml");
228
229 let Ok(result) = AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace)
230 else {
231 panic!("ensure_config_exists_with_workspace failed");
232 };
233 assert!(matches!(result, ConfigInitResult::Created));
234 assert!(workspace.exists(path));
235 let Ok(contents) = workspace.read(path) else {
236 panic!("failed to read created file");
237 };
238 assert_eq!(contents, DEFAULT_AGENTS_TOML);
239 }
240
241 #[test]
242 fn ensure_config_exists_with_workspace_does_not_overwrite_existing() {
243 let workspace =
244 MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
245 let path = Path::new(".agent/agents.toml");
246
247 let Ok(result) = AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace)
248 else {
249 panic!("ensure_config_exists_with_workspace failed");
250 };
251 assert!(matches!(result, ConfigInitResult::AlreadyExists));
252 let Ok(contents) = workspace.read(path) else {
253 panic!("failed to read file");
254 };
255 assert_eq!(contents, "# custom config");
256 }
257
258 #[test]
259 fn default_template_uses_named_chain_and_drain_schema() {
260 let uncommented_lines = DEFAULT_AGENTS_TOML
261 .lines()
262 .map(str::trim)
263 .filter(|line| !line.is_empty() && !line.starts_with('#'))
264 .collect::<Vec<_>>();
265
266 assert!(
267 !uncommented_lines.contains(&"[agent_chain]"),
268 "default template should no longer use legacy [agent_chain] as the primary schema"
269 );
270 assert!(
271 uncommented_lines.contains(&"[agent_chains]"),
272 "default template should define reusable named chains"
273 );
274 assert!(
275 uncommented_lines.contains(&"[agent_drains]"),
276 "default template should bind built-in drains to named chains"
277 );
278 }
279}