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//! # Example
24//!
25//! ```ignore
26//! use ralph_workflow::workspace::WorkspaceFs;
27//! use std::path::PathBuf;
28//!
29//! let ws = WorkspaceFs::new(PathBuf::from("/path/to/repo"));
30//!
31//! // Get paths to well-known files
32//! let plan = ws.plan_md();  // /path/to/repo/.agent/PLAN.md
33//! let prompt = ws.prompt_md();  // /path/to/repo/PROMPT.md
34//!
35//! // Perform file operations
36//! ws.write(".agent/test.txt", "content")?;
37//! let content = ws.read(".agent/test.txt")?;
38//! ```
39
40// ============================================================================
41// Well-known path constants
42// ============================================================================
43
44/// The `.agent` directory where Ralph stores all artifacts.
45pub const AGENT_DIR: &str = ".agent";
46
47/// The `.agent/tmp` directory for temporary files.
48pub const AGENT_TMP: &str = ".agent/tmp";
49
50/// The `.agent/logs` directory for agent logs.
51pub const AGENT_LOGS: &str = ".agent/logs";
52
53/// Path to the implementation plan file.
54pub const PLAN_MD: &str = ".agent/PLAN.md";
55
56/// Path to the issues file from code review.
57pub const ISSUES_MD: &str = ".agent/ISSUES.md";
58
59/// Path to the status file.
60pub const STATUS_MD: &str = ".agent/STATUS.md";
61
62/// Path to the notes file.
63pub const NOTES_MD: &str = ".agent/NOTES.md";
64
65/// Path to the commit message file.
66pub const COMMIT_MESSAGE_TXT: &str = ".agent/commit-message.txt";
67
68/// Path to the checkpoint file for resume support.
69pub const CHECKPOINT_JSON: &str = ".agent/checkpoint.json";
70
71/// Path to the start commit tracking file.
72pub const START_COMMIT: &str = ".agent/start_commit";
73
74/// Path to the review baseline tracking file.
75pub const REVIEW_BASELINE_TXT: &str = ".agent/review_baseline.txt";
76
77/// Path to the prompt file in repository root.
78pub const PROMPT_MD: &str = "PROMPT.md";
79
80/// Path to the prompt backup file.
81pub const PROMPT_BACKUP: &str = ".agent/PROMPT.md.backup";
82
83/// Path to the agent config file.
84pub const AGENT_CONFIG_TOML: &str = ".agent/config.toml";
85
86/// Path to the agents registry file.
87pub const AGENTS_TOML: &str = ".agent/agents.toml";
88
89/// Path to the pipeline log file.
90pub const PIPELINE_LOG: &str = ".agent/logs/pipeline.log";
91
92use std::fs;
93use std::io;
94use std::path::{Path, PathBuf};
95
96// ============================================================================
97// DirEntry - abstraction for directory entries
98// ============================================================================
99
100/// A directory entry returned by `Workspace::read_dir`.
101///
102/// This abstracts `std::fs::DirEntry` to allow in-memory implementations.
103#[derive(Debug, Clone)]
104pub struct DirEntry {
105    /// The path of this entry (relative to workspace root).
106    path: PathBuf,
107    /// Whether this entry is a file.
108    is_file: bool,
109    /// Whether this entry is a directory.
110    is_dir: bool,
111    /// Optional modification time (for sorting by recency).
112    modified: Option<std::time::SystemTime>,
113}
114
115impl DirEntry {
116    /// Create a new directory entry.
117    pub fn new(path: PathBuf, is_file: bool, is_dir: bool) -> Self {
118        Self {
119            path,
120            is_file,
121            is_dir,
122            modified: None,
123        }
124    }
125
126    /// Create a new directory entry with modification time.
127    pub fn with_modified(
128        path: PathBuf,
129        is_file: bool,
130        is_dir: bool,
131        modified: std::time::SystemTime,
132    ) -> Self {
133        Self {
134            path,
135            is_file,
136            is_dir,
137            modified: Some(modified),
138        }
139    }
140
141    /// Get the path of this entry.
142    pub fn path(&self) -> &Path {
143        &self.path
144    }
145
146    /// Check if this entry is a file.
147    pub fn is_file(&self) -> bool {
148        self.is_file
149    }
150
151    /// Check if this entry is a directory.
152    pub fn is_dir(&self) -> bool {
153        self.is_dir
154    }
155
156    /// Get the file name of this entry.
157    pub fn file_name(&self) -> Option<&std::ffi::OsStr> {
158        self.path.file_name()
159    }
160
161    /// Get the modification time of this entry, if available.
162    pub fn modified(&self) -> Option<std::time::SystemTime> {
163        self.modified
164    }
165}
166
167// ============================================================================
168// Workspace Trait
169// ============================================================================
170
171/// Trait defining the workspace filesystem interface.
172///
173/// This trait abstracts file operations relative to a repository root, allowing
174/// for both real filesystem access (production) and in-memory storage (testing).
175pub trait Workspace: Send + Sync {
176    /// Get the repository root path.
177    fn root(&self) -> &Path;
178
179    // =========================================================================
180    // File operations
181    // =========================================================================
182
183    /// Read a file relative to the repository root.
184    fn read(&self, relative: &Path) -> io::Result<String>;
185
186    /// Read a file as bytes relative to the repository root.
187    fn read_bytes(&self, relative: &Path) -> io::Result<Vec<u8>>;
188
189    /// Write content to a file relative to the repository root.
190    /// Creates parent directories if they don't exist.
191    fn write(&self, relative: &Path, content: &str) -> io::Result<()>;
192
193    /// Write bytes to a file relative to the repository root.
194    /// Creates parent directories if they don't exist.
195    fn write_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()>;
196
197    /// Append bytes to a file relative to the repository root.
198    /// Creates the file if it doesn't exist. Creates parent directories if needed.
199    fn append_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()>;
200
201    /// Check if a path exists relative to the repository root.
202    fn exists(&self, relative: &Path) -> bool;
203
204    /// Check if a path is a file relative to the repository root.
205    fn is_file(&self, relative: &Path) -> bool;
206
207    /// Check if a path is a directory relative to the repository root.
208    fn is_dir(&self, relative: &Path) -> bool;
209
210    /// Remove a file relative to the repository root.
211    fn remove(&self, relative: &Path) -> io::Result<()>;
212
213    /// Remove a file if it exists, silently succeeding if it doesn't.
214    fn remove_if_exists(&self, relative: &Path) -> io::Result<()>;
215
216    /// Remove a directory and all its contents relative to the repository root.
217    ///
218    /// Similar to `std::fs::remove_dir_all`, this removes a directory and everything inside it.
219    /// Returns an error if the directory doesn't exist.
220    fn remove_dir_all(&self, relative: &Path) -> io::Result<()>;
221
222    /// Remove a directory and all its contents if it exists, silently succeeding if it doesn't.
223    fn remove_dir_all_if_exists(&self, relative: &Path) -> io::Result<()>;
224
225    /// Create a directory and all parent directories relative to the repository root.
226    fn create_dir_all(&self, relative: &Path) -> io::Result<()>;
227
228    /// List entries in a directory relative to the repository root.
229    ///
230    /// Returns a vector of `DirEntry`-like information for each entry.
231    /// For production, this wraps `std::fs::read_dir`.
232    /// For testing, this returns entries from the in-memory filesystem.
233    fn read_dir(&self, relative: &Path) -> io::Result<Vec<DirEntry>>;
234
235    /// Rename/move a file from one path to another relative to the repository root.
236    ///
237    /// This is used for backup rotation where files are moved to new names.
238    /// Returns an error if the source file doesn't exist.
239    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
240
241    /// Write content to a file atomically using temp file + rename pattern.
242    ///
243    /// This ensures the file is either fully written or not written at all,
244    /// preventing partial writes or corruption from crashes/interruptions.
245    ///
246    /// # Implementation details
247    ///
248    /// - `WorkspaceFs`: Uses `tempfile::NamedTempFile` in the same directory,
249    ///   writes content, syncs to disk, then atomically renames to target.
250    ///   On Unix, temp file has mode 0600 for security.
251    /// - `MemoryWorkspace`: Just calls `write()` since in-memory operations
252    ///   are inherently atomic (no partial state possible).
253    ///
254    /// # When to use
255    ///
256    /// Use `write_atomic()` for critical files where corruption would be problematic:
257    /// - XML outputs (issues.xml, plan.xml, commit_message.xml)
258    /// - Agent artifacts (PLAN.md, commit-message.txt)
259    /// - Any file that must not have partial content
260    ///
261    /// Use regular `write()` for:
262    /// - Log files (append-only, partial is acceptable)
263    /// - Temporary/debug files
264    /// - Files where performance matters more than atomicity
265    fn write_atomic(&self, relative: &Path, content: &str) -> io::Result<()>;
266
267    /// Set a file to read-only permissions.
268    ///
269    /// This is a best-effort operation for protecting files like PROMPT.md backups.
270    /// On Unix, sets permissions to 0o444.
271    /// On Windows, sets the readonly flag.
272    /// In-memory implementations may no-op since permissions aren't relevant for testing.
273    ///
274    /// Returns Ok(()) on success or if the file doesn't exist (nothing to protect).
275    /// Returns Err only if the file exists but permissions cannot be changed.
276    fn set_readonly(&self, relative: &Path) -> io::Result<()>;
277
278    /// Set a file to writable permissions.
279    ///
280    /// Reverses the effect of `set_readonly`.
281    /// On Unix, sets permissions to 0o644.
282    /// On Windows, clears the readonly flag.
283    /// In-memory implementations may no-op since permissions aren't relevant for testing.
284    ///
285    /// Returns Ok(()) on success or if the file doesn't exist.
286    fn set_writable(&self, relative: &Path) -> io::Result<()>;
287
288    // =========================================================================
289    // Path resolution (default implementations)
290    // =========================================================================
291
292    /// Resolve a relative path to an absolute path.
293    fn absolute(&self, relative: &Path) -> PathBuf {
294        self.root().join(relative)
295    }
296
297    /// Resolve a relative path to an absolute path as a string.
298    fn absolute_str(&self, relative: &str) -> String {
299        self.root().join(relative).display().to_string()
300    }
301
302    // =========================================================================
303    // Well-known paths (default implementations)
304    // =========================================================================
305
306    /// Path to the `.agent` directory.
307    fn agent_dir(&self) -> PathBuf {
308        self.root().join(AGENT_DIR)
309    }
310
311    /// Path to the `.agent/logs` directory.
312    fn agent_logs(&self) -> PathBuf {
313        self.root().join(AGENT_LOGS)
314    }
315
316    /// Path to the `.agent/tmp` directory.
317    fn agent_tmp(&self) -> PathBuf {
318        self.root().join(AGENT_TMP)
319    }
320
321    /// Path to `.agent/PLAN.md`.
322    fn plan_md(&self) -> PathBuf {
323        self.root().join(PLAN_MD)
324    }
325
326    /// Path to `.agent/ISSUES.md`.
327    fn issues_md(&self) -> PathBuf {
328        self.root().join(ISSUES_MD)
329    }
330
331    /// Path to `.agent/STATUS.md`.
332    fn status_md(&self) -> PathBuf {
333        self.root().join(STATUS_MD)
334    }
335
336    /// Path to `.agent/NOTES.md`.
337    fn notes_md(&self) -> PathBuf {
338        self.root().join(NOTES_MD)
339    }
340
341    /// Path to `.agent/commit-message.txt`.
342    fn commit_message(&self) -> PathBuf {
343        self.root().join(COMMIT_MESSAGE_TXT)
344    }
345
346    /// Path to `.agent/checkpoint.json`.
347    fn checkpoint(&self) -> PathBuf {
348        self.root().join(CHECKPOINT_JSON)
349    }
350
351    /// Path to `.agent/start_commit`.
352    fn start_commit(&self) -> PathBuf {
353        self.root().join(START_COMMIT)
354    }
355
356    /// Path to `.agent/review_baseline.txt`.
357    fn review_baseline(&self) -> PathBuf {
358        self.root().join(REVIEW_BASELINE_TXT)
359    }
360
361    /// Path to `PROMPT.md` in the repository root.
362    fn prompt_md(&self) -> PathBuf {
363        self.root().join(PROMPT_MD)
364    }
365
366    /// Path to `.agent/PROMPT.md.backup`.
367    fn prompt_backup(&self) -> PathBuf {
368        self.root().join(PROMPT_BACKUP)
369    }
370
371    /// Path to `.agent/config.toml`.
372    fn agent_config(&self) -> PathBuf {
373        self.root().join(AGENT_CONFIG_TOML)
374    }
375
376    /// Path to `.agent/agents.toml`.
377    fn agents_toml(&self) -> PathBuf {
378        self.root().join(AGENTS_TOML)
379    }
380
381    /// Path to `.agent/logs/pipeline.log`.
382    fn pipeline_log(&self) -> PathBuf {
383        self.root().join(PIPELINE_LOG)
384    }
385
386    /// Path to an XSD schema file in `.agent/tmp/`.
387    fn xsd_path(&self, name: &str) -> PathBuf {
388        self.root().join(format!(".agent/tmp/{}.xsd", name))
389    }
390
391    /// Path to an XML file in `.agent/tmp/`.
392    fn xml_path(&self, name: &str) -> PathBuf {
393        self.root().join(format!(".agent/tmp/{}.xml", name))
394    }
395
396    /// Path to a log file in `.agent/logs/`.
397    fn log_path(&self, name: &str) -> PathBuf {
398        self.root().join(format!(".agent/logs/{}", name))
399    }
400}
401
402// ============================================================================
403// Production Implementation: WorkspaceFs
404// ============================================================================
405
406/// Production workspace implementation using the real filesystem.
407///
408/// All file operations are performed relative to the repository root using `std::fs`.
409#[derive(Debug, Clone)]
410pub struct WorkspaceFs {
411    root: PathBuf,
412}
413
414impl WorkspaceFs {
415    /// Create a new workspace filesystem rooted at the given path.
416    ///
417    /// # Arguments
418    ///
419    /// * `repo_root` - The repository root directory (typically discovered via git)
420    pub fn new(repo_root: PathBuf) -> Self {
421        Self { root: repo_root }
422    }
423}
424
425impl Workspace for WorkspaceFs {
426    fn root(&self) -> &Path {
427        &self.root
428    }
429
430    fn read(&self, relative: &Path) -> io::Result<String> {
431        fs::read_to_string(self.root.join(relative))
432    }
433
434    fn read_bytes(&self, relative: &Path) -> io::Result<Vec<u8>> {
435        fs::read(self.root.join(relative))
436    }
437
438    fn write(&self, relative: &Path, content: &str) -> io::Result<()> {
439        let path = self.root.join(relative);
440        if let Some(parent) = path.parent() {
441            fs::create_dir_all(parent)?;
442        }
443        fs::write(path, content)
444    }
445
446    fn write_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()> {
447        let path = self.root.join(relative);
448        if let Some(parent) = path.parent() {
449            fs::create_dir_all(parent)?;
450        }
451        fs::write(path, content)
452    }
453
454    fn append_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()> {
455        use std::io::Write;
456        let path = self.root.join(relative);
457        if let Some(parent) = path.parent() {
458            fs::create_dir_all(parent)?;
459        }
460        let mut file = fs::OpenOptions::new()
461            .create(true)
462            .append(true)
463            .open(path)?;
464        file.write_all(content)?;
465        file.flush()
466    }
467
468    fn exists(&self, relative: &Path) -> bool {
469        self.root.join(relative).exists()
470    }
471
472    fn is_file(&self, relative: &Path) -> bool {
473        self.root.join(relative).is_file()
474    }
475
476    fn is_dir(&self, relative: &Path) -> bool {
477        self.root.join(relative).is_dir()
478    }
479
480    fn remove(&self, relative: &Path) -> io::Result<()> {
481        fs::remove_file(self.root.join(relative))
482    }
483
484    fn remove_if_exists(&self, relative: &Path) -> io::Result<()> {
485        let path = self.root.join(relative);
486        if path.exists() {
487            fs::remove_file(path)?;
488        }
489        Ok(())
490    }
491
492    fn remove_dir_all(&self, relative: &Path) -> io::Result<()> {
493        fs::remove_dir_all(self.root.join(relative))
494    }
495
496    fn remove_dir_all_if_exists(&self, relative: &Path) -> io::Result<()> {
497        let path = self.root.join(relative);
498        if path.exists() {
499            fs::remove_dir_all(path)?;
500        }
501        Ok(())
502    }
503
504    fn create_dir_all(&self, relative: &Path) -> io::Result<()> {
505        fs::create_dir_all(self.root.join(relative))
506    }
507
508    fn read_dir(&self, relative: &Path) -> io::Result<Vec<DirEntry>> {
509        let abs_path = self.root.join(relative);
510        let mut entries = Vec::new();
511        for entry in fs::read_dir(abs_path)? {
512            let entry = entry?;
513            let metadata = entry.metadata()?;
514            // Store relative path from workspace root
515            let rel_path = relative.join(entry.file_name());
516            let modified = metadata.modified().ok();
517            if let Some(mod_time) = modified {
518                entries.push(DirEntry::with_modified(
519                    rel_path,
520                    metadata.is_file(),
521                    metadata.is_dir(),
522                    mod_time,
523                ));
524            } else {
525                entries.push(DirEntry::new(
526                    rel_path,
527                    metadata.is_file(),
528                    metadata.is_dir(),
529                ));
530            }
531        }
532        Ok(entries)
533    }
534
535    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
536        fs::rename(self.root.join(from), self.root.join(to))
537    }
538
539    fn write_atomic(&self, relative: &Path, content: &str) -> io::Result<()> {
540        use std::io::Write;
541        use tempfile::NamedTempFile;
542
543        let path = self.root.join(relative);
544
545        // Create parent directories if needed
546        if let Some(parent) = path.parent() {
547            fs::create_dir_all(parent)?;
548        }
549
550        // Create a NamedTempFile in the same directory as the target file.
551        // This ensures atomic rename works (same filesystem).
552        let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
553        let mut temp_file = NamedTempFile::new_in(parent_dir)?;
554
555        // Set restrictive permissions on temp file (0600 = owner read/write only)
556        // This prevents other users from reading the temp file before rename
557        #[cfg(unix)]
558        {
559            use std::os::unix::fs::PermissionsExt;
560            let mut perms = fs::metadata(temp_file.path())?.permissions();
561            perms.set_mode(0o600);
562            fs::set_permissions(temp_file.path(), perms)?;
563        }
564
565        // Write content to the temp file
566        temp_file.write_all(content.as_bytes())?;
567        temp_file.flush()?;
568        temp_file.as_file().sync_all()?;
569
570        // Persist the temp file to the target location (atomic rename)
571        temp_file.persist(&path).map_err(|e| e.error)?;
572
573        Ok(())
574    }
575
576    fn set_readonly(&self, relative: &Path) -> io::Result<()> {
577        let path = self.root.join(relative);
578        if !path.exists() {
579            return Ok(());
580        }
581
582        let metadata = fs::metadata(&path)?;
583        let mut perms = metadata.permissions();
584
585        #[cfg(unix)]
586        {
587            use std::os::unix::fs::PermissionsExt;
588            perms.set_mode(0o444);
589        }
590
591        #[cfg(windows)]
592        {
593            perms.set_readonly(true);
594        }
595
596        fs::set_permissions(path, perms)
597    }
598
599    fn set_writable(&self, relative: &Path) -> io::Result<()> {
600        let path = self.root.join(relative);
601        if !path.exists() {
602            return Ok(());
603        }
604
605        let metadata = fs::metadata(&path)?;
606        let mut perms = metadata.permissions();
607
608        #[cfg(unix)]
609        {
610            use std::os::unix::fs::PermissionsExt;
611            perms.set_mode(0o644);
612        }
613
614        #[cfg(windows)]
615        {
616            perms.set_readonly(false);
617        }
618
619        fs::set_permissions(path, perms)
620    }
621}
622
623// ============================================================================
624// Test Implementation: MemoryWorkspace
625// ============================================================================
626
627/// In-memory file entry with content and metadata.
628#[cfg(any(test, feature = "test-utils"))]
629#[derive(Debug, Clone)]
630struct MemoryFile {
631    content: Vec<u8>,
632    modified: std::time::SystemTime,
633}
634
635#[cfg(any(test, feature = "test-utils"))]
636impl MemoryFile {
637    fn new(content: Vec<u8>) -> Self {
638        Self {
639            content,
640            modified: std::time::SystemTime::now(),
641        }
642    }
643
644    fn with_modified(content: Vec<u8>, modified: std::time::SystemTime) -> Self {
645        Self { content, modified }
646    }
647}
648
649/// In-memory workspace implementation for testing.
650///
651/// All file operations are performed against an in-memory HashMap, allowing tests to:
652/// - Verify what was written without touching real files
653/// - Control what reads return
654/// - Run in parallel without filesystem conflicts
655/// - Be deterministic and fast
656#[cfg(any(test, feature = "test-utils"))]
657#[derive(Debug)]
658pub struct MemoryWorkspace {
659    root: PathBuf,
660    files: std::sync::RwLock<std::collections::HashMap<PathBuf, MemoryFile>>,
661    directories: std::sync::RwLock<std::collections::HashSet<PathBuf>>,
662}
663
664#[cfg(any(test, feature = "test-utils"))]
665impl MemoryWorkspace {
666    /// Create a new in-memory workspace with the given virtual root path.
667    ///
668    /// The root path is used for path resolution but no real filesystem access occurs.
669    pub fn new(root: PathBuf) -> Self {
670        Self {
671            root,
672            files: std::sync::RwLock::new(std::collections::HashMap::new()),
673            directories: std::sync::RwLock::new(std::collections::HashSet::new()),
674        }
675    }
676
677    /// Create a new in-memory workspace with a default test root path.
678    pub fn new_test() -> Self {
679        Self::new(PathBuf::from("/test/repo"))
680    }
681
682    /// Ensure all parent directories exist for the given path.
683    ///
684    /// This is a helper to reduce duplication in file/directory creation methods.
685    fn ensure_parent_dirs(&self, path: &Path) {
686        if let Some(parent) = path.parent() {
687            if parent.as_os_str().is_empty() {
688                return;
689            }
690            let mut dirs = self.directories.write().unwrap();
691            let mut current = PathBuf::new();
692            for component in parent.components() {
693                current.push(component);
694                dirs.insert(current.clone());
695            }
696        }
697    }
698
699    /// Ensure all components of the path exist as directories.
700    ///
701    /// Used for creating directories themselves (not just parents).
702    fn ensure_dir_path(&self, path: &Path) {
703        let mut dirs = self.directories.write().unwrap();
704        let mut current = PathBuf::new();
705        for component in path.components() {
706            current.push(component);
707            dirs.insert(current.clone());
708        }
709    }
710
711    /// Pre-populate a file with content for testing.
712    ///
713    /// Also creates parent directories automatically.
714    pub fn with_file(self, path: &str, content: &str) -> Self {
715        let path_buf = PathBuf::from(path);
716        self.ensure_parent_dirs(&path_buf);
717        self.files
718            .write()
719            .unwrap()
720            .insert(path_buf, MemoryFile::new(content.as_bytes().to_vec()));
721        self
722    }
723
724    /// Pre-populate a file with content and explicit modification time for testing.
725    ///
726    /// Also creates parent directories automatically.
727    pub fn with_file_at_time(
728        self,
729        path: &str,
730        content: &str,
731        modified: std::time::SystemTime,
732    ) -> Self {
733        let path_buf = PathBuf::from(path);
734        self.ensure_parent_dirs(&path_buf);
735        self.files.write().unwrap().insert(
736            path_buf,
737            MemoryFile::with_modified(content.as_bytes().to_vec(), modified),
738        );
739        self
740    }
741
742    /// Pre-populate a file with bytes for testing.
743    ///
744    /// Also creates parent directories automatically.
745    pub fn with_file_bytes(self, path: &str, content: &[u8]) -> Self {
746        let path_buf = PathBuf::from(path);
747        self.ensure_parent_dirs(&path_buf);
748        self.files
749            .write()
750            .unwrap()
751            .insert(path_buf, MemoryFile::new(content.to_vec()));
752        self
753    }
754
755    /// Pre-populate a directory for testing.
756    pub fn with_dir(self, path: &str) -> Self {
757        let path_buf = PathBuf::from(path);
758        self.ensure_dir_path(&path_buf);
759        self
760    }
761
762    /// List all files in a directory (for test assertions).
763    ///
764    /// Returns file paths relative to the workspace root.
765    pub fn list_files_in_dir(&self, dir: &str) -> Vec<PathBuf> {
766        let dir_path = PathBuf::from(dir);
767        self.files
768            .read()
769            .unwrap()
770            .keys()
771            .filter(|path| {
772                path.parent()
773                    .map(|p| p == dir_path || p.starts_with(&dir_path))
774                    .unwrap_or(false)
775            })
776            .cloned()
777            .collect()
778    }
779
780    /// Get the modification time of a file (for test assertions).
781    pub fn get_modified(&self, path: &str) -> Option<std::time::SystemTime> {
782        self.files
783            .read()
784            .unwrap()
785            .get(&PathBuf::from(path))
786            .map(|f| f.modified)
787    }
788
789    /// List all directories (for test assertions).
790    pub fn list_directories(&self) -> Vec<PathBuf> {
791        self.directories.read().unwrap().iter().cloned().collect()
792    }
793
794    /// Get all files that were written (for test assertions).
795    pub fn written_files(&self) -> std::collections::HashMap<PathBuf, Vec<u8>> {
796        self.files
797            .read()
798            .unwrap()
799            .iter()
800            .map(|(k, v)| (k.clone(), v.content.clone()))
801            .collect()
802    }
803
804    /// Get a specific file's content (for test assertions).
805    pub fn get_file(&self, path: &str) -> Option<String> {
806        self.files
807            .read()
808            .unwrap()
809            .get(&PathBuf::from(path))
810            .map(|f| String::from_utf8_lossy(&f.content).to_string())
811    }
812
813    /// Get a specific file's bytes (for test assertions).
814    pub fn get_file_bytes(&self, path: &str) -> Option<Vec<u8>> {
815        self.files
816            .read()
817            .unwrap()
818            .get(&PathBuf::from(path))
819            .map(|f| f.content.clone())
820    }
821
822    /// Check if a file was written (for test assertions).
823    pub fn was_written(&self, path: &str) -> bool {
824        self.files
825            .read()
826            .unwrap()
827            .contains_key(&PathBuf::from(path))
828    }
829
830    /// Clear all files (for test setup).
831    pub fn clear(&self) {
832        self.files.write().unwrap().clear();
833        self.directories.write().unwrap().clear();
834    }
835}
836
837#[cfg(any(test, feature = "test-utils"))]
838impl Workspace for MemoryWorkspace {
839    fn root(&self) -> &Path {
840        &self.root
841    }
842
843    fn read(&self, relative: &Path) -> io::Result<String> {
844        self.files
845            .read()
846            .unwrap()
847            .get(relative)
848            .map(|f| String::from_utf8_lossy(&f.content).to_string())
849            .ok_or_else(|| {
850                io::Error::new(
851                    io::ErrorKind::NotFound,
852                    format!("File not found: {}", relative.display()),
853                )
854            })
855    }
856
857    fn read_bytes(&self, relative: &Path) -> io::Result<Vec<u8>> {
858        self.files
859            .read()
860            .unwrap()
861            .get(relative)
862            .map(|f| f.content.clone())
863            .ok_or_else(|| {
864                io::Error::new(
865                    io::ErrorKind::NotFound,
866                    format!("File not found: {}", relative.display()),
867                )
868            })
869    }
870
871    fn write(&self, relative: &Path, content: &str) -> io::Result<()> {
872        self.ensure_parent_dirs(relative);
873        self.files.write().unwrap().insert(
874            relative.to_path_buf(),
875            MemoryFile::new(content.as_bytes().to_vec()),
876        );
877        Ok(())
878    }
879
880    fn write_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()> {
881        self.ensure_parent_dirs(relative);
882        self.files
883            .write()
884            .unwrap()
885            .insert(relative.to_path_buf(), MemoryFile::new(content.to_vec()));
886        Ok(())
887    }
888
889    fn append_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()> {
890        self.ensure_parent_dirs(relative);
891        let mut files = self.files.write().unwrap();
892        let entry = files
893            .entry(relative.to_path_buf())
894            .or_insert_with(|| MemoryFile::new(Vec::new()));
895        entry.content.extend_from_slice(content);
896        entry.modified = std::time::SystemTime::now();
897        Ok(())
898    }
899
900    fn exists(&self, relative: &Path) -> bool {
901        self.files.read().unwrap().contains_key(relative)
902            || self.directories.read().unwrap().contains(relative)
903    }
904
905    fn is_file(&self, relative: &Path) -> bool {
906        self.files.read().unwrap().contains_key(relative)
907    }
908
909    fn is_dir(&self, relative: &Path) -> bool {
910        self.directories.read().unwrap().contains(relative)
911    }
912
913    fn remove(&self, relative: &Path) -> io::Result<()> {
914        self.files
915            .write()
916            .unwrap()
917            .remove(relative)
918            .map(|_| ())
919            .ok_or_else(|| {
920                io::Error::new(
921                    io::ErrorKind::NotFound,
922                    format!("File not found: {}", relative.display()),
923                )
924            })
925    }
926
927    fn remove_if_exists(&self, relative: &Path) -> io::Result<()> {
928        self.files.write().unwrap().remove(relative);
929        Ok(())
930    }
931
932    fn remove_dir_all(&self, relative: &Path) -> io::Result<()> {
933        // Check if directory exists first
934        if !self.directories.read().unwrap().contains(relative) {
935            return Err(io::Error::new(
936                io::ErrorKind::NotFound,
937                format!("Directory not found: {}", relative.display()),
938            ));
939        }
940        self.remove_dir_all_if_exists(relative)
941    }
942
943    fn remove_dir_all_if_exists(&self, relative: &Path) -> io::Result<()> {
944        // Remove all files under this directory
945        {
946            let mut files = self.files.write().unwrap();
947            let to_remove: Vec<PathBuf> = files
948                .keys()
949                .filter(|path| path.starts_with(relative))
950                .cloned()
951                .collect();
952            for path in to_remove {
953                files.remove(&path);
954            }
955        }
956        // Remove all directories under this directory (including itself)
957        {
958            let mut dirs = self.directories.write().unwrap();
959            let to_remove: Vec<PathBuf> = dirs
960                .iter()
961                .filter(|path| path.starts_with(relative) || *path == relative)
962                .cloned()
963                .collect();
964            for path in to_remove {
965                dirs.remove(&path);
966            }
967        }
968        Ok(())
969    }
970
971    fn create_dir_all(&self, relative: &Path) -> io::Result<()> {
972        self.ensure_dir_path(relative);
973        Ok(())
974    }
975
976    fn read_dir(&self, relative: &Path) -> io::Result<Vec<DirEntry>> {
977        let files = self.files.read().unwrap();
978        let dirs = self.directories.read().unwrap();
979
980        // Check if the directory exists
981        if !relative.as_os_str().is_empty() && !dirs.contains(relative) {
982            return Err(io::Error::new(
983                io::ErrorKind::NotFound,
984                format!("Directory not found: {}", relative.display()),
985            ));
986        }
987
988        let mut entries = Vec::new();
989        let mut seen = std::collections::HashSet::new();
990
991        // Find all files that are direct children of this directory
992        for (path, mem_file) in files.iter() {
993            if let Some(parent) = path.parent() {
994                if parent == relative {
995                    if let Some(name) = path.file_name() {
996                        if seen.insert(name.to_os_string()) {
997                            entries.push(DirEntry::with_modified(
998                                path.clone(),
999                                true,
1000                                false,
1001                                mem_file.modified,
1002                            ));
1003                        }
1004                    }
1005                }
1006            }
1007        }
1008
1009        // Find all directories that are direct children of this directory
1010        for dir_path in dirs.iter() {
1011            if let Some(parent) = dir_path.parent() {
1012                if parent == relative {
1013                    if let Some(name) = dir_path.file_name() {
1014                        if seen.insert(name.to_os_string()) {
1015                            entries.push(DirEntry::new(dir_path.clone(), false, true));
1016                        }
1017                    }
1018                }
1019            }
1020        }
1021
1022        Ok(entries)
1023    }
1024
1025    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1026        // Create parent directories for destination first (before taking files lock)
1027        self.ensure_parent_dirs(to);
1028        let mut files = self.files.write().unwrap();
1029        if let Some(file) = files.remove(from) {
1030            files.insert(to.to_path_buf(), file);
1031            Ok(())
1032        } else {
1033            Err(io::Error::new(
1034                io::ErrorKind::NotFound,
1035                format!("File not found: {}", from.display()),
1036            ))
1037        }
1038    }
1039
1040    fn set_readonly(&self, _relative: &Path) -> io::Result<()> {
1041        // No-op for in-memory workspace - permissions aren't relevant for testing
1042        Ok(())
1043    }
1044
1045    fn set_writable(&self, _relative: &Path) -> io::Result<()> {
1046        // No-op for in-memory workspace - permissions aren't relevant for testing
1047        Ok(())
1048    }
1049
1050    fn write_atomic(&self, relative: &Path, content: &str) -> io::Result<()> {
1051        // In-memory operations are inherently atomic - no partial state possible.
1052        // Just delegate to regular write().
1053        self.write(relative, content)
1054    }
1055}
1056
1057#[cfg(any(test, feature = "test-utils"))]
1058impl Clone for MemoryWorkspace {
1059    fn clone(&self) -> Self {
1060        Self {
1061            root: self.root.clone(),
1062            files: std::sync::RwLock::new(self.files.read().unwrap().clone()),
1063            directories: std::sync::RwLock::new(self.directories.read().unwrap().clone()),
1064        }
1065    }
1066}
1067
1068// ============================================================================
1069// Tests
1070// ============================================================================
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075
1076    // =========================================================================
1077    // WorkspaceFs path resolution tests (no filesystem access needed)
1078    // =========================================================================
1079
1080    #[test]
1081    fn test_workspace_fs_root() {
1082        let ws = WorkspaceFs::new(PathBuf::from("/test/repo"));
1083        assert_eq!(ws.root(), Path::new("/test/repo"));
1084    }
1085
1086    #[test]
1087    fn test_workspace_fs_agent_paths() {
1088        let ws = WorkspaceFs::new(PathBuf::from("/test/repo"));
1089
1090        assert_eq!(ws.agent_dir(), PathBuf::from("/test/repo/.agent"));
1091        assert_eq!(ws.agent_logs(), PathBuf::from("/test/repo/.agent/logs"));
1092        assert_eq!(ws.agent_tmp(), PathBuf::from("/test/repo/.agent/tmp"));
1093        assert_eq!(ws.plan_md(), PathBuf::from("/test/repo/.agent/PLAN.md"));
1094        assert_eq!(ws.issues_md(), PathBuf::from("/test/repo/.agent/ISSUES.md"));
1095        assert_eq!(
1096            ws.commit_message(),
1097            PathBuf::from("/test/repo/.agent/commit-message.txt")
1098        );
1099        assert_eq!(
1100            ws.checkpoint(),
1101            PathBuf::from("/test/repo/.agent/checkpoint.json")
1102        );
1103        assert_eq!(
1104            ws.start_commit(),
1105            PathBuf::from("/test/repo/.agent/start_commit")
1106        );
1107        assert_eq!(ws.prompt_md(), PathBuf::from("/test/repo/PROMPT.md"));
1108    }
1109
1110    #[test]
1111    fn test_workspace_fs_dynamic_paths() {
1112        let ws = WorkspaceFs::new(PathBuf::from("/test/repo"));
1113
1114        assert_eq!(
1115            ws.xsd_path("plan"),
1116            PathBuf::from("/test/repo/.agent/tmp/plan.xsd")
1117        );
1118        assert_eq!(
1119            ws.xml_path("issues"),
1120            PathBuf::from("/test/repo/.agent/tmp/issues.xml")
1121        );
1122        assert_eq!(
1123            ws.log_path("agent.log"),
1124            PathBuf::from("/test/repo/.agent/logs/agent.log")
1125        );
1126    }
1127
1128    #[test]
1129    fn test_workspace_fs_absolute() {
1130        let ws = WorkspaceFs::new(PathBuf::from("/test/repo"));
1131
1132        let abs = ws.absolute(Path::new(".agent/tmp/plan.xml"));
1133        assert_eq!(abs, PathBuf::from("/test/repo/.agent/tmp/plan.xml"));
1134
1135        let abs_str = ws.absolute_str(".agent/tmp/plan.xml");
1136        assert_eq!(abs_str, "/test/repo/.agent/tmp/plan.xml");
1137    }
1138
1139    // =========================================================================
1140    // MemoryWorkspace tests
1141    // =========================================================================
1142
1143    #[test]
1144    fn test_memory_workspace_read_write() {
1145        let ws = MemoryWorkspace::new_test();
1146
1147        ws.write(Path::new(".agent/test.txt"), "hello").unwrap();
1148        assert_eq!(ws.read(Path::new(".agent/test.txt")).unwrap(), "hello");
1149        assert!(ws.was_written(".agent/test.txt"));
1150    }
1151
1152    #[test]
1153    fn test_memory_workspace_with_file() {
1154        let ws = MemoryWorkspace::new_test().with_file("existing.txt", "pre-existing content");
1155
1156        assert_eq!(
1157            ws.read(Path::new("existing.txt")).unwrap(),
1158            "pre-existing content"
1159        );
1160    }
1161
1162    #[test]
1163    fn test_memory_workspace_exists() {
1164        let ws = MemoryWorkspace::new_test().with_file("exists.txt", "content");
1165
1166        assert!(ws.exists(Path::new("exists.txt")));
1167        assert!(!ws.exists(Path::new("not_exists.txt")));
1168    }
1169
1170    #[test]
1171    fn test_memory_workspace_remove() {
1172        let ws = MemoryWorkspace::new_test().with_file("to_delete.txt", "content");
1173
1174        assert!(ws.exists(Path::new("to_delete.txt")));
1175        ws.remove(Path::new("to_delete.txt")).unwrap();
1176        assert!(!ws.exists(Path::new("to_delete.txt")));
1177    }
1178
1179    #[test]
1180    fn test_memory_workspace_read_nonexistent_fails() {
1181        let ws = MemoryWorkspace::new_test();
1182
1183        let result = ws.read(Path::new("nonexistent.txt"));
1184        assert!(result.is_err());
1185        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
1186    }
1187
1188    #[test]
1189    fn test_memory_workspace_written_files() {
1190        let ws = MemoryWorkspace::new_test();
1191
1192        ws.write(Path::new("file1.txt"), "content1").unwrap();
1193        ws.write(Path::new("file2.txt"), "content2").unwrap();
1194
1195        let files = ws.written_files();
1196        assert_eq!(files.len(), 2);
1197        assert_eq!(
1198            String::from_utf8_lossy(files.get(&PathBuf::from("file1.txt")).unwrap()),
1199            "content1"
1200        );
1201    }
1202
1203    #[test]
1204    fn test_memory_workspace_get_file() {
1205        let ws = MemoryWorkspace::new_test();
1206
1207        ws.write(Path::new("test.txt"), "test content").unwrap();
1208        assert_eq!(ws.get_file("test.txt"), Some("test content".to_string()));
1209        assert_eq!(ws.get_file("nonexistent.txt"), None);
1210    }
1211
1212    #[test]
1213    fn test_memory_workspace_clear() {
1214        let ws = MemoryWorkspace::new_test().with_file("file.txt", "content");
1215
1216        assert!(ws.exists(Path::new("file.txt")));
1217        ws.clear();
1218        assert!(!ws.exists(Path::new("file.txt")));
1219    }
1220
1221    #[test]
1222    fn test_memory_workspace_absolute_str() {
1223        let ws = MemoryWorkspace::new_test();
1224
1225        assert_eq!(
1226            ws.absolute_str(".agent/tmp/commit_message.xml"),
1227            "/test/repo/.agent/tmp/commit_message.xml"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_memory_workspace_creates_parent_dirs() {
1233        let ws = MemoryWorkspace::new_test();
1234
1235        ws.write(Path::new("a/b/c/file.txt"), "content").unwrap();
1236
1237        // Parent directories should be tracked
1238        assert!(ws.is_dir(Path::new("a")));
1239        assert!(ws.is_dir(Path::new("a/b")));
1240        assert!(ws.is_dir(Path::new("a/b/c")));
1241        assert!(ws.is_file(Path::new("a/b/c/file.txt")));
1242    }
1243
1244    #[test]
1245    fn test_memory_workspace_rename() {
1246        let ws = MemoryWorkspace::new_test().with_file("old.txt", "content");
1247
1248        ws.rename(Path::new("old.txt"), Path::new("new.txt"))
1249            .unwrap();
1250
1251        assert!(!ws.exists(Path::new("old.txt")));
1252        assert!(ws.exists(Path::new("new.txt")));
1253        assert_eq!(ws.read(Path::new("new.txt")).unwrap(), "content");
1254    }
1255
1256    #[test]
1257    fn test_memory_workspace_rename_creates_parent_dirs() {
1258        let ws = MemoryWorkspace::new_test().with_file("old.txt", "content");
1259
1260        ws.rename(Path::new("old.txt"), Path::new("a/b/new.txt"))
1261            .unwrap();
1262
1263        assert!(!ws.exists(Path::new("old.txt")));
1264        assert!(ws.is_dir(Path::new("a")));
1265        assert!(ws.is_dir(Path::new("a/b")));
1266        assert!(ws.exists(Path::new("a/b/new.txt")));
1267    }
1268
1269    #[test]
1270    fn test_memory_workspace_rename_nonexistent_fails() {
1271        let ws = MemoryWorkspace::new_test();
1272
1273        let result = ws.rename(Path::new("nonexistent.txt"), Path::new("new.txt"));
1274        assert!(result.is_err());
1275        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
1276    }
1277
1278    #[test]
1279    fn test_memory_workspace_set_readonly_noop() {
1280        // In-memory workspace doesn't track permissions, but should succeed
1281        let ws = MemoryWorkspace::new_test().with_file("test.txt", "content");
1282
1283        // Should succeed (no-op)
1284        ws.set_readonly(Path::new("test.txt")).unwrap();
1285        ws.set_writable(Path::new("test.txt")).unwrap();
1286
1287        // File should still be readable
1288        assert_eq!(ws.read(Path::new("test.txt")).unwrap(), "content");
1289    }
1290
1291    #[test]
1292    fn test_memory_workspace_write_atomic() {
1293        let ws = MemoryWorkspace::new_test();
1294
1295        ws.write_atomic(Path::new("atomic.txt"), "atomic content")
1296            .unwrap();
1297
1298        assert_eq!(ws.read(Path::new("atomic.txt")).unwrap(), "atomic content");
1299    }
1300
1301    #[test]
1302    fn test_memory_workspace_write_atomic_creates_parent_dirs() {
1303        let ws = MemoryWorkspace::new_test();
1304
1305        ws.write_atomic(Path::new("a/b/c/atomic.txt"), "nested atomic")
1306            .unwrap();
1307
1308        assert!(ws.is_dir(Path::new("a")));
1309        assert!(ws.is_dir(Path::new("a/b")));
1310        assert!(ws.is_dir(Path::new("a/b/c")));
1311        assert_eq!(
1312            ws.read(Path::new("a/b/c/atomic.txt")).unwrap(),
1313            "nested atomic"
1314        );
1315    }
1316
1317    #[test]
1318    fn test_memory_workspace_write_atomic_overwrites() {
1319        let ws = MemoryWorkspace::new_test().with_file("existing.txt", "old content");
1320
1321        ws.write_atomic(Path::new("existing.txt"), "new content")
1322            .unwrap();
1323
1324        assert_eq!(ws.read(Path::new("existing.txt")).unwrap(), "new content");
1325    }
1326}