fob_graph/runtime/
native.rs

1//! Native Runtime Implementation
2//!
3//! This module provides a `Runtime` trait implementation for native (non-WASM)
4//! environments where standard filesystem operations are available.
5//!
6//! ## Why This Exists
7//!
8//! While `std::fs` works perfectly on native platforms, we wrap it in the
9//! Runtime trait to provide a consistent interface across all platforms.
10//! This allows the bundler core to be platform-agnostic.
11//!
12//! ## Architecture
13//!
14//! ```text
15//! Rust (Native)
16//! ┌─────────────────┐
17//! │ NativeRuntime   │
18//! │  .read_file()   │────▶ std::fs::read()
19//! │  .write_file()  │────▶ std::fs::write()
20//! │  .exists()      │────▶ std::path::Path::exists()
21//! └─────────────────┘
22//!          │
23//!          ▼
24//!   ┌──────────────┐
25//!   │ OS Filesystem│
26//!   └──────────────┘
27//! ```
28
29// NativeRuntime is platform-specific and wraps std::fs by design
30#![allow(clippy::disallowed_methods)]
31
32use async_trait::async_trait;
33use std::path::{Path, PathBuf};
34use tokio::task;
35
36use crate::runtime::{FileMetadata, Runtime, RuntimeError, RuntimeResult};
37
38/// Native filesystem Runtime implementation using `std::fs`.
39///
40/// This implementation provides async wrappers around synchronous `std::fs`
41/// operations using tokio's spawn_blocking to avoid blocking the async runtime.
42///
43/// # Educational Note: Async File I/O
44///
45/// Standard library file operations are blocking (they wait for the OS).
46/// To use them in async code without blocking the executor, we run them in
47/// a separate thread pool using `tokio::task::spawn_blocking`.
48///
49/// # Example
50///
51/// ```rust,ignore
52/// use crate::runtime::native::NativeRuntime;
53/// use crate::runtime::Runtime;
54///
55/// let runtime = NativeRuntime;
56/// let content = runtime.read_file(Path::new("file.txt")).await?;
57/// ```
58#[derive(Debug, Clone, Copy)]
59pub struct NativeRuntime;
60
61impl NativeRuntime {
62    /// Create a new NativeRuntime instance.
63    pub fn new() -> Self {
64        Self
65    }
66}
67
68impl Default for NativeRuntime {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74#[async_trait]
75impl Runtime for NativeRuntime {
76    /// Read a file from the native filesystem.
77    ///
78    /// # Educational Note: spawn_blocking
79    ///
80    /// `std::fs::read` is a blocking operation that waits for disk I/O.
81    /// We use `spawn_blocking` to run it in a dedicated thread pool,
82    /// preventing it from blocking async tasks.
83    async fn read_file(&self, path: &Path) -> RuntimeResult<Vec<u8>> {
84        let path = path.to_path_buf();
85
86        task::spawn_blocking(move || {
87            std::fs::read(&path).map_err(|e| {
88                if e.kind() == std::io::ErrorKind::NotFound {
89                    RuntimeError::FileNotFound(path.clone())
90                } else {
91                    RuntimeError::Io(format!("Failed to read {}: {}", path.display(), e))
92                }
93            })
94        })
95        .await
96        .map_err(|e| RuntimeError::Other(format!("Task join error: {}", e)))?
97    }
98
99    /// Write a file to the native filesystem.
100    async fn write_file(&self, path: &Path, content: &[u8]) -> RuntimeResult<()> {
101        let path = path.to_path_buf();
102        let content = content.to_vec();
103
104        task::spawn_blocking(move || {
105            std::fs::write(&path, content)
106                .map_err(|e| RuntimeError::Io(format!("Failed to write {}: {}", path.display(), e)))
107        })
108        .await
109        .map_err(|e| RuntimeError::Other(format!("Task join error: {}", e)))?
110    }
111
112    /// Get file metadata from the native filesystem.
113    async fn metadata(&self, path: &Path) -> RuntimeResult<FileMetadata> {
114        let path = path.to_path_buf();
115
116        task::spawn_blocking(move || {
117            let metadata = std::fs::metadata(&path).map_err(|e| {
118                if e.kind() == std::io::ErrorKind::NotFound {
119                    RuntimeError::FileNotFound(path.clone())
120                } else {
121                    RuntimeError::Io(format!(
122                        "Failed to get metadata for {}: {}",
123                        path.display(),
124                        e
125                    ))
126                }
127            })?;
128
129            // Get modification time (platform-specific)
130            let modified = metadata
131                .modified()
132                .ok()
133                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
134                .map(|d| d.as_millis() as u64);
135
136            Ok(FileMetadata {
137                size: metadata.len(),
138                is_dir: metadata.is_dir(),
139                is_file: metadata.is_file(),
140                modified,
141            })
142        })
143        .await
144        .map_err(|e| RuntimeError::Other(format!("Task join error: {}", e)))?
145    }
146
147    /// Check if a path exists on the native filesystem.
148    ///
149    /// # Educational Note: Synchronous Methods
150    ///
151    /// This method is synchronous in the trait (no async), so we can call
152    /// `std::path::Path::exists()` directly without spawn_blocking.
153    /// It's a quick metadata check that won't block significantly.
154    fn exists(&self, path: &Path) -> bool {
155        path.exists()
156    }
157
158    /// Resolve a module specifier on the native filesystem.
159    ///
160    /// # Educational Note: Path Resolution
161    ///
162    /// This handles relative and absolute path resolution.
163    /// For bare specifiers (like "lodash"), the bundler's node resolution
164    /// logic will handle them separately.
165    fn resolve(&self, specifier: &str, from: &Path) -> RuntimeResult<PathBuf> {
166        // Handle absolute paths
167        if Path::new(specifier).is_absolute() {
168            return Ok(PathBuf::from(specifier));
169        }
170
171        // Handle relative paths
172        if specifier.starts_with("./") || specifier.starts_with("../") {
173            let from_dir = from.parent().unwrap_or(Path::new(""));
174            let resolved = from_dir.join(specifier);
175
176            // Canonicalize to resolve .. and symlinks
177            return resolved
178                .canonicalize()
179                .map_err(|e| RuntimeError::ResolutionFailed {
180                    specifier: specifier.to_string(),
181                    from: from.to_path_buf(),
182                    reason: format!("Canonicalization failed: {}", e),
183                });
184        }
185
186        // For bare specifiers, return as-is
187        // The bundler's node resolution will handle these
188        Ok(PathBuf::from(specifier))
189    }
190
191    /// Create a directory on the native filesystem.
192    async fn create_dir(&self, path: &Path, recursive: bool) -> RuntimeResult<()> {
193        let path = path.to_path_buf();
194
195        task::spawn_blocking(move || {
196            let result = if recursive {
197                std::fs::create_dir_all(&path)
198            } else {
199                std::fs::create_dir(&path)
200            };
201
202            result.map_err(|e| {
203                RuntimeError::Io(format!(
204                    "Failed to create directory {}: {}",
205                    path.display(),
206                    e
207                ))
208            })
209        })
210        .await
211        .map_err(|e| RuntimeError::Other(format!("Task join error: {}", e)))?
212    }
213
214    /// Remove a file from the native filesystem.
215    async fn remove_file(&self, path: &Path) -> RuntimeResult<()> {
216        let path = path.to_path_buf();
217
218        task::spawn_blocking(move || {
219            std::fs::remove_file(&path).map_err(|e| {
220                if e.kind() == std::io::ErrorKind::NotFound {
221                    RuntimeError::FileNotFound(path.clone())
222                } else {
223                    RuntimeError::Io(format!("Failed to remove {}: {}", path.display(), e))
224                }
225            })
226        })
227        .await
228        .map_err(|e| RuntimeError::Other(format!("Task join error: {}", e)))?
229    }
230
231    /// Read directory contents from the native filesystem.
232    async fn read_dir(&self, path: &Path) -> RuntimeResult<Vec<String>> {
233        let path = path.to_path_buf();
234
235        task::spawn_blocking(move || {
236            let entries = std::fs::read_dir(&path).map_err(|e| {
237                if e.kind() == std::io::ErrorKind::NotFound {
238                    RuntimeError::FileNotFound(path.clone())
239                } else {
240                    RuntimeError::Io(format!(
241                        "Failed to read directory {}: {}",
242                        path.display(),
243                        e
244                    ))
245                }
246            })?;
247
248            let mut result = Vec::new();
249            for entry in entries {
250                let entry = entry.map_err(|e| {
251                    RuntimeError::Io(format!("Failed to read directory entry: {}", e))
252                })?;
253
254                if let Some(name) = entry.file_name().to_str() {
255                    result.push(name.to_string());
256                }
257            }
258
259            Ok(result)
260        })
261        .await
262        .map_err(|e| RuntimeError::Other(format!("Task join error: {}", e)))?
263    }
264
265    /// Get the current working directory from the operating system.
266    ///
267    /// # Educational Note: OS Working Directory
268    ///
269    /// Delegates to `std::env::current_dir()` to get the actual OS-level
270    /// current working directory. This method abstracts the OS call behind
271    /// the Runtime trait, enabling platform-agnostic code.
272    fn get_cwd(&self) -> RuntimeResult<PathBuf> {
273        std::env::current_dir().map_err(|e| {
274            RuntimeError::Io(format!("Failed to get current working directory: {}", e))
275        })
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::fs;
283    use tempfile::TempDir;
284
285    #[tokio::test]
286    async fn test_read_write_file() {
287        let temp_dir = TempDir::new().unwrap();
288        let file_path = temp_dir.path().join("test.txt");
289
290        let runtime = NativeRuntime::new();
291
292        // Write file
293        let content = b"Hello, World!";
294        runtime.write_file(&file_path, content).await.unwrap();
295
296        // Read file
297        let read_content = runtime.read_file(&file_path).await.unwrap();
298        assert_eq!(read_content, content);
299    }
300
301    #[tokio::test]
302    async fn test_metadata() {
303        let temp_dir = TempDir::new().unwrap();
304        let file_path = temp_dir.path().join("test.txt");
305
306        fs::write(&file_path, b"test content").unwrap();
307
308        let runtime = NativeRuntime::new();
309        let metadata = runtime.metadata(&file_path).await.unwrap();
310
311        assert!(metadata.is_file);
312        assert!(!metadata.is_dir);
313        assert_eq!(metadata.size, 12); // "test content" is 12 bytes
314    }
315
316    #[tokio::test]
317    async fn test_exists() {
318        let temp_dir = TempDir::new().unwrap();
319        let file_path = temp_dir.path().join("test.txt");
320
321        let runtime = NativeRuntime::new();
322
323        // Should not exist initially
324        assert!(!runtime.exists(&file_path));
325
326        // Create file
327        fs::write(&file_path, b"test").unwrap();
328
329        // Should exist now
330        assert!(runtime.exists(&file_path));
331    }
332
333    #[tokio::test]
334    async fn test_read_dir() {
335        let temp_dir = TempDir::new().unwrap();
336        fs::write(temp_dir.path().join("file1.txt"), b"test1").unwrap();
337        fs::write(temp_dir.path().join("file2.txt"), b"test2").unwrap();
338
339        let runtime = NativeRuntime::new();
340        let mut entries = runtime.read_dir(temp_dir.path()).await.unwrap();
341        entries.sort();
342
343        assert_eq!(entries, vec!["file1.txt", "file2.txt"]);
344    }
345
346    #[tokio::test]
347    async fn test_create_dir() {
348        let temp_dir = TempDir::new().unwrap();
349        let dir_path = temp_dir.path().join("subdir");
350
351        let runtime = NativeRuntime::new();
352        runtime.create_dir(&dir_path, false).await.unwrap();
353
354        assert!(dir_path.exists());
355        assert!(dir_path.is_dir());
356    }
357
358    #[tokio::test]
359    async fn test_create_dir_recursive() {
360        let temp_dir = TempDir::new().unwrap();
361        let nested_path = temp_dir.path().join("a").join("b").join("c");
362
363        let runtime = NativeRuntime::new();
364        runtime.create_dir(&nested_path, true).await.unwrap();
365
366        assert!(nested_path.exists());
367        assert!(nested_path.is_dir());
368    }
369}