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    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Option<Self>, AgentConfigError> {
49        let path = path.as_ref();
50        let workspace = WorkspaceFs::new(PathBuf::from("."));
51
52        if !workspace.exists(path) {
53            return Ok(None);
54        }
55
56        let contents = workspace.read(path)?;
57        let config: Self = toml::from_str(&contents)?;
58        Ok(Some(config))
59    }
60
61    /// Load agents config from a file using workspace abstraction.
62    ///
63    /// This is the architecture-conformant version that uses the Workspace trait
64    /// instead of direct filesystem access, allowing for proper testing with
65    /// MemoryWorkspace.
66    pub fn load_from_file_with_workspace(
67        path: &Path,
68        workspace: &dyn Workspace,
69    ) -> Result<Option<Self>, AgentConfigError> {
70        if !workspace.exists(path) {
71            return Ok(None);
72        }
73
74        let contents = workspace
75            .read(path)
76            .map_err(|e| AgentConfigError::Io(io::Error::other(e)))?;
77        let config: Self = toml::from_str(&contents)?;
78        Ok(Some(config))
79    }
80
81    /// Ensure agents config file exists, creating it from template if needed.
82    pub fn ensure_config_exists<P: AsRef<Path>>(path: P) -> io::Result<ConfigInitResult> {
83        let path = path.as_ref();
84        let workspace = WorkspaceFs::new(PathBuf::from("."));
85
86        if workspace.exists(path) {
87            return Ok(ConfigInitResult::AlreadyExists);
88        }
89
90        // Create parent directories if they don't exist
91        if let Some(parent) = path.parent() {
92            workspace.create_dir_all(parent)?;
93        }
94
95        // Write the default template
96        workspace.write(path, DEFAULT_AGENTS_TOML)?;
97
98        Ok(ConfigInitResult::Created)
99    }
100
101    /// Ensure agents config file exists using workspace abstraction.
102    ///
103    /// This is the architecture-conformant version that uses the Workspace trait
104    /// instead of direct filesystem access, allowing for proper testing with
105    /// MemoryWorkspace.
106    pub fn ensure_config_exists_with_workspace(
107        path: &Path,
108        workspace: &dyn Workspace,
109    ) -> io::Result<ConfigInitResult> {
110        if workspace.exists(path) {
111            return Ok(ConfigInitResult::AlreadyExists);
112        }
113
114        // Create parent directories if they don't exist
115        if let Some(parent) = path.parent() {
116            workspace.create_dir_all(parent)?;
117        }
118
119        // Write the default template
120        workspace.write(path, DEFAULT_AGENTS_TOML)?;
121
122        Ok(ConfigInitResult::Created)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::workspace::MemoryWorkspace;
130
131    #[test]
132    fn load_from_file_with_workspace_returns_none_when_missing() {
133        let workspace = MemoryWorkspace::new_test();
134        let path = Path::new(".agent/agents.toml");
135
136        let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
137        assert!(result.is_none());
138    }
139
140    #[test]
141    fn load_from_file_with_workspace_parses_valid_config() {
142        let workspace =
143            MemoryWorkspace::new_test().with_file(".agent/agents.toml", DEFAULT_AGENTS_TOML);
144        let path = Path::new(".agent/agents.toml");
145
146        let result = AgentsConfigFile::load_from_file_with_workspace(path, &workspace).unwrap();
147        assert!(result.is_some());
148        assert!(result.unwrap().agents.contains_key("claude"));
149    }
150
151    #[test]
152    fn ensure_config_exists_with_workspace_creates_file_when_missing() {
153        let workspace = MemoryWorkspace::new_test();
154        let path = Path::new(".agent/agents.toml");
155
156        let result =
157            AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
158        assert!(matches!(result, ConfigInitResult::Created));
159        assert!(workspace.exists(path));
160        assert_eq!(workspace.read(path).unwrap(), DEFAULT_AGENTS_TOML);
161    }
162
163    #[test]
164    fn ensure_config_exists_with_workspace_does_not_overwrite_existing() {
165        let workspace =
166            MemoryWorkspace::new_test().with_file(".agent/agents.toml", "# custom config");
167        let path = Path::new(".agent/agents.toml");
168
169        let result =
170            AgentsConfigFile::ensure_config_exists_with_workspace(path, &workspace).unwrap();
171        assert!(matches!(result, ConfigInitResult::AlreadyExists));
172        assert_eq!(workspace.read(path).unwrap(), "# custom config");
173    }
174}