Skip to main content

ralph_workflow/config/path_resolver/
boundary.rs

1//! In-memory implementation of [`ConfigEnvironment`] for testing.
2//!
3//! This is a **boundary module** because it uses interior mutability (RwLock)
4//! to simulate file system operations for test isolation.
5//!
6//! Provides complete isolation from the real environment:
7//! - Injected paths instead of environment variables
8//! - In-memory file storage instead of real filesystem
9//! - Injectable environment variables via `with_env_var()` (default: no vars set)
10
11use std::collections::HashMap;
12use std::io;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, RwLock};
15
16use crate::config::ConfigEnvironment;
17
18/// In-memory implementation of [`ConfigEnvironment`] for testing.
19///
20/// # Example
21///
22/// ```ignore
23/// use crate::config::MemoryConfigEnvironment;
24///
25/// let env = MemoryConfigEnvironment::new()
26///     .with_unified_config_path("/test/config/ralph-workflow.toml")
27///     .with_prompt_path("/test/repo/PROMPT.md")
28///     .with_file("/test/repo/existing.txt", "content")
29///     .with_env_var("RALPH_DEVELOPER_ITERS", "10");
30///
31/// // Write a file
32/// env.write_file(Path::new("/test/new.txt"), "new content")?;
33///
34/// // Verify it was written
35/// assert!(env.was_written(Path::new("/test/new.txt")));
36/// assert_eq!(env.get_file(Path::new("/test/new.txt")), Some("new content".to_string()));
37/// ```
38#[derive(Debug, Clone, Default)]
39pub struct MemoryConfigEnvironment {
40    unified_config_path: Option<PathBuf>,
41    prompt_path: Option<PathBuf>,
42    local_config_path: Option<PathBuf>,
43    worktree_root: Option<PathBuf>,
44    files: Arc<RwLock<HashMap<PathBuf, String>>>,
45    dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
46    env_vars: HashMap<String, String>,
47}
48
49impl MemoryConfigEnvironment {
50    #[must_use]
51    pub fn new() -> Self {
52        Self::default()
53    }
54
55    #[must_use]
56    pub fn with_unified_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
57        self.unified_config_path = Some(path.into());
58        self
59    }
60
61    #[must_use]
62    pub fn with_local_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
63        self.local_config_path = Some(path.into());
64        self
65    }
66
67    #[must_use]
68    pub fn with_prompt_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
69        self.prompt_path = Some(path.into());
70        self
71    }
72
73    #[must_use]
74    pub fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
75        let path = path.into();
76        self.files.write()
77            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
78            .insert(path, content.into());
79        self
80    }
81
82    #[must_use]
83    pub fn with_worktree_root<P: Into<PathBuf>>(mut self, path: P) -> Self {
84        self.worktree_root = Some(path.into());
85        self
86    }
87
88    #[must_use]
89    pub fn with_env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
90        self.env_vars.insert(key.into(), value.into());
91        self
92    }
93
94    #[must_use]
95    pub fn get_file(&self, path: &Path) -> Option<String> {
96        self.files.read()
97            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
98            .get(path).cloned()
99    }
100
101    #[must_use]
102    pub fn was_written(&self, path: &Path) -> bool {
103        self.files.read()
104            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
105            .contains_key(path)
106    }
107}
108
109impl ConfigEnvironment for MemoryConfigEnvironment {
110    fn unified_config_path(&self) -> Option<PathBuf> {
111        self.unified_config_path.clone()
112    }
113
114    fn get_env_var(&self, key: &str) -> Option<String> {
115        self.env_vars.get(key).cloned()
116    }
117
118    fn local_config_path(&self) -> Option<PathBuf> {
119        if let Some(ref path) = self.local_config_path {
120            return Some(path.clone());
121        }
122
123        self.worktree_root()
124            .map(|root| root.join(".agent/ralph-workflow.toml"))
125            .or_else(|| Some(PathBuf::from(".agent/ralph-workflow.toml")))
126    }
127
128    fn prompt_path(&self) -> PathBuf {
129        self.prompt_path
130            .clone()
131            .unwrap_or_else(|| PathBuf::from("PROMPT.md"))
132    }
133
134    fn file_exists(&self, path: &Path) -> bool {
135        self.files.read()
136            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
137            .contains_key(path)
138    }
139
140    fn read_file(&self, path: &Path) -> io::Result<String> {
141        self.files
142            .read()
143            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
144            .get(path)
145            .cloned()
146            .ok_or_else(|| {
147                io::Error::new(
148                    io::ErrorKind::NotFound,
149                    format!("File not found: {}", path.display()),
150                )
151            })
152    }
153
154    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
155        if let Some(parent) = path.parent() {
156            self.dirs.write()
157                .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment dirs lock")
158                .insert(parent.to_path_buf());
159        }
160        self.files
161            .write()
162            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
163            .insert(path.to_path_buf(), content.to_string());
164        Ok(())
165    }
166
167    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
168        self.dirs.write()
169            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment dirs lock")
170            .insert(path.to_path_buf());
171        Ok(())
172    }
173
174    fn worktree_root(&self) -> Option<PathBuf> {
175        self.worktree_root.clone()
176    }
177}
178
179///
180/// Uses real environment variables and filesystem operations:
181/// - Reads `XDG_CONFIG_HOME` for config path resolution
182/// - Uses `std::fs` for all file operations
183#[derive(Debug, Default, Clone, Copy)]
184pub struct RealConfigEnvironment;
185
186fn compute_canonical_repo_root(gitdir: &Path) -> Option<PathBuf> {
187    let parent = gitdir.parent()?;
188    if parent.file_name().and_then(|n| n.to_str()) == Some("worktrees") {
189        parent.parent().and_then(|p| p.parent()).map(PathBuf::from)
190    } else {
191        None
192    }
193}
194
195impl ConfigEnvironment for RealConfigEnvironment {
196    fn unified_config_path(&self) -> Option<PathBuf> {
197        crate::config::unified::unified_config_path()
198    }
199
200    fn get_env_var(&self, key: &str) -> Option<String> {
201        std::env::var(key).ok()
202    }
203
204    fn file_exists(&self, path: &Path) -> bool {
205        path.exists()
206    }
207
208    fn read_file(&self, path: &Path) -> io::Result<String> {
209        std::fs::read_to_string(path)
210    }
211
212    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
213        if let Some(parent) = path.parent() {
214            std::fs::create_dir_all(parent)?;
215        }
216        std::fs::write(path, content)
217    }
218
219    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
220        std::fs::create_dir_all(path)
221    }
222
223    fn worktree_root(&self) -> Option<PathBuf> {
224        let repo = git2::Repository::discover(".").ok()?;
225        let gitdir = repo.path();
226
227        compute_canonical_repo_root(gitdir).or_else(|| repo.workdir().map(PathBuf::from))
228    }
229
230    fn local_config_path(&self) -> Option<PathBuf> {
231        self.worktree_root()
232            .map(|root| root.join(".agent/ralph-workflow.toml"))
233            .or_else(|| Some(PathBuf::from(".agent/ralph-workflow.toml")))
234    }
235}