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
78// ============================================================================
79// Well-known path constants
80// ============================================================================
81
82/// The `.agent` directory where Ralph stores all artifacts.
83pub const AGENT_DIR: &str = ".agent";
84
85/// The `.agent/tmp` directory for temporary files.
86pub const AGENT_TMP: &str = ".agent/tmp";
87
88/// The `.agent/logs` directory for agent logs.
89pub const AGENT_LOGS: &str = ".agent/logs";
90
91/// Path to the implementation plan file.
92pub const PLAN_MD: &str = ".agent/PLAN.md";
93
94/// Path to the issues file from code review.
95pub const ISSUES_MD: &str = ".agent/ISSUES.md";
96
97/// Path to the status file.
98pub const STATUS_MD: &str = ".agent/STATUS.md";
99
100/// Path to the notes file.
101pub const NOTES_MD: &str = ".agent/NOTES.md";
102
103/// Path to the commit message file.
104pub const COMMIT_MESSAGE_TXT: &str = ".agent/commit-message.txt";
105
106/// Path to the checkpoint file for resume support.
107pub const CHECKPOINT_JSON: &str = ".agent/checkpoint.json";
108
109/// Path to the start commit tracking file.
110pub const START_COMMIT: &str = ".agent/start_commit";
111
112/// Path to the review baseline tracking file.
113pub const REVIEW_BASELINE_TXT: &str = ".agent/review_baseline.txt";
114
115/// Path to the prompt file in repository root.
116pub const PROMPT_MD: &str = "PROMPT.md";
117
118/// Path to the prompt backup file.
119pub const PROMPT_BACKUP: &str = ".agent/PROMPT.md.backup";
120
121/// Path to the agent config file.
122pub const AGENT_CONFIG_TOML: &str = ".agent/config.toml";
123
124/// Path to the agents registry file.
125pub const AGENTS_TOML: &str = ".agent/agents.toml";
126
127/// Path to the pipeline log file.
128pub const PIPELINE_LOG: &str = ".agent/logs/pipeline.log";
129
130use std::fs;
131use std::io;
132use std::path::{Path, PathBuf};
133
134// ============================================================================
135// DirEntry - abstraction for directory entries
136// ============================================================================
137
138/// A directory entry returned by `Workspace::read_dir`.
139///
140/// This abstracts `std::fs::DirEntry` to allow in-memory implementations.
141#[derive(Debug, Clone)]
142pub struct DirEntry {
143 /// The path of this entry (relative to workspace root).
144 path: PathBuf,
145 /// Whether this entry is a file.
146 is_file: bool,
147 /// Whether this entry is a directory.
148 is_dir: bool,
149 /// Optional modification time (for sorting by recency).
150 modified: Option<std::time::SystemTime>,
151}
152
153impl DirEntry {
154 /// Create a new directory entry.
155 pub fn new(path: PathBuf, is_file: bool, is_dir: bool) -> Self {
156 Self {
157 path,
158 is_file,
159 is_dir,
160 modified: None,
161 }
162 }
163
164 /// Create a new directory entry with modification time.
165 pub fn with_modified(
166 path: PathBuf,
167 is_file: bool,
168 is_dir: bool,
169 modified: std::time::SystemTime,
170 ) -> Self {
171 Self {
172 path,
173 is_file,
174 is_dir,
175 modified: Some(modified),
176 }
177 }
178
179 /// Get the path of this entry.
180 pub fn path(&self) -> &Path {
181 &self.path
182 }
183
184 /// Check if this entry is a file.
185 pub fn is_file(&self) -> bool {
186 self.is_file
187 }
188
189 /// Check if this entry is a directory.
190 pub fn is_dir(&self) -> bool {
191 self.is_dir
192 }
193
194 /// Get the file name of this entry.
195 pub fn file_name(&self) -> Option<&std::ffi::OsStr> {
196 self.path.file_name()
197 }
198
199 /// Get the modification time of this entry, if available.
200 pub fn modified(&self) -> Option<std::time::SystemTime> {
201 self.modified
202 }
203}
204
205// ============================================================================
206// Workspace Trait
207// ============================================================================
208
209/// Trait defining the workspace filesystem interface.
210///
211/// This trait abstracts file operations relative to a repository root, allowing
212/// for both real filesystem access (production) and in-memory storage (testing).
213pub trait Workspace: Send + Sync {
214 /// Get the repository root path.
215 fn root(&self) -> &Path;
216
217 // =========================================================================
218 // File operations
219 // =========================================================================
220
221 /// Read a file relative to the repository root.
222 fn read(&self, relative: &Path) -> io::Result<String>;
223
224 /// Read a file as bytes relative to the repository root.
225 fn read_bytes(&self, relative: &Path) -> io::Result<Vec<u8>>;
226
227 /// Write content to a file relative to the repository root.
228 /// Creates parent directories if they don't exist.
229 fn write(&self, relative: &Path, content: &str) -> io::Result<()>;
230
231 /// Write bytes to a file relative to the repository root.
232 /// Creates parent directories if they don't exist.
233 fn write_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()>;
234
235 /// Append bytes to a file relative to the repository root.
236 /// Creates the file if it doesn't exist. Creates parent directories if needed.
237 fn append_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()>;
238
239 /// Check if a path exists relative to the repository root.
240 fn exists(&self, relative: &Path) -> bool;
241
242 /// Check if a path is a file relative to the repository root.
243 fn is_file(&self, relative: &Path) -> bool;
244
245 /// Check if a path is a directory relative to the repository root.
246 fn is_dir(&self, relative: &Path) -> bool;
247
248 /// Remove a file relative to the repository root.
249 fn remove(&self, relative: &Path) -> io::Result<()>;
250
251 /// Remove a file if it exists, silently succeeding if it doesn't.
252 fn remove_if_exists(&self, relative: &Path) -> io::Result<()>;
253
254 /// Remove a directory and all its contents relative to the repository root.
255 ///
256 /// Similar to `std::fs::remove_dir_all`, this removes a directory and everything inside it.
257 /// Returns an error if the directory doesn't exist.
258 fn remove_dir_all(&self, relative: &Path) -> io::Result<()>;
259
260 /// Remove a directory and all its contents if it exists, silently succeeding if it doesn't.
261 fn remove_dir_all_if_exists(&self, relative: &Path) -> io::Result<()>;
262
263 /// Create a directory and all parent directories relative to the repository root.
264 fn create_dir_all(&self, relative: &Path) -> io::Result<()>;
265
266 /// List entries in a directory relative to the repository root.
267 ///
268 /// Returns a vector of `DirEntry`-like information for each entry.
269 /// For production, this wraps `std::fs::read_dir`.
270 /// For testing, this returns entries from the in-memory filesystem.
271 fn read_dir(&self, relative: &Path) -> io::Result<Vec<DirEntry>>;
272
273 /// Rename/move a file from one path to another relative to the repository root.
274 ///
275 /// This is used for backup rotation where files are moved to new names.
276 /// Returns an error if the source file doesn't exist.
277 fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
278
279 /// Write content to a file atomically using temp file + rename pattern.
280 ///
281 /// This ensures the file is either fully written or not written at all,
282 /// preventing partial writes or corruption from crashes/interruptions.
283 ///
284 /// # Implementation details
285 ///
286 /// - `WorkspaceFs`: Uses `tempfile::NamedTempFile` in the same directory,
287 /// writes content, syncs to disk, then atomically renames to target.
288 /// On Unix, temp file has mode 0600 for security.
289 /// - `MemoryWorkspace`: Just calls `write()` since in-memory operations
290 /// are inherently atomic (no partial state possible).
291 ///
292 /// # When to use
293 ///
294 /// Use `write_atomic()` for critical files where corruption would be problematic:
295 /// - XML outputs (issues.xml, plan.xml, commit_message.xml)
296 /// - Agent artifacts (PLAN.md, commit-message.txt)
297 /// - Any file that must not have partial content
298 ///
299 /// Use regular `write()` for:
300 /// - Log files (append-only, partial is acceptable)
301 /// - Temporary/debug files
302 /// - Files where performance matters more than atomicity
303 fn write_atomic(&self, relative: &Path, content: &str) -> io::Result<()>;
304
305 /// Set a file to read-only permissions.
306 ///
307 /// This is a best-effort operation for protecting files like PROMPT.md backups.
308 /// On Unix, sets permissions to 0o444.
309 /// On Windows, sets the readonly flag.
310 /// In-memory implementations may no-op since permissions aren't relevant for testing.
311 ///
312 /// Returns Ok(()) on success or if the file doesn't exist (nothing to protect).
313 /// Returns Err only if the file exists but permissions cannot be changed.
314 fn set_readonly(&self, relative: &Path) -> io::Result<()>;
315
316 /// Set a file to writable permissions.
317 ///
318 /// Reverses the effect of `set_readonly`.
319 /// On Unix, sets permissions to 0o644.
320 /// On Windows, clears the readonly flag.
321 /// In-memory implementations may no-op since permissions aren't relevant for testing.
322 ///
323 /// Returns Ok(()) on success or if the file doesn't exist.
324 fn set_writable(&self, relative: &Path) -> io::Result<()>;
325
326 // =========================================================================
327 // Path resolution (default implementations)
328 // =========================================================================
329
330 /// Resolve a relative path to an absolute path.
331 fn absolute(&self, relative: &Path) -> PathBuf {
332 self.root().join(relative)
333 }
334
335 /// Resolve a relative path to an absolute path as a string.
336 fn absolute_str(&self, relative: &str) -> String {
337 self.root().join(relative).display().to_string()
338 }
339
340 // =========================================================================
341 // Well-known paths (default implementations)
342 // =========================================================================
343
344 /// Path to the `.agent` directory.
345 fn agent_dir(&self) -> PathBuf {
346 self.root().join(AGENT_DIR)
347 }
348
349 /// Path to the `.agent/logs` directory.
350 fn agent_logs(&self) -> PathBuf {
351 self.root().join(AGENT_LOGS)
352 }
353
354 /// Path to the `.agent/tmp` directory.
355 fn agent_tmp(&self) -> PathBuf {
356 self.root().join(AGENT_TMP)
357 }
358
359 /// Path to `.agent/PLAN.md`.
360 fn plan_md(&self) -> PathBuf {
361 self.root().join(PLAN_MD)
362 }
363
364 /// Path to `.agent/ISSUES.md`.
365 fn issues_md(&self) -> PathBuf {
366 self.root().join(ISSUES_MD)
367 }
368
369 /// Path to `.agent/STATUS.md`.
370 fn status_md(&self) -> PathBuf {
371 self.root().join(STATUS_MD)
372 }
373
374 /// Path to `.agent/NOTES.md`.
375 fn notes_md(&self) -> PathBuf {
376 self.root().join(NOTES_MD)
377 }
378
379 /// Path to `.agent/commit-message.txt`.
380 fn commit_message(&self) -> PathBuf {
381 self.root().join(COMMIT_MESSAGE_TXT)
382 }
383
384 /// Path to `.agent/checkpoint.json`.
385 fn checkpoint(&self) -> PathBuf {
386 self.root().join(CHECKPOINT_JSON)
387 }
388
389 /// Path to `.agent/start_commit`.
390 fn start_commit(&self) -> PathBuf {
391 self.root().join(START_COMMIT)
392 }
393
394 /// Path to `.agent/review_baseline.txt`.
395 fn review_baseline(&self) -> PathBuf {
396 self.root().join(REVIEW_BASELINE_TXT)
397 }
398
399 /// Path to `PROMPT.md` in the repository root.
400 fn prompt_md(&self) -> PathBuf {
401 self.root().join(PROMPT_MD)
402 }
403
404 /// Path to `.agent/PROMPT.md.backup`.
405 fn prompt_backup(&self) -> PathBuf {
406 self.root().join(PROMPT_BACKUP)
407 }
408
409 /// Path to `.agent/config.toml`.
410 fn agent_config(&self) -> PathBuf {
411 self.root().join(AGENT_CONFIG_TOML)
412 }
413
414 /// Path to `.agent/agents.toml`.
415 fn agents_toml(&self) -> PathBuf {
416 self.root().join(AGENTS_TOML)
417 }
418
419 /// Path to `.agent/logs/pipeline.log`.
420 fn pipeline_log(&self) -> PathBuf {
421 self.root().join(PIPELINE_LOG)
422 }
423
424 /// Path to an XSD schema file in `.agent/tmp/`.
425 fn xsd_path(&self, name: &str) -> PathBuf {
426 self.root().join(format!(".agent/tmp/{}.xsd", name))
427 }
428
429 /// Path to an XML file in `.agent/tmp/`.
430 fn xml_path(&self, name: &str) -> PathBuf {
431 self.root().join(format!(".agent/tmp/{}.xml", name))
432 }
433
434 /// Path to a log file in `.agent/logs/`.
435 fn log_path(&self, name: &str) -> PathBuf {
436 self.root().join(format!(".agent/logs/{}", name))
437 }
438}
439
440// ============================================================================
441// Production Implementation: WorkspaceFs
442// ============================================================================
443
444include!("workspace/workspace_fs.rs");
445
446// ============================================================================
447// Test Implementation: MemoryWorkspace
448// ============================================================================
449
450#[cfg(any(test, feature = "test-utils"))]
451include!("workspace/memory_workspace.rs");
452
453// ============================================================================
454// Tests
455// ============================================================================
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 include!("workspace/tests.rs");
462}