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 local config file.
60    ///
61    /// In production, this returns `.agent/ralph-workflow.toml` relative to CWD.
62    /// Tests may override this to use a different path.
63    ///
64    /// Returns `None` if local config is not supported or path cannot be determined.
65    fn local_config_path(&self) -> Option<PathBuf> {
66        Some(PathBuf::from(".agent/ralph-workflow.toml"))
67    }
68
69    /// Get the path to the PROMPT.md file.
70    ///
71    /// In production, this returns `./PROMPT.md` (relative to current directory).
72    /// Tests may override this to use a different path.
73    fn prompt_path(&self) -> PathBuf {
74        PathBuf::from("PROMPT.md")
75    }
76
77    /// Check if a file exists at the given path.
78    fn file_exists(&self, path: &Path) -> bool;
79
80    /// Read the contents of a file.
81    fn read_file(&self, path: &Path) -> io::Result<String>;
82
83    /// Write content to a file, creating parent directories if needed.
84    fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
85
86    /// Create directories recursively.
87    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
88
89    /// Get the root of the current git worktree, if running inside one.
90    ///
91    /// Returns `None` if not in a git repository or in a bare repository.
92    /// This is used to resolve local config paths relative to the worktree root
93    /// instead of the current working directory.
94    fn worktree_root(&self) -> Option<PathBuf> {
95        None // Default implementation for backwards compatibility
96    }
97}
98
99/// Production implementation of [`ConfigEnvironment`].
100///
101/// Uses real environment variables and filesystem operations:
102/// - Reads `XDG_CONFIG_HOME` for config path resolution
103/// - Uses `std::fs` for all file operations
104#[derive(Debug, Default, Clone, Copy)]
105pub struct RealConfigEnvironment;
106
107impl ConfigEnvironment for RealConfigEnvironment {
108    fn unified_config_path(&self) -> Option<PathBuf> {
109        super::unified::unified_config_path()
110    }
111
112    fn file_exists(&self, path: &Path) -> bool {
113        path.exists()
114    }
115
116    fn read_file(&self, path: &Path) -> io::Result<String> {
117        std::fs::read_to_string(path)
118    }
119
120    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
121        if let Some(parent) = path.parent() {
122            std::fs::create_dir_all(parent)?;
123        }
124        std::fs::write(path, content)
125    }
126
127    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
128        std::fs::create_dir_all(path)
129    }
130
131    fn worktree_root(&self) -> Option<PathBuf> {
132        git2::Repository::discover(".")
133            .ok()
134            .and_then(|repo| repo.workdir().map(PathBuf::from))
135    }
136
137    fn local_config_path(&self) -> Option<PathBuf> {
138        // Try worktree root first, fall back to default behavior
139        self.worktree_root()
140            .map(|root| root.join(".agent/ralph-workflow.toml"))
141            .or_else(|| Some(PathBuf::from(".agent/ralph-workflow.toml")))
142    }
143}
144
145/// In-memory implementation of [`ConfigEnvironment`] for testing.
146///
147/// Provides complete isolation from the real environment:
148/// - Injected paths instead of environment variables
149/// - In-memory file storage instead of real filesystem
150///
151/// # Example
152///
153/// ```ignore
154/// use crate::config::MemoryConfigEnvironment;
155///
156/// let env = MemoryConfigEnvironment::new()
157///     .with_unified_config_path("/test/config/ralph-workflow.toml")
158///     .with_prompt_path("/test/repo/PROMPT.md")
159///     .with_file("/test/repo/existing.txt", "content");
160///
161/// // Write a file
162/// env.write_file(Path::new("/test/new.txt"), "new content")?;
163///
164/// // Verify it was written
165/// assert!(env.was_written(Path::new("/test/new.txt")));
166/// assert_eq!(env.get_file(Path::new("/test/new.txt")), Some("new content".to_string()));
167/// ```
168#[derive(Debug, Clone, Default)]
169pub struct MemoryConfigEnvironment {
170    unified_config_path: Option<PathBuf>,
171    prompt_path: Option<PathBuf>,
172    local_config_path: Option<PathBuf>,
173    worktree_root: Option<PathBuf>,
174    /// In-memory file storage.
175    files: Arc<RwLock<HashMap<PathBuf, String>>>,
176    /// Directories that have been created.
177    dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
178}
179
180impl MemoryConfigEnvironment {
181    /// Create a new memory environment with no paths configured.
182    pub fn new() -> Self {
183        Self::default()
184    }
185
186    /// Set the unified config path.
187    #[must_use]
188    pub fn with_unified_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
189        self.unified_config_path = Some(path.into());
190        self
191    }
192
193    /// Set the local config path.
194    #[must_use]
195    pub fn with_local_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
196        self.local_config_path = Some(path.into());
197        self
198    }
199
200    /// Set the PROMPT.md path.
201    #[must_use]
202    pub fn with_prompt_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
203        self.prompt_path = Some(path.into());
204        self
205    }
206
207    /// Pre-populate a file in memory.
208    #[must_use]
209    pub fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
210        let path = path.into();
211        self.files.write()
212            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
213            .insert(path, content.into());
214        self
215    }
216
217    /// Set the worktree root path for testing git worktree scenarios.
218    #[must_use]
219    pub fn with_worktree_root<P: Into<PathBuf>>(mut self, path: P) -> Self {
220        self.worktree_root = Some(path.into());
221        self
222    }
223
224    /// Get the contents of a file (for test assertions).
225    pub fn get_file(&self, path: &Path) -> Option<String> {
226        self.files.read()
227            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
228            .get(path).cloned()
229    }
230
231    /// Check if a file was written (for test assertions).
232    pub fn was_written(&self, path: &Path) -> bool {
233        self.files.read()
234            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
235            .contains_key(path)
236    }
237}
238
239impl ConfigEnvironment for MemoryConfigEnvironment {
240    fn unified_config_path(&self) -> Option<PathBuf> {
241        self.unified_config_path.clone()
242    }
243
244    fn local_config_path(&self) -> Option<PathBuf> {
245        // If explicit local_config_path was set, use it (for legacy tests)
246        if let Some(ref path) = self.local_config_path {
247            return Some(path.clone());
248        }
249
250        // Otherwise, use worktree root if available
251        self.worktree_root()
252            .map(|root| root.join(".agent/ralph-workflow.toml"))
253            .or_else(|| Some(PathBuf::from(".agent/ralph-workflow.toml")))
254    }
255
256    fn prompt_path(&self) -> PathBuf {
257        self.prompt_path
258            .clone()
259            .unwrap_or_else(|| PathBuf::from("PROMPT.md"))
260    }
261
262    fn file_exists(&self, path: &Path) -> bool {
263        self.files.read()
264            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
265            .contains_key(path)
266    }
267
268    fn read_file(&self, path: &Path) -> io::Result<String> {
269        self.files
270            .read()
271            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
272            .get(path)
273            .cloned()
274            .ok_or_else(|| {
275                io::Error::new(
276                    io::ErrorKind::NotFound,
277                    format!("File not found: {}", path.display()),
278                )
279            })
280    }
281
282    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
283        // Simulate creating parent directories
284        if let Some(parent) = path.parent() {
285            self.dirs.write()
286                .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment dirs lock")
287                .insert(parent.to_path_buf());
288        }
289        self.files
290            .write()
291            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
292            .insert(path.to_path_buf(), content.to_string());
293        Ok(())
294    }
295
296    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
297        self.dirs.write()
298            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment dirs lock")
299            .insert(path.to_path_buf());
300        Ok(())
301    }
302
303    fn worktree_root(&self) -> Option<PathBuf> {
304        self.worktree_root.clone()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_real_environment_returns_path() {
314        let env = RealConfigEnvironment;
315        // Should return Some path (unless running in weird environment without home dir)
316        let path = env.unified_config_path();
317        if let Some(p) = path {
318            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
319        }
320    }
321
322    #[test]
323    fn test_memory_environment_with_custom_paths() {
324        let env = MemoryConfigEnvironment::new()
325            .with_unified_config_path("/custom/config.toml")
326            .with_prompt_path("/custom/PROMPT.md");
327
328        assert_eq!(
329            env.unified_config_path(),
330            Some(PathBuf::from("/custom/config.toml"))
331        );
332        assert_eq!(env.prompt_path(), PathBuf::from("/custom/PROMPT.md"));
333    }
334
335    #[test]
336    fn test_memory_environment_default_prompt_path() {
337        let env = MemoryConfigEnvironment::new();
338        assert_eq!(env.prompt_path(), PathBuf::from("PROMPT.md"));
339    }
340
341    #[test]
342    fn test_memory_environment_no_unified_config() {
343        let env = MemoryConfigEnvironment::new();
344        assert_eq!(env.unified_config_path(), None);
345    }
346
347    #[test]
348    fn test_memory_environment_file_operations() {
349        let env = MemoryConfigEnvironment::new();
350        let path = Path::new("/test/file.txt");
351
352        // File doesn't exist initially
353        assert!(!env.file_exists(path));
354
355        // Write file
356        env.write_file(path, "test content").unwrap();
357
358        // File now exists
359        assert!(env.file_exists(path));
360        assert_eq!(env.read_file(path).unwrap(), "test content");
361        assert!(env.was_written(path));
362    }
363
364    #[test]
365    fn test_memory_environment_with_prepopulated_file() {
366        let env =
367            MemoryConfigEnvironment::new().with_file("/test/existing.txt", "existing content");
368
369        assert!(env.file_exists(Path::new("/test/existing.txt")));
370        assert_eq!(
371            env.read_file(Path::new("/test/existing.txt")).unwrap(),
372            "existing content"
373        );
374    }
375
376    #[test]
377    fn test_memory_environment_read_nonexistent_file() {
378        let env = MemoryConfigEnvironment::new();
379        let result = env.read_file(Path::new("/nonexistent"));
380        assert!(result.is_err());
381        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
382    }
383
384    #[test]
385    fn test_memory_environment_with_worktree_root() {
386        let env = MemoryConfigEnvironment::new().with_worktree_root("/test/worktree");
387
388        assert_eq!(env.worktree_root(), Some(PathBuf::from("/test/worktree")));
389        assert_eq!(
390            env.local_config_path(),
391            Some(PathBuf::from("/test/worktree/.agent/ralph-workflow.toml"))
392        );
393    }
394
395    #[test]
396    fn test_memory_environment_without_worktree_root() {
397        let env = MemoryConfigEnvironment::new();
398
399        assert_eq!(env.worktree_root(), None);
400        assert_eq!(
401            env.local_config_path(),
402            Some(PathBuf::from(".agent/ralph-workflow.toml"))
403        );
404    }
405
406    #[test]
407    fn test_memory_environment_explicit_local_path_overrides_worktree() {
408        let env = MemoryConfigEnvironment::new()
409            .with_worktree_root("/test/worktree")
410            .with_local_config_path("/custom/path/config.toml");
411
412        // Explicit local_config_path should take precedence
413        assert_eq!(
414            env.local_config_path(),
415            Some(PathBuf::from("/custom/path/config.toml"))
416        );
417    }
418}