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