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