Skip to main content

ralph_workflow/workspace/files/
io.rs

1// workspace/files/io.rs — boundary module for filesystem I/O.
2// File stem is `io` — recognized as boundary module by forbid_io_effects lint.
3//
4// Contains the production WorkspaceFs implementation that performs actual
5// filesystem operations relative to the repository root.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use crate::workspace::{DirEntry, Workspace};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum AtomicWriteSync {
14    Full,
15    SkipInterrupt,
16}
17
18pub fn decide_atomic_write_sync(interrupted: bool) -> AtomicWriteSync {
19    if interrupted {
20        AtomicWriteSync::SkipInterrupt
21    } else {
22        AtomicWriteSync::Full
23    }
24}
25
26pub fn sync_temp_file(file: &std::fs::File, policy: AtomicWriteSync) -> std::io::Result<()> {
27    match policy {
28        AtomicWriteSync::Full => {
29            file.sync_all()?;
30            Ok(())
31        }
32        AtomicWriteSync::SkipInterrupt => Ok(()),
33    }
34}
35
36#[cfg(unix)]
37pub fn set_restrictive_permissions(path: &std::path::Path) -> std::io::Result<()> {
38    use std::os::unix::fs::PermissionsExt;
39    let metadata = fs::metadata(path)?;
40    let mut perms = metadata.permissions();
41    perms.set_mode(0o600);
42    fs::set_permissions(path, perms)
43}
44
45#[cfg(not(unix))]
46pub fn set_restrictive_permissions(_path: &std::path::Path) -> std::io::Result<()> {
47    Ok(())
48}
49
50/// Production workspace implementation using the real filesystem.
51///
52/// All file operations are performed relative to the repository root using `std::fs`.
53#[derive(Debug, Clone)]
54pub struct WorkspaceFs {
55    root: PathBuf,
56}
57
58impl WorkspaceFs {
59    /// Create a new workspace filesystem rooted at the given path.
60    ///
61    /// # Arguments
62    ///
63    /// * `repo_root` - The repository root directory (typically discovered via git)
64    #[must_use]
65    pub const fn new(repo_root: PathBuf) -> Self {
66        Self { root: repo_root }
67    }
68}
69
70impl Workspace for WorkspaceFs {
71    fn root(&self) -> &Path {
72        &self.root
73    }
74
75    fn read(&self, relative: &Path) -> std::io::Result<String> {
76        fs::read_to_string(self.root.join(relative))
77    }
78
79    fn read_bytes(&self, relative: &Path) -> std::io::Result<Vec<u8>> {
80        fs::read(self.root.join(relative))
81    }
82
83    fn write(&self, relative: &Path, content: &str) -> std::io::Result<()> {
84        let path = self.root.join(relative);
85        if let Some(parent) = path.parent() {
86            fs::create_dir_all(parent)?;
87        }
88        fs::write(path, content)
89    }
90
91    fn write_bytes(&self, relative: &Path, content: &[u8]) -> std::io::Result<()> {
92        let path = self.root.join(relative);
93        if let Some(parent) = path.parent() {
94            fs::create_dir_all(parent)?;
95        }
96        fs::write(path, content)
97    }
98
99    fn append_bytes(&self, relative: &Path, content: &[u8]) -> std::io::Result<()> {
100        let path = self.root.join(relative);
101        if let Some(parent) = path.parent() {
102            fs::create_dir_all(parent)?;
103        }
104        let mut file = fs::OpenOptions::new()
105            .create(true)
106            .append(true)
107            .open(path)?;
108        std::io::Write::write_all(&mut file, content)?;
109        std::io::Write::flush(&mut file)
110    }
111
112    fn exists(&self, relative: &Path) -> bool {
113        self.root.join(relative).exists()
114    }
115
116    fn is_file(&self, relative: &Path) -> bool {
117        self.root.join(relative).is_file()
118    }
119
120    fn is_dir(&self, relative: &Path) -> bool {
121        self.root.join(relative).is_dir()
122    }
123
124    fn remove(&self, relative: &Path) -> std::io::Result<()> {
125        fs::remove_file(self.root.join(relative))
126    }
127
128    fn remove_if_exists(&self, relative: &Path) -> std::io::Result<()> {
129        let path = self.root.join(relative);
130        if path.exists() {
131            fs::remove_file(path)?;
132        }
133        Ok(())
134    }
135
136    fn remove_dir_all(&self, relative: &Path) -> std::io::Result<()> {
137        fs::remove_dir_all(self.root.join(relative))
138    }
139
140    fn remove_dir_all_if_exists(&self, relative: &Path) -> std::io::Result<()> {
141        let path = self.root.join(relative);
142        if path.exists() {
143            fs::remove_dir_all(path)?;
144        }
145        Ok(())
146    }
147
148    fn create_dir_all(&self, relative: &Path) -> std::io::Result<()> {
149        fs::create_dir_all(self.root.join(relative))
150    }
151
152    fn read_dir(&self, relative: &Path) -> std::io::Result<Vec<DirEntry>> {
153        let abs_path = self.root.join(relative);
154        let entries: Vec<DirEntry> = fs::read_dir(abs_path)?
155            .map(|entry| -> std::io::Result<DirEntry> {
156                let entry = entry?;
157                let metadata = entry.metadata()?;
158                let rel_path = relative.join(entry.file_name());
159                let modified = metadata.modified().ok();
160                Ok(if let Some(mod_time) = modified {
161                    DirEntry::with_modified(
162                        rel_path,
163                        metadata.is_file(),
164                        metadata.is_dir(),
165                        mod_time,
166                    )
167                } else {
168                    DirEntry::new(rel_path, metadata.is_file(), metadata.is_dir())
169                })
170            })
171            .collect::<std::io::Result<Vec<_>>>()?;
172        Ok(entries)
173    }
174
175    fn rename(&self, from: &Path, to: &Path) -> std::io::Result<()> {
176        fs::rename(self.root.join(from), self.root.join(to))
177    }
178
179    fn write_atomic(&self, relative: &Path, content: &str) -> std::io::Result<()> {
180        use tempfile::NamedTempFile;
181
182        let path = self.root.join(relative);
183
184        if let Some(parent) = path.parent() {
185            fs::create_dir_all(parent)?;
186        }
187
188        let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
189        let mut temp_file = NamedTempFile::new_in(parent_dir)?;
190
191        #[cfg(unix)]
192        set_restrictive_permissions(temp_file.path())?;
193
194        std::io::Write::write_all(&mut temp_file, content.as_bytes())?;
195        std::io::Write::flush(&mut temp_file)?;
196
197        let policy = decide_atomic_write_sync(crate::interrupt::user_interrupted_occurred());
198        sync_temp_file(temp_file.as_file(), policy)?;
199
200        temp_file.persist(&path).map_err(|e| e.error)?;
201
202        Ok(())
203    }
204
205    fn set_readonly(&self, relative: &Path) -> std::io::Result<()> {
206        let path = self.root.join(relative);
207        if !path.exists() {
208            return Ok(());
209        }
210
211        let metadata = fs::metadata(&path)?;
212        let mut perms = metadata.permissions();
213
214        #[cfg(unix)]
215        {
216            use std::os::unix::fs::PermissionsExt;
217            perms.set_mode(0o444);
218        }
219
220        #[cfg(windows)]
221        {
222            perms.set_readonly(true);
223        }
224
225        fs::set_permissions(path, perms)
226    }
227
228    fn set_writable(&self, relative: &Path) -> std::io::Result<()> {
229        let path = self.root.join(relative);
230        if !path.exists() {
231            return Ok(());
232        }
233
234        let metadata = fs::metadata(&path)?;
235        let mut perms = metadata.permissions();
236
237        #[cfg(unix)]
238        {
239            use std::os::unix::fs::PermissionsExt;
240            perms.set_mode(0o644);
241        }
242
243        #[cfg(windows)]
244        {
245            perms.set_readonly(false);
246        }
247
248        fs::set_permissions(path, perms)
249    }
250}