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