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