ralph_workflow/workspace.rs
1//! Workspace filesystem abstraction for explicit path resolution.
2//!
3//! This module provides the [`Workspace`] trait and implementations that eliminate
4//! CWD dependencies by making all path operations explicit relative to the repository root.
5//!
6//! # Problem
7//!
8//! The codebase previously relied on `std::env::set_current_dir()` to set the
9//! process CWD to the repository root, then used relative paths (`.agent/`,
10//! `PROMPT.md`, etc.) throughout. This caused:
11//!
12//! - Test flakiness when tests ran in parallel (CWD is process-global)
13//! - Background thread bugs when CWD changed after thread started
14//! - Poor testability without complex CWD manipulation
15//!
16//! # Solution
17//!
18//! The [`Workspace`] trait defines the interface for file operations, with two implementations:
19//!
20//! - [`WorkspaceFs`] - Production implementation using the real filesystem
21//! - `MemoryWorkspace` - Test implementation with in-memory storage (available with `test-utils` feature)
22//!
23//! # Well-Known Paths
24//!
25//! This module defines constants for all Ralph artifact paths:
26//!
27//! - [`AGENT_DIR`] - `.agent/` directory
28//! - [`PLAN_MD`] - `.agent/PLAN.md`
29//! - [`ISSUES_MD`] - `.agent/ISSUES.md`
30//! - [`PROMPT_MD`] - `PROMPT.md` (repository root)
31//! - [`CHECKPOINT_JSON`] - `.agent/checkpoint.json`
32//!
33//! The [`Workspace`] trait provides convenience methods for these paths (e.g., [`Workspace::plan_md`]).
34//!
35//! # Production Example
36//!
37//! ```ignore
38//! use ralph_workflow::workspace::WorkspaceFs;
39//! use std::path::PathBuf;
40//!
41//! let ws = WorkspaceFs::new(PathBuf::from("/path/to/repo"));
42//!
43//! // Get paths to well-known files
44//! let plan = ws.plan_md(); // /path/to/repo/.agent/PLAN.md
45//! let prompt = ws.prompt_md(); // /path/to/repo/PROMPT.md
46//!
47//! // Perform file operations
48//! ws.write(Path::new(".agent/test.txt"), "content")?;
49//! let content = ws.read(Path::new(".agent/test.txt"))?;
50//! ```
51//!
52//! # Testing with `MemoryWorkspace`
53//!
54//! The `test-utils` feature enables `MemoryWorkspace` for integration tests:
55//!
56//! ```ignore
57//! use ralph_workflow::workspace::{MemoryWorkspace, Workspace};
58//! use std::path::Path;
59//!
60//! // Create a test workspace with pre-populated files
61//! let ws = MemoryWorkspace::new_test()
62//! .with_file("PROMPT.md", "# Task: Add logging")
63//! .with_file(".agent/PLAN.md", "1. Add log statements");
64//!
65//! // Verify file operations
66//! assert!(ws.exists(Path::new("PROMPT.md")));
67//! assert_eq!(ws.read(Path::new("PROMPT.md"))?, "# Task: Add logging");
68//!
69//! // Write and verify
70//! ws.write(Path::new(".agent/output.txt"), "result")?;
71//! assert!(ws.was_written(".agent/output.txt"));
72//! ```
73//!
74//! # See Also
75//!
76//! - [`crate::executor::ProcessExecutor`] - Similar abstraction for process execution
77
78use std::fs;
79use std::io;
80use std::path::{Path, PathBuf};
81
82// ============================================================================
83// Well-known path constants
84// ============================================================================
85
86include!("workspace/paths.rs");
87
88// ============================================================================
89// DirEntry - abstraction for directory entries
90// ============================================================================
91
92include!("workspace/dir_entry.rs");
93
94// ============================================================================
95// Workspace Trait
96// ============================================================================
97
98/// Trait defining the workspace filesystem interface.
99///
100/// This trait abstracts file operations relative to a repository root, allowing
101/// for both real filesystem access (production) and in-memory storage (testing).
102pub trait Workspace: Send + Sync {
103 /// Get the repository root path.
104 fn root(&self) -> &Path;
105
106 // =========================================================================
107 // File operations
108 // =========================================================================
109
110 /// Read a file relative to the repository root.
111 ///
112 /// # Errors
113 ///
114 /// Returns error if the operation fails.
115 fn read(&self, relative: &Path) -> io::Result<String>;
116
117 /// Read a file as bytes relative to the repository root.
118 ///
119 /// # Errors
120 ///
121 /// Returns error if the operation fails.
122 fn read_bytes(&self, relative: &Path) -> io::Result<Vec<u8>>;
123
124 /// Write content to a file relative to the repository root.
125 /// Creates parent directories if they don't exist.
126 ///
127 /// # Errors
128 ///
129 /// Returns error if the operation fails.
130 fn write(&self, relative: &Path, content: &str) -> io::Result<()>;
131
132 /// Write bytes to a file relative to the repository root.
133 /// Creates parent directories if they don't exist.
134 ///
135 /// # Errors
136 ///
137 /// Returns error if the operation fails.
138 fn write_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()>;
139
140 /// Append bytes to a file relative to the repository root.
141 /// Creates the file if it doesn't exist. Creates parent directories if needed.
142 ///
143 /// # Errors
144 ///
145 /// Returns error if the operation fails.
146 fn append_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()>;
147
148 /// Check if a path exists relative to the repository root.
149 fn exists(&self, relative: &Path) -> bool;
150
151 /// Check if a path is a file relative to the repository root.
152 fn is_file(&self, relative: &Path) -> bool;
153
154 /// Check if a path is a directory relative to the repository root.
155 fn is_dir(&self, relative: &Path) -> bool;
156
157 /// Remove a file relative to the repository root.
158 ///
159 /// # Errors
160 ///
161 /// Returns error if the operation fails.
162 fn remove(&self, relative: &Path) -> io::Result<()>;
163
164 /// Remove a file if it exists, silently succeeding if it doesn't.
165 ///
166 /// # Errors
167 ///
168 /// Returns error if the operation fails.
169 fn remove_if_exists(&self, relative: &Path) -> io::Result<()>;
170
171 /// Remove a directory and all its contents relative to the repository root.
172 ///
173 /// Similar to `std::fs::remove_dir_all`, this removes a directory and everything inside it.
174 /// Returns an error if the directory doesn't exist.
175 ///
176 /// # Errors
177 ///
178 /// Returns error if the operation fails.
179 fn remove_dir_all(&self, relative: &Path) -> io::Result<()>;
180
181 /// Remove a directory and all its contents if it exists, silently succeeding if it doesn't.
182 ///
183 /// # Errors
184 ///
185 /// Returns error if the operation fails.
186 fn remove_dir_all_if_exists(&self, relative: &Path) -> io::Result<()>;
187
188 /// Create a directory and all parent directories relative to the repository root.
189 ///
190 /// # Errors
191 ///
192 /// Returns error if the operation fails.
193 fn create_dir_all(&self, relative: &Path) -> io::Result<()>;
194
195 /// List entries in a directory relative to the repository root.
196 ///
197 /// Returns a vector of `DirEntry`-like information for each entry.
198 /// For production, this wraps `std::fs::read_dir`.
199 /// For testing, this returns entries from the in-memory filesystem.
200 ///
201 /// # Errors
202 ///
203 /// Returns error if the operation fails.
204 fn read_dir(&self, relative: &Path) -> io::Result<Vec<DirEntry>>;
205
206 /// Rename/move a file from one path to another relative to the repository root.
207 ///
208 /// This is used for backup rotation where files are moved to new names.
209 /// Returns an error if the source file doesn't exist.
210 ///
211 /// # Errors
212 ///
213 /// Returns error if the operation fails.
214 fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
215
216 /// Write content to a file atomically using temp file + rename pattern.
217 ///
218 /// This ensures the file is either fully written or not written at all,
219 /// preventing partial writes or corruption from crashes/interruptions.
220 ///
221 /// # Implementation details
222 ///
223 /// - `WorkspaceFs`: Uses `tempfile::NamedTempFile` in the same directory,
224 /// writes content, syncs to disk, then atomically renames to target.
225 /// On Unix, temp file has mode 0600 for security.
226 /// - `MemoryWorkspace`: Just calls `write()` since in-memory operations
227 /// are inherently atomic (no partial state possible).
228 ///
229 /// # When to use
230 ///
231 /// Use `write_atomic()` for critical files where corruption would be problematic:
232 /// - XML outputs (issues.xml, plan.xml, `commit_message.xml`)
233 /// - Agent artifacts (PLAN.md, commit-message.txt)
234 /// - Any file that must not have partial content
235 ///
236 /// Use regular `write()` for:
237 /// - Log files (append-only, partial is acceptable)
238 /// - Temporary/debug files
239 /// - Files where performance matters more than atomicity
240 ///
241 /// # Errors
242 ///
243 /// Returns error if the operation fails.
244 fn write_atomic(&self, relative: &Path, content: &str) -> io::Result<()>;
245
246 /// Set a file to read-only permissions.
247 ///
248 /// This is a best-effort operation for protecting files like PROMPT.md backups.
249 /// On Unix, sets permissions to 0o444.
250 /// On Windows, sets the readonly flag.
251 /// In-memory implementations may no-op since permissions aren't relevant for testing.
252 ///
253 /// Returns Ok(()) on success or if the file doesn't exist (nothing to protect).
254 /// Returns Err only if the file exists but permissions cannot be changed.
255 ///
256 /// # Errors
257 ///
258 /// Returns error if the operation fails.
259 fn set_readonly(&self, relative: &Path) -> io::Result<()>;
260
261 /// Set a file to writable permissions.
262 ///
263 /// Reverses the effect of `set_readonly`.
264 /// On Unix, sets permissions to 0o644.
265 /// On Windows, clears the readonly flag.
266 /// In-memory implementations may no-op since permissions aren't relevant for testing.
267 ///
268 /// Returns Ok(()) on success or if the file doesn't exist.
269 ///
270 /// # Errors
271 ///
272 /// Returns error if the operation fails.
273 fn set_writable(&self, relative: &Path) -> io::Result<()>;
274
275 // =========================================================================
276 // Path resolution (default implementations)
277 // =========================================================================
278
279 /// Resolve a relative path to an absolute path.
280 fn absolute(&self, relative: &Path) -> PathBuf {
281 self.root().join(relative)
282 }
283
284 /// Resolve a relative path to an absolute path as a string.
285 fn absolute_str(&self, relative: &str) -> String {
286 self.root().join(relative).display().to_string()
287 }
288
289 // =========================================================================
290 // Well-known paths (default implementations)
291 // =========================================================================
292
293 /// Path to the `.agent` directory.
294 fn agent_dir(&self) -> PathBuf {
295 self.root().join(AGENT_DIR)
296 }
297
298 /// Path to the `.agent/tmp` directory.
299 fn agent_tmp(&self) -> PathBuf {
300 self.root().join(AGENT_TMP)
301 }
302
303 /// Path to `.agent/PLAN.md`.
304 fn plan_md(&self) -> PathBuf {
305 self.root().join(PLAN_MD)
306 }
307
308 /// Path to `.agent/ISSUES.md`.
309 fn issues_md(&self) -> PathBuf {
310 self.root().join(ISSUES_MD)
311 }
312
313 /// Path to `.agent/STATUS.md`.
314 fn status_md(&self) -> PathBuf {
315 self.root().join(STATUS_MD)
316 }
317
318 /// Path to `.agent/NOTES.md`.
319 fn notes_md(&self) -> PathBuf {
320 self.root().join(NOTES_MD)
321 }
322
323 /// Path to `.agent/commit-message.txt`.
324 fn commit_message(&self) -> PathBuf {
325 self.root().join(COMMIT_MESSAGE_TXT)
326 }
327
328 /// Path to `.agent/checkpoint.json`.
329 fn checkpoint(&self) -> PathBuf {
330 self.root().join(CHECKPOINT_JSON)
331 }
332
333 /// Path to `.agent/start_commit`.
334 fn start_commit(&self) -> PathBuf {
335 self.root().join(START_COMMIT)
336 }
337
338 /// Path to `.agent/review_baseline.txt`.
339 fn review_baseline(&self) -> PathBuf {
340 self.root().join(REVIEW_BASELINE_TXT)
341 }
342
343 /// Path to `PROMPT.md` in the repository root.
344 fn prompt_md(&self) -> PathBuf {
345 self.root().join(PROMPT_MD)
346 }
347
348 /// Path to `.agent/PROMPT.md.backup`.
349 fn prompt_backup(&self) -> PathBuf {
350 self.root().join(PROMPT_BACKUP)
351 }
352
353 /// Path to `.agent/config.toml`.
354 fn agent_config(&self) -> PathBuf {
355 self.root().join(AGENT_CONFIG_TOML)
356 }
357
358 /// Path to `.agent/agents.toml`.
359 fn agents_toml(&self) -> PathBuf {
360 self.root().join(AGENTS_TOML)
361 }
362
363 /// Path to an XSD schema file in `.agent/tmp/`.
364 fn xsd_path(&self, name: &str) -> PathBuf {
365 self.root().join(format!(".agent/tmp/{name}.xsd"))
366 }
367
368 /// Path to an XML file in `.agent/tmp/`.
369 fn xml_path(&self, name: &str) -> PathBuf {
370 self.root().join(format!(".agent/tmp/{name}.xml"))
371 }
372
373 /// Path to a log file in `.agent/logs/`.
374 fn log_path(&self, name: &str) -> PathBuf {
375 self.root().join(format!(".agent/logs/{name}"))
376 }
377}
378
379// ============================================================================
380// Production Implementation: WorkspaceFs
381// ============================================================================
382
383include!("workspace/workspace_fs.rs");
384
385// ============================================================================
386// Test Implementation: MemoryWorkspace
387// ============================================================================
388
389#[cfg(any(test, feature = "test-utils"))]
390pub mod memory_workspace;
391
392#[cfg(any(test, feature = "test-utils"))]
393pub use memory_workspace::MemoryWorkspace;
394
395// ============================================================================
396// Tests
397// ============================================================================
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 include!("workspace/tests.rs");
404}