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}