Skip to main content

ralph_workflow/config/path_resolver/
mod.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::io;
37use std::path::{Path, PathBuf};
38
39mod memory_env;
40pub use memory_env::MemoryConfigEnvironment;
41
42/// Trait for configuration environment access.
43///
44/// This trait abstracts all external side effects needed for configuration:
45/// - Path resolution (which may depend on environment variables)
46/// - File existence checks
47/// - File reading and writing
48/// - Directory creation
49///
50/// By injecting this trait, configuration code becomes pure and testable.
51pub trait ConfigEnvironment: Send + Sync {
52    /// Get the path to the unified config file.
53    ///
54    /// In production, this returns `~/.config/ralph-workflow.toml` or
55    /// `$XDG_CONFIG_HOME/ralph-workflow.toml` if the env var is set.
56    ///
57    /// Returns `None` if the path cannot be determined (e.g., no home directory).
58    fn unified_config_path(&self) -> Option<PathBuf>;
59
60    /// Get the path to the local config file.
61    ///
62    /// In production, this returns `.agent/ralph-workflow.toml` relative to CWD.
63    /// Tests may override this to use a different path.
64    ///
65    /// Returns `None` if local config is not supported or path cannot be determined.
66    fn local_config_path(&self) -> Option<PathBuf> {
67        Some(PathBuf::from(".agent/ralph-workflow.toml"))
68    }
69
70    /// Get the path to the PROMPT.md file.
71    ///
72    /// In production, this returns `./PROMPT.md` (relative to current directory).
73    /// Tests may override this to use a different path.
74    fn prompt_path(&self) -> PathBuf {
75        PathBuf::from("PROMPT.md")
76    }
77
78    /// Check if a file exists at the given path.
79    fn file_exists(&self, path: &Path) -> bool;
80
81    /// Read the contents of a file.
82    ///
83    /// # Errors
84    ///
85    /// Returns error if the operation fails.
86    fn read_file(&self, path: &Path) -> io::Result<String>;
87
88    /// Write content to a file, creating parent directories if needed.
89    ///
90    /// # Errors
91    ///
92    /// Returns error if the operation fails.
93    fn write_file(&self, path: &Path, content: &str) -> io::Result<()>;
94
95    /// Create directories recursively.
96    ///
97    /// # Errors
98    ///
99    /// Returns error if the operation fails.
100    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
101
102    /// Get the canonical root of the git repository, even from a worktree.
103    ///
104    /// When running inside a git worktree, this returns the **main repository root**
105    /// (not the ephemeral worktree working directory). This ensures local config
106    /// paths remain valid after worktree deletion.
107    ///
108    /// Returns `None` if not in a git repository or in a bare repository.
109    fn worktree_root(&self) -> Option<PathBuf> {
110        None // Default implementation for backwards compatibility
111    }
112
113    /// Get a single environment variable by name.
114    ///
115    /// In production (`RealConfigEnvironment`), reads from the real process environment.
116    /// In tests (`MemoryConfigEnvironment`), returns `None` by default (no env vars set),
117    /// providing complete isolation from the real process environment.
118    ///
119    /// Used to thread env-var access through config loading so production code is
120    /// fully testable without `#[serial]` or global env mutation.
121    fn get_env_var(&self, _key: &str) -> Option<String> {
122        None
123    }
124}
125
126/// Production implementation of [`ConfigEnvironment`].
127///
128/// Uses real environment variables and filesystem operations:
129/// - Reads `XDG_CONFIG_HOME` for config path resolution
130/// - Uses `std::fs` for all file operations
131#[derive(Debug, Default, Clone, Copy)]
132pub struct RealConfigEnvironment;
133
134impl ConfigEnvironment for RealConfigEnvironment {
135    fn unified_config_path(&self) -> Option<PathBuf> {
136        super::unified::unified_config_path()
137    }
138
139    fn get_env_var(&self, key: &str) -> Option<String> {
140        std::env::var(key).ok()
141    }
142
143    fn file_exists(&self, path: &Path) -> bool {
144        path.exists()
145    }
146
147    fn read_file(&self, path: &Path) -> io::Result<String> {
148        std::fs::read_to_string(path)
149    }
150
151    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
152        if let Some(parent) = path.parent() {
153            std::fs::create_dir_all(parent)?;
154        }
155        std::fs::write(path, content)
156    }
157
158    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
159        std::fs::create_dir_all(path)
160    }
161
162    fn worktree_root(&self) -> Option<PathBuf> {
163        let repo = git2::Repository::discover(".").ok()?;
164        let gitdir = repo.path();
165
166        // Detect worktrees: in a git worktree, repo.path() returns something like
167        // <main-repo>/.git/worktrees/<worktree-name>/
168        // We detect this by checking if the gitdir is inside a "worktrees" subdirectory
169        // of the main repo's .git directory.
170        if let Some(parent) = gitdir.parent() {
171            if parent.file_name().and_then(|n| n.to_str()) == Some("worktrees") {
172                // parent is <main-repo>/.git/worktrees
173                // parent.parent() is <main-repo>/.git
174                // parent.parent().parent() is <main-repo> (the canonical root)
175                return parent.parent().and_then(|p| p.parent()).map(PathBuf::from);
176            }
177        }
178
179        repo.workdir().map(PathBuf::from)
180    }
181
182    fn local_config_path(&self) -> Option<PathBuf> {
183        // Try worktree root first, fall back to default behavior
184        self.worktree_root()
185            .map(|root| root.join(".agent/ralph-workflow.toml"))
186            .or_else(|| Some(PathBuf::from(".agent/ralph-workflow.toml")))
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_real_environment_returns_path() {
196        let env = RealConfigEnvironment;
197        // Should return Some path (unless running in weird environment without home dir)
198        let path = env.unified_config_path();
199        if let Some(p) = path {
200            assert!(p.to_string_lossy().contains("ralph-workflow.toml"));
201        }
202    }
203
204    #[test]
205    fn test_memory_environment_with_custom_paths() {
206        let env = MemoryConfigEnvironment::new()
207            .with_unified_config_path("/custom/config.toml")
208            .with_prompt_path("/custom/PROMPT.md");
209
210        assert_eq!(
211            env.unified_config_path(),
212            Some(PathBuf::from("/custom/config.toml"))
213        );
214        assert_eq!(env.prompt_path(), PathBuf::from("/custom/PROMPT.md"));
215    }
216
217    #[test]
218    fn test_memory_environment_default_prompt_path() {
219        let env = MemoryConfigEnvironment::new();
220        assert_eq!(env.prompt_path(), PathBuf::from("PROMPT.md"));
221    }
222
223    #[test]
224    fn test_memory_environment_no_unified_config() {
225        let env = MemoryConfigEnvironment::new();
226        assert_eq!(env.unified_config_path(), None);
227    }
228
229    #[test]
230    fn test_memory_environment_file_operations() {
231        let env = MemoryConfigEnvironment::new();
232        let path = Path::new("/test/file.txt");
233
234        // File doesn't exist initially
235        assert!(!env.file_exists(path));
236
237        // Write file
238        env.write_file(path, "test content").unwrap();
239
240        // File now exists
241        assert!(env.file_exists(path));
242        assert_eq!(env.read_file(path).unwrap(), "test content");
243        assert!(env.was_written(path));
244    }
245
246    #[test]
247    fn test_memory_environment_with_prepopulated_file() {
248        let env =
249            MemoryConfigEnvironment::new().with_file("/test/existing.txt", "existing content");
250
251        assert!(env.file_exists(Path::new("/test/existing.txt")));
252        assert_eq!(
253            env.read_file(Path::new("/test/existing.txt")).unwrap(),
254            "existing content"
255        );
256    }
257
258    #[test]
259    fn test_memory_environment_read_nonexistent_file() {
260        let env = MemoryConfigEnvironment::new();
261        let result = env.read_file(Path::new("/nonexistent"));
262        assert!(result.is_err());
263        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
264    }
265
266    #[test]
267    fn test_memory_environment_with_worktree_root() {
268        let env = MemoryConfigEnvironment::new().with_worktree_root("/test/worktree");
269
270        assert_eq!(env.worktree_root(), Some(PathBuf::from("/test/worktree")));
271        assert_eq!(
272            env.local_config_path(),
273            Some(PathBuf::from("/test/worktree/.agent/ralph-workflow.toml"))
274        );
275    }
276
277    #[test]
278    fn test_memory_environment_without_worktree_root() {
279        let env = MemoryConfigEnvironment::new();
280
281        assert_eq!(env.worktree_root(), None);
282        assert_eq!(
283            env.local_config_path(),
284            Some(PathBuf::from(".agent/ralph-workflow.toml"))
285        );
286    }
287
288    #[test]
289    fn test_memory_environment_explicit_local_path_overrides_worktree() {
290        let env = MemoryConfigEnvironment::new()
291            .with_worktree_root("/test/worktree")
292            .with_local_config_path("/custom/path/config.toml");
293
294        // Explicit local_config_path should take precedence
295        assert_eq!(
296            env.local_config_path(),
297            Some(PathBuf::from("/custom/path/config.toml"))
298        );
299    }
300
301    #[test]
302    fn test_canonical_repo_root_used_for_local_config_path() {
303        // Simulate a worktree scenario where the canonical repo root
304        // differs from the worktree working directory. The worktree_root
305        // should point to the canonical repo root (not the ephemeral
306        // worktree path) so local config persists after worktree deletion.
307        let canonical_root = "/home/user/my-repo";
308        let env = MemoryConfigEnvironment::new().with_worktree_root(canonical_root);
309
310        assert_eq!(env.worktree_root(), Some(PathBuf::from(canonical_root)));
311        assert_eq!(
312            env.local_config_path(),
313            Some(PathBuf::from(
314                "/home/user/my-repo/.agent/ralph-workflow.toml"
315            ))
316        );
317    }
318
319    #[test]
320    fn test_worktree_root_and_local_config_path_consistency() {
321        // Both --init-local-config and runtime loading must resolve
322        // the same canonical path. Verify that local_config_path()
323        // is always derived from worktree_root() when set.
324        let env = MemoryConfigEnvironment::new().with_worktree_root("/repos/main-repo");
325
326        let root = env.worktree_root().unwrap();
327        let local_path = env.local_config_path().unwrap();
328
329        assert_eq!(local_path, root.join(".agent/ralph-workflow.toml"));
330    }
331}