Skip to main content

ralph_workflow/agents/config/
file.rs

1use 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// Note: Legacy global config directory functions (global_config_dir, global_agents_config_path)
12// have been removed. Use unified config path from the config module instead.
13
14/// Root TOML configuration structure.
15#[derive(Debug, Clone, Deserialize)]
16pub struct AgentsConfigFile {
17    /// Map of agent name to configuration.
18    #[serde(default)]
19    pub agents: HashMap<String, AgentConfigToml>,
20    /// Named reusable agent chains.
21    #[serde(default)]
22    pub agent_chains: HashMap<String, Vec<String>>,
23    /// Built-in drain bindings to named chains.
24    #[serde(default)]
25    pub agent_drains: HashMap<String, String>,
26    /// Legacy agent chain configuration (preferred agents + fallbacks).
27    #[serde(default, rename = "agent_chain")]
28    pub fallback: Option<FallbackConfig>,
29    #[serde(skip)]
30    raw_toml: Option<String>,
31}
32
33/// Error type for agent configuration loading.
34#[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/// Result of checking/initializing the agents config file.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ConfigInitResult {
51    /// Config file already exists, no action taken.
52    AlreadyExists,
53    /// Config file was just created from template.
54    Created,
55}
56
57impl AgentsConfigFile {
58    /// Resolve the configured agent chains into explicit built-in drains.
59    ///
60    /// Returns `None` when the file defines no chain configuration at all.
61    ///
62    /// # Errors
63    ///
64    /// Returns error if the named chain/drain schema is internally inconsistent
65    /// or mixed with the legacy `[agent_chain]` table.
66    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    /// Load agents config from a file, returning None if file doesn't exist.
86    ///
87    /// # Errors
88    ///
89    /// Returns error if:
90    /// - File cannot be read
91    /// - File contents are not valid TOML
92    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    /// Load agents config from a file using workspace abstraction.
107    ///
108    /// This is the architecture-conformant version that uses the Workspace trait
109    /// instead of direct filesystem access, allowing for proper testing with
110    /// `MemoryWorkspace`.
111    ///
112    /// # Errors
113    ///
114    /// Returns error if:
115    /// - File cannot be read from workspace
116    /// - File contents are not valid TOML
117    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    /// Ensure agents config file exists, creating it from template if needed.
134    ///
135    /// # Errors
136    ///
137    /// Returns error if:
138    /// - Parent directories cannot be created
139    /// - Default template cannot be written to file
140    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        // Create parent directories if they don't exist
149        if let Some(parent) = path.parent() {
150            workspace.create_dir_all(parent)?;
151        }
152
153        // Write the default template
154        workspace.write(path, DEFAULT_AGENTS_TOML)?;
155
156        Ok(ConfigInitResult::Created)
157    }
158
159    /// Ensure agents config file exists using workspace abstraction.
160    ///
161    /// This is the architecture-conformant version that uses the Workspace trait
162    /// instead of direct filesystem access, allowing for proper testing with
163    /// `MemoryWorkspace`.
164    ///
165    /// # Errors
166    ///
167    /// Returns error if:
168    /// - Parent directories cannot be created in workspace
169    /// - Default template cannot be written to workspace
170    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        // Create parent directories if they don't exist
179        if let Some(parent) = path.parent() {
180            workspace.create_dir_all(parent)?;
181        }
182
183        // Write the default template
184        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}