Skip to main content

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