Skip to main content

ralph_workflow/workspace/
workspace_fs.rs

1// WorkspaceFs - Production filesystem implementation of the Workspace trait.
2//
3// This file contains the production implementation that performs actual
4// filesystem operations relative to the repository root.
5
6/// Production workspace implementation using the real filesystem.
7///
8/// All file operations are performed relative to the repository root using `std::fs`.
9#[derive(Debug, Clone)]
10pub struct WorkspaceFs {
11    root: PathBuf,
12}
13
14impl WorkspaceFs {
15    /// Create a new workspace filesystem rooted at the given path.
16    ///
17    /// # Arguments
18    ///
19    /// * `repo_root` - The repository root directory (typically discovered via git)
20    #[must_use] 
21    pub const fn new(repo_root: PathBuf) -> Self {
22        Self { root: repo_root }
23    }
24}
25
26impl Workspace for WorkspaceFs {
27    fn root(&self) -> &Path {
28        &self.root
29    }
30
31    fn read(&self, relative: &Path) -> io::Result<String> {
32        fs::read_to_string(self.root.join(relative))
33    }
34
35    fn read_bytes(&self, relative: &Path) -> io::Result<Vec<u8>> {
36        fs::read(self.root.join(relative))
37    }
38
39    fn write(&self, relative: &Path, content: &str) -> io::Result<()> {
40        let path = self.root.join(relative);
41        if let Some(parent) = path.parent() {
42            fs::create_dir_all(parent)?;
43        }
44        fs::write(path, content)
45    }
46
47    fn write_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()> {
48        let path = self.root.join(relative);
49        if let Some(parent) = path.parent() {
50            fs::create_dir_all(parent)?;
51        }
52        fs::write(path, content)
53    }
54
55    fn append_bytes(&self, relative: &Path, content: &[u8]) -> io::Result<()> {
56        use std::io::Write;
57        let path = self.root.join(relative);
58        if let Some(parent) = path.parent() {
59            fs::create_dir_all(parent)?;
60        }
61        let mut file = fs::OpenOptions::new()
62            .create(true)
63            .append(true)
64            .open(path)?;
65        file.write_all(content)?;
66        file.flush()
67    }
68
69    fn exists(&self, relative: &Path) -> bool {
70        self.root.join(relative).exists()
71    }
72
73    fn is_file(&self, relative: &Path) -> bool {
74        self.root.join(relative).is_file()
75    }
76
77    fn is_dir(&self, relative: &Path) -> bool {
78        self.root.join(relative).is_dir()
79    }
80
81    fn remove(&self, relative: &Path) -> io::Result<()> {
82        fs::remove_file(self.root.join(relative))
83    }
84
85    fn remove_if_exists(&self, relative: &Path) -> io::Result<()> {
86        let path = self.root.join(relative);
87        if path.exists() {
88            fs::remove_file(path)?;
89        }
90        Ok(())
91    }
92
93    fn remove_dir_all(&self, relative: &Path) -> io::Result<()> {
94        fs::remove_dir_all(self.root.join(relative))
95    }
96
97    fn remove_dir_all_if_exists(&self, relative: &Path) -> io::Result<()> {
98        let path = self.root.join(relative);
99        if path.exists() {
100            fs::remove_dir_all(path)?;
101        }
102        Ok(())
103    }
104
105    fn create_dir_all(&self, relative: &Path) -> io::Result<()> {
106        fs::create_dir_all(self.root.join(relative))
107    }
108
109    fn read_dir(&self, relative: &Path) -> io::Result<Vec<DirEntry>> {
110        let abs_path = self.root.join(relative);
111        let mut entries = Vec::new();
112        for entry in fs::read_dir(abs_path)? {
113            let entry = entry?;
114            let metadata = entry.metadata()?;
115            // Store relative path from workspace root
116            let rel_path = relative.join(entry.file_name());
117            let modified = metadata.modified().ok();
118            if let Some(mod_time) = modified {
119                entries.push(DirEntry::with_modified(
120                    rel_path,
121                    metadata.is_file(),
122                    metadata.is_dir(),
123                    mod_time,
124                ));
125            } else {
126                entries.push(DirEntry::new(
127                    rel_path,
128                    metadata.is_file(),
129                    metadata.is_dir(),
130                ));
131            }
132        }
133        Ok(entries)
134    }
135
136    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
137        fs::rename(self.root.join(from), self.root.join(to))
138    }
139
140    fn write_atomic(&self, relative: &Path, content: &str) -> io::Result<()> {
141        use std::io::Write;
142        use tempfile::NamedTempFile;
143
144        let path = self.root.join(relative);
145
146        // Create parent directories if needed
147        if let Some(parent) = path.parent() {
148            fs::create_dir_all(parent)?;
149        }
150
151        // Create a NamedTempFile in the same directory as the target file.
152        // This ensures atomic rename works (same filesystem).
153        let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
154        let mut temp_file = NamedTempFile::new_in(parent_dir)?;
155
156        // Set restrictive permissions on temp file (0600 = owner read/write only)
157        // This prevents other users from reading the temp file before rename
158        #[cfg(unix)]
159        {
160            use std::os::unix::fs::PermissionsExt;
161            let mut perms = fs::metadata(temp_file.path())?.permissions();
162            perms.set_mode(0o600);
163            fs::set_permissions(temp_file.path(), perms)?;
164        }
165
166        // Write content to the temp file
167        temp_file.write_all(content.as_bytes())?;
168        temp_file.flush()?;
169
170        // Skip sync_all() when a user interrupt is in progress.
171        //
172        // On macOS, sync_all() maps to F_FULLFSYNC which waits for ALL pending I/O to
173        // complete at the hardware level. After a SIGTERM-killed agent subprocess, the
174        // kernel may defer this indefinitely (tens of seconds), causing the checkpoint
175        // write to hang and the process to never exit.
176        //
177        // Skipping sync is safe in this context: we are shutting down due to Ctrl+C and
178        // the user has already accepted the risk of incomplete I/O by pressing interrupt.
179        // The checkpoint content is already in the OS page cache and will be visible to
180        // any subsequent process that reads it (e.g., `ralph --resume`). The only risk
181        // is data loss if the machine loses power between now and the OS flushing the
182        // cache -- an acceptable trade-off for an interrupt scenario.
183        if !crate::interrupt::user_interrupted_occurred() {
184            temp_file.as_file().sync_all()?;
185        }
186
187        // Persist the temp file to the target location (atomic rename)
188        temp_file.persist(&path).map_err(|e| e.error)?;
189
190        Ok(())
191    }
192
193    fn set_readonly(&self, relative: &Path) -> io::Result<()> {
194        let path = self.root.join(relative);
195        if !path.exists() {
196            return Ok(());
197        }
198
199        let metadata = fs::metadata(&path)?;
200        let mut perms = metadata.permissions();
201
202        #[cfg(unix)]
203        {
204            use std::os::unix::fs::PermissionsExt;
205            perms.set_mode(0o444);
206        }
207
208        #[cfg(windows)]
209        {
210            perms.set_readonly(true);
211        }
212
213        fs::set_permissions(path, perms)
214    }
215
216    fn set_writable(&self, relative: &Path) -> io::Result<()> {
217        let path = self.root.join(relative);
218        if !path.exists() {
219            return Ok(());
220        }
221
222        let metadata = fs::metadata(&path)?;
223        let mut perms = metadata.permissions();
224
225        #[cfg(unix)]
226        {
227            use std::os::unix::fs::PermissionsExt;
228            perms.set_mode(0o644);
229        }
230
231        #[cfg(windows)]
232        {
233            perms.set_readonly(false);
234        }
235
236        fs::set_permissions(path, perms)
237    }
238}