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::workspace::{Workspace, WorkspaceFs};
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::io;
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    /// Agent chain configuration (preferred agents + fallbacks).
20    #[serde(default, rename = "agent_chain")]
21    pub fallback: FallbackConfig,
22}
23
24/// Error type for agent configuration loading.
25#[derive(Debug, thiserror::Error)]
26pub enum AgentConfigError {
27    #[error("Failed to read config file: {0}")]
28    Io(#[from] io::Error),
29    #[error("Failed to parse TOML: {0}")]
30    Toml(#[from] toml::de::Error),
31    #[error("Built-in agents.toml template is invalid TOML: {0}")]
32    DefaultTemplateToml(toml::de::Error),
33    #[error("{0}")]
34    CcsEnvVars(#[from] CcsEnvVarsError),
35}
36
37/// Result of checking/initializing the agents config file.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ConfigInitResult {
40    /// Config file already exists, no action taken.
41    AlreadyExists,
42    /// Config file was just created from template.
43    Created,
44}
45
46impl AgentsConfigFile {
47    /// Load agents config from a file, returning None if file doesn't exist.
48    ///
49    /// # Errors
50    ///
51    /// Returns error if:
52    /// - File cannot be read
53    /// - File contents are not valid TOML
54    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
55        let path = path.as_ref();
56        let workspace = WorkspaceFs::new(PathBuf::from("."));
57
58        if !workspace.exists(path) {
59            return Ok(None);
60        }
61
62        let contents = workspace.read(path)?;
63        let config: Self = toml::from_str(&contents)?;
64        Ok(Some(config))
65    }
66
67    /// Load agents config from a file using workspace abstraction.
68    ///
69    /// This is the architecture-conformant version that uses the Workspace trait
70    /// instead of direct filesystem access, allowing for proper testing with
71    /// `MemoryWorkspace`.
72    ///
73    /// # Errors
74    ///
75    /// Returns error if:
76    /// - File cannot be read from workspace
77    /// - File contents are not valid TOML
78    pub fn load_from_file_with_workspace(
79        path: &Path,
80        workspace: &dyn Workspace,
81    ) -> Result<Option<Self>, AgentConfigError> {
82        if !workspace.exists(path) {
83            return Ok(None);
84        }
85
86        let contents = workspace
87            .read(path)
88            .map_err(|e| AgentConfigError::Io(io::Error::other(e)))?;
89        let config: Self = toml::from_str(&contents)?;
90        Ok(Some(config))
91    }
92
93    /// Ensure agents config file exists, creating it from template if needed.
94    ///
95    /// # Errors
96    ///
97    /// Returns error if:
98    /// - Parent directories cannot be created
99    /// - Default template cannot be written to file
100    pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
101        let path = path.as_ref();
102        let workspace = WorkspaceFs::new(PathBuf::from("."));
103
104        if workspace.exists(path) {
105            return Ok(ConfigInitResult::AlreadyExists);
106        }
107
108        // Create parent directories if they don't exist
109        if let Some(parent) = path.parent() {
110            workspace.create_dir_all(parent)?;
111        }
112
113        // Write the default template
114        workspace.write(path, DEFAULT_AGENTS_TOML)?;
115
116        Ok(ConfigInitResult::Created)
117    }
118
119    /// Ensure agents config file exists using workspace abstraction.
120    ///
121    /// This is the architecture-conformant version that uses the Workspace trait
122    /// instead of direct filesystem access, allowing for proper testing with
123    /// `MemoryWorkspace`.
124    ///
125    /// # Errors
126    ///
127    /// Returns error if:
128    /// - Parent directories cannot be created in workspace
129    /// - Default template cannot be written to workspace
130    pub fn ensure_config_exists_with_workspace(
131        path: &Path,
132        workspace: &dyn Workspace,
133    ) -> io::Result<ConfigInitResult> {
134        if workspace.exists(path) {
135            return Ok(ConfigInitResult::AlreadyExists);
136        }
137
138        // Create parent directories if they don't exist
139        if let Some(parent) = path.parent() {
140            workspace.create_dir_all(parent)?;
141        }
142
143        // Write the default template
144        workspace.write(path, DEFAULT_AGENTS_TOML)?;
145
146        Ok(ConfigInitResult::Created)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use crate::workspace::MemoryWorkspace;
154
155    #[test]
156    fn load_from_file_with_workspace_returns_none_when_missing() {
157        let workspace = MemoryWorkspace::new_test();
158        let path = Path::new(".agent/agents.toml");
159
160        let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
161        assert!(result.is_none());
162    }
163
164    #[test]
165    fn load_from_file_with_workspace_parses_valid_config() {
166        let workspace =
167            MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
168        let path = Path::new(".agent/agents.toml");
169
170        let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
171        assert!(result.is_some());
172        assert!(result.unwrap().agents.contains_key("claude"));
173    }
174
175    #[test]
176    fn ensure_config_exists_with_workspace_creates_file_when_missing() {
177        let workspace = MemoryWorkspace::new_test();
178        let path = Path::new(".agent/agents.toml");
179
180        let result =
181            AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
182        assert!(matches!(result, ConfigInitResult::Created));
183        assert!(workspace.exists(path));
184        assert_eq!(workspace.read(path).unwrap(), DEFAULT_AGENTS_TOML);
185    }
186
187    #[test]
188    fn ensure_config_exists_with_workspace_does_not_overwrite_existing() {
189        let workspace =
190            MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
191        let path = Path::new(".agent/agents.toml");
192
193        let result =
194            AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
195        assert!(matches!(result, ConfigInitResult::AlreadyExists));
196        assert_eq!(workspace.read(path).unwrap(), "# custom config");
197    }
198}