Skip to main content

wasm_slim/
infra.rs

1//! Infrastructure traits for abstracting I/O operations.
2//!
3//! This module provides trait abstractions for filesystem and command execution operations,
4//! enabling better testability and adherence to the Dependency Inversion Principle.
5
6use std::fs::{Metadata, ReadDir};
7use std::io;
8use std::path::Path;
9use std::process::{Command, ExitStatus, Output};
10
11/// Trait for abstracting filesystem operations.
12///
13/// This trait allows for dependency injection of filesystem operations,
14/// making code more testable and allowing for alternative implementations
15/// (e.g., in-memory filesystems for testing, cloud storage, etc.).
16pub trait FileSystem {
17    /// Copy a file from one location to another.
18    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64>;
19
20    /// Create a directory and all missing parent directories.
21    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
22
23    /// Read the contents of a directory.
24    fn read_dir(&self, path: &Path) -> io::Result<ReadDir>;
25
26    /// Get metadata for a file or directory.
27    fn metadata(&self, path: &Path) -> io::Result<Metadata>;
28
29    /// Read the entire contents of a file into a string.
30    fn read_to_string(&self, path: &Path) -> io::Result<String>;
31
32    /// Write a slice of bytes to a file.
33    fn write(&self, path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()>;
34}
35
36/// Real filesystem implementation that delegates to std::fs.
37#[derive(Clone, Copy)]
38pub struct RealFileSystem;
39
40impl FileSystem for RealFileSystem {
41    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
42        std::fs::copy(from, to)
43    }
44
45    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
46        std::fs::create_dir_all(path)
47    }
48
49    fn read_dir(&self, path: &Path) -> io::Result<ReadDir> {
50        std::fs::read_dir(path)
51    }
52
53    fn metadata(&self, path: &Path) -> io::Result<Metadata> {
54        std::fs::metadata(path)
55    }
56
57    fn read_to_string(&self, path: &Path) -> io::Result<String> {
58        std::fs::read_to_string(path)
59    }
60
61    fn write(&self, path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
62        std::fs::write(path, contents)
63    }
64}
65
66/// Trait for abstracting command execution.
67///
68/// This trait allows for dependency injection of command execution operations,
69/// enabling testing without running real commands and allowing for alternative
70/// implementations (e.g., mocked execution, remote execution, etc.).
71pub trait CommandExecutor {
72    /// Execute a command and return its exit status.
73    /// This is the primary method used by the pipeline for running external tools.
74    fn status(&self, cmd: &mut Command) -> io::Result<ExitStatus>;
75
76    /// Execute a command and return its output (stdout, stderr, status).
77    /// Useful for commands where we need to capture output.
78    fn output(&self, cmd: &mut Command) -> io::Result<Output>;
79
80    /// Execute a command built with a closure and return its output.
81    ///
82    /// This provides a more ergonomic API for building and executing commands:
83    ///
84    /// # Examples
85    ///
86    /// ```no_run
87    /// use wasm_slim::infra::{CommandExecutor, RealCommandExecutor};
88    /// use std::process::Command;
89    ///
90    /// let executor = RealCommandExecutor;
91    /// let output = executor.execute(|cmd| {
92    ///     cmd.arg("--version")
93    ///        .env("RUST_LOG", "debug")
94    /// }, "cargo")?;
95    /// # Ok::<(), std::io::Error>(())
96    /// ```
97    fn execute<F>(&self, builder: F, program: &str) -> io::Result<Output>
98    where
99        F: FnOnce(&mut Command) -> &mut Command,
100    {
101        let mut cmd = Command::new(program);
102        builder(&mut cmd);
103        self.output(&mut cmd)
104    }
105
106    /// Execute a command built with a closure and return its exit status.
107    ///
108    /// Similar to `execute()` but only returns the exit status without capturing output.
109    ///
110    /// # Examples
111    ///
112    /// ```no_run
113    /// use wasm_slim::infra::{CommandExecutor, RealCommandExecutor};
114    /// use std::process::Command;
115    ///
116    /// let executor = RealCommandExecutor;
117    /// let status = executor.run(|cmd| {
118    ///     cmd.arg("build")
119    ///        .arg("--release")
120    /// }, "cargo")?;
121    /// # Ok::<(), std::io::Error>(())
122    /// ```
123    fn run<F>(&self, builder: F, program: &str) -> io::Result<ExitStatus>
124    where
125        F: FnOnce(&mut Command) -> &mut Command,
126    {
127        let mut cmd = Command::new(program);
128        builder(&mut cmd);
129        self.status(&mut cmd)
130    }
131}
132
133/// Real command executor that delegates to std::process::Command.
134#[derive(Debug, Clone, Copy)]
135pub struct RealCommandExecutor;
136
137impl CommandExecutor for RealCommandExecutor {
138    fn status(&self, cmd: &mut Command) -> io::Result<ExitStatus> {
139        cmd.status()
140    }
141
142    fn output(&self, cmd: &mut Command) -> io::Result<Output> {
143        cmd.output()
144    }
145}
146
147/// Create an ExitStatus with the given exit code for use in test mocks.
148///
149/// This avoids spawning actual processes (like `Command::new("true")`) in tests.
150#[cfg(all(test, unix))]
151pub fn mock_exit_status(code: i32) -> ExitStatus {
152    use std::os::unix::process::ExitStatusExt;
153    ExitStatus::from_raw(code << 8) // Unix stores exit code in upper bits
154}
155
156#[cfg(all(test, windows))]
157pub fn mock_exit_status(code: i32) -> ExitStatus {
158    use std::os::windows::process::ExitStatusExt;
159    ExitStatus::from_raw(code as u32)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    use tempfile::TempDir;
167
168    // FileSystem tests
169
170    #[test]
171    fn test_real_filesystem_write_and_read() {
172        let temp_dir = TempDir::new().unwrap();
173        let file_path = temp_dir.path().join("test.txt");
174
175        let fs = RealFileSystem;
176
177        // Write content
178        let content = b"Hello, World!";
179        fs.write(&file_path, content).unwrap();
180
181        // Read content back
182        let read_content = fs.read_to_string(&file_path).unwrap();
183        assert_eq!(read_content, "Hello, World!");
184    }
185
186    #[test]
187    fn test_real_filesystem_copy() {
188        let temp_dir = TempDir::new().unwrap();
189        let source = temp_dir.path().join("source.txt");
190        let dest = temp_dir.path().join("dest.txt");
191
192        let fs = RealFileSystem;
193
194        // Create source file
195        fs.write(&source, b"test content").unwrap();
196
197        // Copy file
198        let bytes_copied = fs.copy(&source, &dest).unwrap();
199        assert_eq!(bytes_copied, 12); // "test content" is 12 bytes
200
201        // Verify destination exists and has same content
202        let dest_content = fs.read_to_string(&dest).unwrap();
203        assert_eq!(dest_content, "test content");
204    }
205
206    #[test]
207    fn test_real_filesystem_create_dir_all() {
208        let temp_dir = TempDir::new().unwrap();
209        let nested_path = temp_dir.path().join("a").join("b").join("c");
210
211        let fs = RealFileSystem;
212
213        // Create nested directories
214        fs.create_dir_all(&nested_path).unwrap();
215
216        // Verify they exist
217        assert!(nested_path.exists());
218        assert!(nested_path.is_dir());
219    }
220
221    #[test]
222    fn test_real_filesystem_metadata() {
223        let temp_dir = TempDir::new().unwrap();
224        let file_path = temp_dir.path().join("test.txt");
225
226        let fs = RealFileSystem;
227
228        // Create a file
229        fs.write(&file_path, b"content").unwrap();
230
231        // Get metadata
232        let metadata = fs.metadata(&file_path).unwrap();
233        assert!(metadata.is_file());
234        assert_eq!(metadata.len(), 7); // "content" is 7 bytes
235    }
236
237    #[test]
238    fn test_real_filesystem_read_dir() {
239        let temp_dir = TempDir::new().unwrap();
240        let fs = RealFileSystem;
241
242        // Create some files
243        fs.write(&temp_dir.path().join("file1.txt"), b"test1")
244            .unwrap();
245        fs.write(&temp_dir.path().join("file2.txt"), b"test2")
246            .unwrap();
247        fs.write(&temp_dir.path().join("file3.txt"), b"test3")
248            .unwrap();
249
250        // Read directory
251        let entries: Vec<_> = fs
252            .read_dir(temp_dir.path())
253            .unwrap()
254            .collect::<Result<Vec<_>, _>>()
255            .unwrap();
256
257        assert_eq!(entries.len(), 3);
258    }
259
260    #[test]
261    fn test_real_filesystem_read_nonexistent_file_returns_error() {
262        let fs = RealFileSystem;
263        let result = fs.read_to_string(Path::new("/nonexistent/file.txt"));
264        assert!(result.is_err());
265    }
266
267    #[test]
268    fn test_real_filesystem_copy_nonexistent_file_returns_error() {
269        let temp_dir = TempDir::new().unwrap();
270        let fs = RealFileSystem;
271
272        let result = fs.copy(
273            Path::new("/nonexistent.txt"),
274            &temp_dir.path().join("dest.txt"),
275        );
276        assert!(result.is_err());
277    }
278
279    // CommandExecutor tests
280
281    #[test]
282    fn test_real_command_executor_status_success() {
283        let executor = RealCommandExecutor;
284        let mut cmd = Command::new("echo");
285        cmd.arg("test");
286
287        let status = executor.status(&mut cmd).unwrap();
288        assert!(status.success());
289    }
290
291    #[test]
292    fn test_real_command_executor_output_captures_stdout() {
293        let executor = RealCommandExecutor;
294        let mut cmd = Command::new("echo");
295        cmd.arg("hello");
296
297        let output = executor.output(&mut cmd).unwrap();
298        assert!(output.status.success());
299
300        let stdout = String::from_utf8_lossy(&output.stdout);
301        assert!(stdout.contains("hello"));
302    }
303
304    #[test]
305    fn test_real_command_executor_execute_with_builder() {
306        let executor = RealCommandExecutor;
307
308        let output = executor
309            .execute(|cmd| cmd.arg("test_output"), "echo")
310            .unwrap();
311
312        assert!(output.status.success());
313        let stdout = String::from_utf8_lossy(&output.stdout);
314        assert!(stdout.contains("test_output"));
315    }
316
317    #[test]
318    fn test_real_command_executor_run_with_builder() {
319        let executor = RealCommandExecutor;
320
321        let status = executor.run(|cmd| cmd.arg("test_arg"), "echo").unwrap();
322
323        assert!(status.success());
324    }
325
326    #[test]
327    fn test_real_command_executor_nonexistent_command_returns_error() {
328        let executor = RealCommandExecutor;
329        let mut cmd = Command::new("nonexistent_command_xyz_123");
330
331        let result = executor.output(&mut cmd);
332        assert!(result.is_err());
333    }
334
335    #[test]
336    fn test_real_command_executor_failed_command_returns_non_success() {
337        let executor = RealCommandExecutor;
338        // Run a command that will fail (cat with nonexistent file)
339        let mut cmd = Command::new("cat");
340        cmd.arg("/nonexistent/file/that/does/not/exist.txt");
341
342        let output = executor.output(&mut cmd).unwrap();
343        assert!(!output.status.success());
344    }
345
346    #[test]
347    fn test_real_filesystem_clone() {
348        let fs1 = RealFileSystem;
349        let fs2 = fs1;
350
351        // Both should work independently
352        let temp_dir = TempDir::new().unwrap();
353        let path = temp_dir.path().join("test.txt");
354
355        fs1.write(&path, b"test1").unwrap();
356        let content = fs2.read_to_string(&path).unwrap();
357        assert_eq!(content, "test1");
358    }
359
360    #[test]
361    fn test_real_command_executor_clone() {
362        let exec1 = RealCommandExecutor;
363        let exec2 = exec1;
364
365        // Both should work independently
366        let mut cmd = Command::new("echo");
367        cmd.arg("test");
368
369        let status1 = exec1.status(&mut cmd).unwrap();
370        assert!(status1.success());
371
372        let mut cmd2 = Command::new("echo");
373        cmd2.arg("test");
374        let status2 = exec2.status(&mut cmd2).unwrap();
375        assert!(status2.success());
376    }
377}