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