Skip to main content

ralph_workflow/config/path_resolver/
memory_env.rs

1use std::collections::HashMap;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, RwLock};
5
6use super::ConfigEnvironment;
7
8/// In-memory implementation of [`ConfigEnvironment`] for testing.
9///
10/// Provides complete isolation from the real environment:
11/// - Injected paths instead of environment variables
12/// - In-memory file storage instead of real filesystem
13/// - Injectable environment variables via `with_env_var()` (default: no vars set)
14///
15/// # Example
16///
17/// ```ignore
18/// use crate::config::MemoryConfigEnvironment;
19///
20/// let env = MemoryConfigEnvironment::new()
21///     .with_unified_config_path("/test/config/ralph-workflow.toml")
22///     .with_prompt_path("/test/repo/PROMPT.md")
23///     .with_file("/test/repo/existing.txt", "content")
24///     .with_env_var("RALPH_DEVELOPER_ITERS", "10");
25///
26/// // Write a file
27/// env.write_file(Path::new("/test/new.txt"), "new content")?;
28///
29/// // Verify it was written
30/// assert!(env.was_written(Path::new("/test/new.txt")));
31/// assert_eq!(env.get_file(Path::new("/test/new.txt")), Some("new content".to_string()));
32/// ```
33#[derive(Debug, Clone, Default)]
34pub struct MemoryConfigEnvironment {
35    unified_config_path: Option<PathBuf>,
36    prompt_path: Option<PathBuf>,
37    local_config_path: Option<PathBuf>,
38    worktree_root: Option<PathBuf>,
39    /// In-memory file storage.
40    files: Arc<RwLock<HashMap<PathBuf, String>>>,
41    /// Directories that have been created.
42    dirs: Arc<RwLock<std::collections::HashSet<PathBuf>>>,
43    /// Injectable environment variables for testing.
44    ///
45    /// By default (empty map), `get_env_var()` returns `None` for all keys,
46    /// providing complete isolation from the real process environment.
47    env_vars: HashMap<String, String>,
48}
49
50impl MemoryConfigEnvironment {
51    /// Create a new memory environment with no paths configured.
52    #[must_use]
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Set the unified config path.
58    #[must_use]
59    pub fn with_unified_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
60        self.unified_config_path = Some(path.into());
61        self
62    }
63
64    /// Set the local config path.
65    #[must_use]
66    pub fn with_local_config_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
67        self.local_config_path = Some(path.into());
68        self
69    }
70
71    /// Set the PROMPT.md path.
72    #[must_use]
73    pub fn with_prompt_path<P: Into<PathBuf>>(mut self, path: P) -> Self {
74        self.prompt_path = Some(path.into());
75        self
76    }
77
78    /// Pre-populate a file in memory.
79    ///
80    /// # Panics
81    ///
82    /// Panics if the `RwLock` is poisoned.
83    #[must_use]
84    pub fn with_file<P: Into<PathBuf>, S: Into<String>>(self, path: P, content: S) -> Self {
85        let path = path.into();
86        self.files.write()
87            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
88            .insert(path, content.into());
89        self
90    }
91
92    /// Set the worktree root path for testing git worktree scenarios.
93    #[must_use]
94    pub fn with_worktree_root<P: Into<PathBuf>>(mut self, path: P) -> Self {
95        self.worktree_root = Some(path.into());
96        self
97    }
98
99    /// Inject an environment variable for testing.
100    ///
101    /// Provides per-test env isolation without mutating the real process environment.
102    /// Use this instead of `std::env::set_var` to avoid `#[serial]` requirements.
103    ///
104    /// # Example
105    ///
106    /// ```ignore
107    /// let env = MemoryConfigEnvironment::new()
108    ///     .with_env_var("RALPH_DEVELOPER_ITERS", "42")
109    ///     .with_env_var("RALPH_ISOLATION_MODE", "false");
110    /// ```
111    #[must_use]
112    pub fn with_env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
113        self.env_vars.insert(key.into(), value.into());
114        self
115    }
116
117    /// Get the contents of a file (for test assertions).
118    ///
119    /// # Panics
120    ///
121    /// Panics if the `RwLock` is poisoned.
122    #[must_use]
123    pub fn get_file(&self, path: &Path) -> Option<String> {
124        self.files.read()
125            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
126            .get(path).cloned()
127    }
128
129    /// Check if a file was written (for test assertions).
130    ///
131    /// # Panics
132    ///
133    /// Panics if the `RwLock` is poisoned.
134    #[must_use]
135    pub fn was_written(&self, path: &Path) -> bool {
136        self.files.read()
137            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
138            .contains_key(path)
139    }
140}
141
142impl ConfigEnvironment for MemoryConfigEnvironment {
143    fn unified_config_path(&self) -> Option<PathBuf> {
144        self.unified_config_path.clone()
145    }
146
147    /// Returns an injected env var from the in-memory map.
148    ///
149    /// Returns `None` for any key not explicitly set via `with_env_var()`,
150    /// providing complete isolation from the real process environment.
151    fn get_env_var(&self, key: &str) -> Option<String> {
152        self.env_vars.get(key).cloned()
153    }
154
155    fn local_config_path(&self) -> Option<PathBuf> {
156        // If explicit local_config_path was set, use it (for legacy tests)
157        if let Some(ref path) = self.local_config_path {
158            return Some(path.clone());
159        }
160
161        // Otherwise, use worktree root if available
162        self.worktree_root()
163            .map(|root| root.join(".agent/ralph-workflow.toml"))
164            .or_else(|| Some(PathBuf::from(".agent/ralph-workflow.toml")))
165    }
166
167    fn prompt_path(&self) -> PathBuf {
168        self.prompt_path
169            .clone()
170            .unwrap_or_else(|| PathBuf::from("PROMPT.md"))
171    }
172
173    fn file_exists(&self, path: &Path) -> bool {
174        self.files.read()
175            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
176            .contains_key(path)
177    }
178
179    fn read_file(&self, path: &Path) -> io::Result<String> {
180        self.files
181            .read()
182            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
183            .get(path)
184            .cloned()
185            .ok_or_else(|| {
186                io::Error::new(
187                    io::ErrorKind::NotFound,
188                    format!("File not found: {}", path.display()),
189                )
190            })
191    }
192
193    fn write_file(&self, path: &Path, content: &str) -> io::Result<()> {
194        // Simulate creating parent directories
195        if let Some(parent) = path.parent() {
196            self.dirs.write()
197                .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment dirs lock")
198                .insert(parent.to_path_buf());
199        }
200        self.files
201            .write()
202            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment files lock")
203            .insert(path.to_path_buf(), content.to_string());
204        Ok(())
205    }
206
207    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
208        self.dirs.write()
209            .expect("RwLock poisoned - indicates panic in another thread holding MemoryConfigEnvironment dirs lock")
210            .insert(path.to_path_buf());
211        Ok(())
212    }
213
214    fn worktree_root(&self) -> Option<PathBuf> {
215        self.worktree_root.clone()
216    }
217}