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}