Skip to main content

ralph_workflow/config/
path_resolver.rs

1//! Configuration environment abstraction.
2//!
3//! This module provides the [`ConfigEnvironment`] trait that abstracts all
4//! external side effects needed for configuration operations:
5//! - Environment variable access (for path resolution)
6//! - Filesystem operations (for reading/writing config files)
7//!
8//! # Design Philosophy
9//!
10//! Configuration types like `UnifiedConfig` should be pure data structures.
11//! All side effects (env vars, file I/O) are injected through this trait,
12//! making the code testable without mocking globals.
13//!
14//! # Dependency Injection
15//!
16//! Production code uses [`RealConfigEnvironment`] which reads from actual
17//! environment variables and performs real filesystem operations. Tests use
18//! [`MemoryConfigEnvironment`] with in-memory storage for both.
19//!
20//! # Example
21//!
22//! ```ignore
23//! use crate::config::{ConfigEnvironment, RealConfigEnvironment, MemoryConfigEnvironment};
24//!
25//! // Production: uses real env vars and filesystem
26//! let env = RealConfigEnvironment;
27//! let config_path = env.unified_config_path();
28//!
29//! // Testing: uses in-memory storage
30//! let env = MemoryConfigEnvironment::new()
31//!     .with_unified_config_path("/test/config/ralph-workflow.toml")
32//!     .with_prompt_path("/test/repo/PROMPT.md")
33//!     .with_file("/test/repo/PROMPT.md", "# Goal\nTest");
34//! ```
35
36use std::collections::HashMap;
37use std::io;
38use std::path::{Path, PathBuf};
39use std::sync::{Arc, RwLock};
40
41/// Trait for configuration environment access.
42///
43/// This trait abstracts all external side effects needed for configuration:
44/// - Path resolution (which may depend on environment variables)
45/// - File existence checks
46/// - File reading and writing
47/// - Directory creation
48///
49/// By injecting this trait, configuration code becomes pure and testable.
50pub trait ConfigEnvironment: Send + Sync {
51    /// Get the path to the unified config file.
52    ///
53    /// In production, this returns `~/.config/ralph-workflow.toml` or
54    /// `$XDG_CONFIG_HOME/ralph-workflow.toml` if the env var is set.
55    ///
56    /// Returns `None` if the path cannot be determined (e.g., no home directory).
57    fn unified_config_path(&self) -> Option<PathBuf>;
58
59    /// Get the path to the PROMPT.md file.
60    ///
61    /// In production, this returns `./PROMPT.md` (relative to current directory).
62    /// Tests may override this to use a different path.
63    fn prompt_path(&self) -> PathBuf {
64        PathBuf::from("PROMPT.md")
65    }
66
67    /// Check if a file exists at the given path.
68    fn file_exists(&self, path: &Path) -> bool;
69
70    /// Read the contents of a file.
71    fn read_file(&self, path: &Path) -> io::Result<String>;
72
73    /// Write content to a file, creating parent directories if needed.
74    fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
75
76    /// Create directories recursively.
77    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
78}
79
80/// Production implementation of [`ConfigEnvironment`].
81///
82/// Uses real environment variables and filesystem operations:
83/// - Reads `XDG_CONFIG_HOME` for config path resolution
84/// - Uses `std::fs` for all file operations
85#[derive(Debug, Default, Clone, Copy)]
86pub struct RealConfigEnvironment;
87
88impl ConfigEnvironment for RealConfigEnvironment {
89    fn unified_config_path(&self) -> Option<PathBuf> {
90        super::unified::unified_config_path()
91    }
92
93    fn file_exists(&self, path: &Path) -> bool {
94        path.exists()
95    }
96
97    fn read_file(&self, path: &Path) -> io::Result<String> {
98        std::fs::read_to_string(path)
99    }
100
101    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
102        if let Some(parent) = path.parent() {
103            std::fs::create_dir_all(parent)?;
104        }
105        std::fs::write(path, content)
106    }
107
108    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
109        std::fs::create_dir_all(path)
110    }
111}
112
113/// In-memory implementation of [`ConfigEnvironment`] for testing.
114///
115/// Provides complete isolation from the real environment:
116/// - Injected paths instead of environment variables
117/// - In-memory file storage instead of real filesystem
118///
119/// # Example
120///
121/// ```ignore
122/// use crate::config::MemoryConfigEnvironment;
123///
124/// let env = MemoryConfigEnvironment::new()
125///     .with_unified_config_path("/test/config/ralph-workflow.toml")
126///     .with_prompt_path("/test/repo/PROMPT.md")
127///     .with_file("/test/repo/existing.txt", "content");
128///
129/// // Write a file
130/// env.write_file(Path::new("/test/new.txt"), "new content")?;
131///
132/// // Verify it was written
133/// assert!(env.was_written(Path::new("/test/new.txt")));
134/// assert_eq!(env.get_file(Path::new("/test/new.txt")), Some("new content".to_string()));
135/// ```
136#[derive(Debug, Clone, Default)]
137pub struct MemoryConfigEnvironment {
138    unified_config_path: Option<PathBuf>,
139    prompt_path: Option<PathBuf>,
140    /// In-memory file storage.
141    files: Arc<RwLock<HashMap<PathBuf, String>>>,
142    /// Directories that have been created.
143    dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
144}
145
146impl MemoryConfigEnvironment {
147    /// Create a new memory environment with no paths configured.
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Set the unified config path.
153    #[must_use]
154    pub fn with_unified_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
155        self.unified_config_path = Some(path.into());
156        self
157    }
158
159    /// Set the PROMPT.md path.
160    #[must_use]
161    pub fn with_prompt_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
162        self.prompt_path = Some(path.into());
163        self
164    }
165
166    /// Pre-populate a file in memory.
167    #[must_use]
168    pub fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
169        let path = path.into();
170        self.files.write().unwrap().insert(path, content.into());
171        self
172    }
173
174    /// Get the contents of a file (for test assertions).
175    pub fn get_file(&self, path: &Path) -> Option<String> {
176        self.files.read().unwrap().get(path).cloned()
177    }
178
179    /// Check if a file was written (for test assertions).
180    pub fn was_written(&self, path: &Path) -> bool {
181        self.files.read().unwrap().contains_key(path)
182    }
183}
184
185impl ConfigEnvironment for MemoryConfigEnvironment {
186    fn unified_config_path(&self) -> Option<PathBuf> {
187        self.unified_config_path.clone()
188    }
189
190    fn prompt_path(&self) -> PathBuf {
191        self.prompt_path
192            .clone()
193            .unwrap_or_else(|| PathBuf::from("PROMPT.md"))
194    }
195
196    fn file_exists(&self, path: &Path) -> bool {
197        self.files.read().unwrap().contains_key(path)
198    }
199
200    fn read_file(&self, path: &Path) -> io::Result<String> {
201        self.files
202            .read()
203            .unwrap()
204            .get(path)
205            .cloned()
206            .ok_or_else(|| {
207                io::Error::new(
208                    io::ErrorKind::NotFound,
209                    format!("File not found: {}", path.display()),
210                )
211            })
212    }
213
214    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
215        // Simulate creating parent directories
216        if let Some(parent) = path.parent() {
217            self.dirs.write().unwrap().insert(parent.to_path_buf());
218        }
219        self.files
220            .write()
221            .unwrap()
222            .insert(path.to_path_buf(), content.to_string());
223        Ok(())
224    }
225
226    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
227        self.dirs.write().unwrap().insert(path.to_path_buf());
228        Ok(())
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_real_environment_returns_path() {
238        let env = RealConfigEnvironment;
239        // Should return Some path (unless running in weird environment without home dir)
240        let path = env.unified_config_path();
241        if let Some(p) = path {
242            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
243        }
244    }
245
246    #[test]
247    fn test_memory_environment_with_custom_paths() {
248        let env = MemoryConfigEnvironment::new()
249            .with_unified_config_path("/custom/config.toml")
250            .with_prompt_path("/custom/PROMPT.md");
251
252        assert_eq!(
253            env.unified_config_path(),
254            Some(PathBuf::from("/custom/config.toml"))
255        );
256        assert_eq!(env.prompt_path(), PathBuf::from("/custom/PROMPT.md"));
257    }
258
259    #[test]
260    fn test_memory_environment_default_prompt_path() {
261        let env = MemoryConfigEnvironment::new();
262        assert_eq!(env.prompt_path(), PathBuf::from("PROMPT.md"));
263    }
264
265    #[test]
266    fn test_memory_environment_no_unified_config() {
267        let env = MemoryConfigEnvironment::new();
268        assert_eq!(env.unified_config_path(), None);
269    }
270
271    #[test]
272    fn test_memory_environment_file_operations() {
273        let env = MemoryConfigEnvironment::new();
274        let path = Path::new("/test/file.txt");
275
276        // File doesn't exist initially
277        assert!(!env.file_exists(path));
278
279        // Write file
280        env.write_file(path, "test content").unwrap();
281
282        // File now exists
283        assert!(env.file_exists(path));
284        assert_eq!(env.read_file(path).unwrap(), "test content");
285        assert!(env.was_written(path));
286    }
287
288    #[test]
289    fn test_memory_environment_with_prepopulated_file() {
290        let env =
291            MemoryConfigEnvironment::new().with_file("/test/existing.txt", "existing content");
292
293        assert!(env.file_exists(Path::new("/test/existing.txt")));
294        assert_eq!(
295            env.read_file(Path::new("/test/existing.txt")).unwrap(),
296            "existing content"
297        );
298    }
299
300    #[test]
301    fn test_memory_environment_read_nonexistent_file() {
302        let env = MemoryConfigEnvironment::new();
303        let result = env.read_file(Path::new("/nonexistent"));
304        assert!(result.is_err());
305        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
306    }
307}