Skip to main content

bashkit/fs/
traits.rs

1//! Filesystem trait definitions.
2//!
3//! # Overview
4//!
5//! This module defines [`FileSystem`], the high-level trait that enforces
6//! POSIX-like semantics. For implementing custom storage backends, see also
7//! [`FsBackend`](super::FsBackend) which provides a simpler contract.
8//!
9//! # POSIX Semantics Contract
10//!
11//! All [`FileSystem`] implementations MUST enforce these POSIX-like semantics:
12//!
13//! 1. **No duplicate names**: A file and directory cannot share the same path.
14//!    The filesystem entry type (file/directory/symlink) is determined by
15//!    whichever was created first.
16//!
17//! 2. **Type-safe writes**: [`FileSystem::write_file`] and [`FileSystem::append_file`]
18//!    MUST fail with "is a directory" error when the path is a directory.
19//!
20//! 3. **Type-safe mkdir**: [`FileSystem::mkdir`] MUST fail with "already exists"
21//!    when the path exists (file, directory, or symlink), unless `recursive=true`
22//!    and the existing entry is a directory.
23//!
24//! 4. **Parent directory requirement**: Write operations require parent directory
25//!    to exist (except with `recursive=true` for mkdir).
26//!
27//! # Implementing Custom Filesystems
28//!
29//! **Recommended**: Implement [`FsBackend`](super::FsBackend) and wrap with
30//! [`PosixFs`](super::PosixFs) to get POSIX semantics automatically.
31//!
32//! **Alternative**: Implement `FileSystem` directly using [`fs_errors`] helpers:
33//!
34//! ```rust,ignore
35//! use bashkit::fs::fs_errors;
36//!
37//! // In your write_file implementation:
38//! if path_is_directory {
39//!     return Err(fs_errors::is_a_directory());
40//! }
41//! ```
42
43use async_trait::async_trait;
44use std::io::{Error as IoError, ErrorKind};
45use std::path::Path;
46use std::time::SystemTime;
47
48use super::limits::{FsLimits, FsUsage};
49use crate::error::Result;
50
51/// Standard filesystem errors for consistent error messages across implementations.
52///
53/// Use these helpers when implementing [`FileSystem`] to ensure consistent
54/// error messages that match POSIX conventions.
55#[allow(dead_code)]
56pub mod fs_errors {
57    use super::*;
58
59    /// Error for attempting to write to a directory.
60    ///
61    /// Use when `write_file` or `append_file` is called on a directory path.
62    #[inline]
63    pub fn is_a_directory() -> crate::Error {
64        IoError::other("is a directory").into()
65    }
66
67    /// Error for path already existing (for mkdir without recursive).
68    ///
69    /// Use when `mkdir` is called on a path that already exists.
70    #[inline]
71    pub fn already_exists(msg: &str) -> crate::Error {
72        IoError::new(ErrorKind::AlreadyExists, msg).into()
73    }
74
75    /// Error for missing parent directory.
76    ///
77    /// Use when write operation is attempted but parent directory doesn't exist.
78    #[inline]
79    pub fn parent_not_found() -> crate::Error {
80        IoError::new(ErrorKind::NotFound, "parent directory not found").into()
81    }
82
83    /// Error for file or directory not found.
84    #[inline]
85    pub fn not_found(msg: &str) -> crate::Error {
86        IoError::new(ErrorKind::NotFound, msg).into()
87    }
88
89    /// Error for attempting directory operation on a file.
90    ///
91    /// Use when `read_dir` is called on a file path.
92    #[inline]
93    pub fn not_a_directory() -> crate::Error {
94        IoError::other("not a directory").into()
95    }
96
97    /// Error for non-empty directory removal without recursive flag.
98    #[inline]
99    pub fn directory_not_empty() -> crate::Error {
100        IoError::other("directory not empty").into()
101    }
102}
103
104/// Optional filesystem extensions for resource tracking and special file types.
105///
106/// This trait provides methods that most custom filesystem implementations do not
107/// need to override. All methods have sensible defaults:
108///
109/// - [`usage()`](FileSystemExt::usage) returns zero usage
110/// - [`limits()`](FileSystemExt::limits) returns unlimited
111/// - [`mkfifo()`](FileSystemExt::mkfifo) returns "not supported"
112///
113/// Built-in implementations (`InMemoryFs`, `OverlayFs`, `MountableFs`) override
114/// these to provide real statistics. Custom backends can opt in by implementing
115/// just the methods they need.
116///
117/// `FileSystemExt` is a supertrait of [`FileSystem`], so its methods are
118/// available on any `dyn FileSystem` trait object.
119#[async_trait]
120pub trait FileSystemExt: Send + Sync {
121    /// Get current filesystem usage statistics.
122    ///
123    /// Returns total bytes used, file count, and directory count.
124    /// Used by `du` and `df` builtins.
125    ///
126    /// # Default Implementation
127    ///
128    /// Returns zeros. Implementations should override for accurate stats.
129    fn usage(&self) -> FsUsage {
130        FsUsage::default()
131    }
132
133    /// Create a named pipe (FIFO) at the given path.
134    ///
135    /// FIFOs are simulated as buffered files in the virtual filesystem.
136    /// Reading from a FIFO returns its buffered content, writing appends to it.
137    ///
138    /// # Default Implementation
139    ///
140    /// Returns "not supported" error. Override in implementations that support FIFOs.
141    async fn mkfifo(&self, _path: &Path, _mode: u32) -> Result<()> {
142        Err(std::io::Error::other("mkfifo not supported").into())
143    }
144
145    /// Get filesystem limits.
146    ///
147    /// Returns the configured limits for this filesystem.
148    /// Used by `df` builtin to show available space.
149    ///
150    /// # Default Implementation
151    ///
152    /// Returns unlimited limits.
153    fn limits(&self) -> FsLimits {
154        FsLimits::unlimited()
155    }
156}
157
158/// Async virtual filesystem trait.
159///
160/// This trait defines the core interface for all filesystem implementations in
161/// Bashkit. It contains only the essential POSIX-like operations. Optional
162/// methods for resource tracking and special file types live in
163/// [`FileSystemExt`], which is a supertrait — so all `FileSystem` implementors
164/// must also implement `FileSystemExt` (usually just `impl FileSystemExt for T {}`
165/// to accept the defaults).
166///
167/// # Thread Safety
168///
169/// All implementations must be `Send + Sync` to support concurrent access from
170/// multiple tasks. Use interior mutability patterns (e.g., `RwLock`, `Mutex`)
171/// for mutable state.
172///
173/// # Implementing FileSystem
174///
175/// To create a custom filesystem, implement all methods in this trait and
176/// add an empty `FileSystemExt` impl (or override specific extension methods).
177/// See `examples/custom_filesystem_impl.rs` for a complete implementation.
178///
179/// ```rust,ignore
180/// use bashkit::{async_trait, FileSystem, FileSystemExt, Result};
181///
182/// pub struct MyFileSystem { /* ... */ }
183///
184/// #[async_trait]
185/// impl FileSystemExt for MyFileSystem {}
186///
187/// #[async_trait]
188/// impl FileSystem for MyFileSystem {
189///     async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
190///         // Your implementation
191///     }
192///     // ... implement all other methods
193/// }
194/// ```
195///
196/// # Using Custom Filesystems
197///
198/// Pass your filesystem to [`Bash::builder()`](crate::Bash::builder):
199///
200/// ```rust,ignore
201/// use bashkit::Bash;
202/// use std::sync::Arc;
203///
204/// let custom_fs = Arc::new(MyFileSystem::new());
205/// let mut bash = Bash::builder().fs(custom_fs).build();
206/// ```
207///
208/// # Built-in Implementations
209///
210/// Bashkit provides three implementations:
211///
212/// - [`InMemoryFs`](crate::InMemoryFs) - HashMap-based in-memory storage
213/// - [`OverlayFs`](crate::OverlayFs) - Copy-on-write layered filesystem
214/// - [`MountableFs`](crate::MountableFs) - Multiple mount points
215#[async_trait]
216pub trait FileSystem: FileSystemExt {
217    /// Read a file's contents as bytes.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if:
222    /// - The file does not exist (`NotFound`)
223    /// - The path is a directory
224    /// - I/O error occurs
225    async fn read_file(&self, path: &Path) -> Result<Vec<u8>>;
226
227    /// Write contents to a file, creating it if necessary.
228    ///
229    /// If the file exists, its contents are replaced. If it doesn't exist,
230    /// a new file is created (parent directory must exist).
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if:
235    /// - The parent directory does not exist
236    /// - The path is a directory
237    /// - I/O error occurs
238    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()>;
239
240    /// Append contents to a file, creating it if necessary.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if:
245    /// - The path is a directory
246    /// - I/O error occurs
247    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()>;
248
249    /// Create a directory.
250    ///
251    /// # Arguments
252    ///
253    /// * `path` - The directory path to create
254    /// * `recursive` - If true, create parent directories as needed (like `mkdir -p`)
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if:
259    /// - `recursive` is false and parent directory doesn't exist
260    /// - Path already exists as a file or symlink (always fails)
261    /// - Path already exists as a directory (fails unless `recursive=true`)
262    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()>;
263
264    /// Remove a file or directory.
265    ///
266    /// # Arguments
267    ///
268    /// * `path` - The path to remove
269    /// * `recursive` - If true and path is a directory, remove all contents (like `rm -r`)
270    ///
271    /// # Errors
272    ///
273    /// Returns an error if:
274    /// - The path does not exist
275    /// - Path is a non-empty directory and `recursive` is false
276    async fn remove(&self, path: &Path, recursive: bool) -> Result<()>;
277
278    /// Get file or directory metadata.
279    ///
280    /// Returns information about the file including type, size, permissions,
281    /// and timestamps.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if the path does not exist.
286    async fn stat(&self, path: &Path) -> Result<Metadata>;
287
288    /// List directory contents.
289    ///
290    /// Returns a list of entries (files, directories, symlinks) in the directory.
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if:
295    /// - The path does not exist
296    /// - The path is not a directory
297    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>>;
298
299    /// Check if a path exists.
300    ///
301    /// Returns `true` if the path exists (file, directory, or symlink).
302    async fn exists(&self, path: &Path) -> Result<bool>;
303
304    /// Rename or move a file or directory.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if:
309    /// - The source path does not exist
310    /// - The destination parent directory does not exist
311    async fn rename(&self, from: &Path, to: &Path) -> Result<()>;
312
313    /// Copy a file.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if:
318    /// - The source file does not exist
319    /// - The source is a directory
320    async fn copy(&self, from: &Path, to: &Path) -> Result<()>;
321
322    /// Create a symbolic link.
323    ///
324    /// Creates a symlink at `link` that points to `target`.
325    ///
326    /// # Arguments
327    ///
328    /// * `target` - The path the symlink will point to
329    /// * `link` - The path where the symlink will be created
330    async fn symlink(&self, target: &Path, link: &Path) -> Result<()>;
331
332    /// Read a symbolic link's target.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if:
337    /// - The path does not exist
338    /// - The path is not a symlink
339    async fn read_link(&self, path: &Path) -> Result<std::path::PathBuf>;
340
341    /// Change file permissions.
342    ///
343    /// # Arguments
344    ///
345    /// * `path` - The file path
346    /// * `mode` - Unix permission mode (e.g., `0o644`, `0o755`)
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if the path does not exist.
351    async fn chmod(&self, path: &Path, mode: u32) -> Result<()>;
352
353    /// Returns a reference to this filesystem as a [`SearchCapable`](super::SearchCapable)
354    /// implementation, if supported.
355    ///
356    /// Builtins like `grep` call this to check for optimized search support.
357    /// Returns `None` by default — override in implementations that provide
358    /// indexed search.
359    fn as_search_capable(&self) -> Option<&dyn super::SearchCapable> {
360        None
361    }
362}
363
364/// File or directory metadata.
365///
366/// Returned by [`FileSystem::stat()`] and included in [`DirEntry`].
367///
368/// # Example
369///
370/// ```rust
371/// use bashkit::{Bash, FileSystem, FileType};
372/// use std::path::Path;
373///
374/// # #[tokio::main]
375/// # async fn main() -> bashkit::Result<()> {
376/// let bash = Bash::new();
377/// let fs = bash.fs();
378///
379/// fs.write_file(Path::new("/tmp/test.txt"), b"hello").await?;
380///
381/// let stat = fs.stat(Path::new("/tmp/test.txt")).await?;
382/// assert!(stat.file_type.is_file());
383/// assert_eq!(stat.size, 5);  // "hello" = 5 bytes
384/// assert_eq!(stat.mode, 0o644);  // Default file permissions
385/// # Ok(())
386/// # }
387/// ```
388#[derive(Debug, Clone)]
389pub struct Metadata {
390    /// The type of this entry (file, directory, or symlink).
391    pub file_type: FileType,
392    /// File size in bytes. For directories, this is typically 0.
393    pub size: u64,
394    /// Unix permission mode (e.g., `0o644` for files, `0o755` for directories).
395    pub mode: u32,
396    /// Last modification time.
397    pub modified: SystemTime,
398    /// Creation time.
399    pub created: SystemTime,
400}
401
402impl Default for Metadata {
403    fn default() -> Self {
404        Self {
405            file_type: FileType::File,
406            size: 0,
407            mode: 0o644,
408            modified: SystemTime::now(),
409            created: SystemTime::now(),
410        }
411    }
412}
413
414/// Type of a filesystem entry.
415///
416/// Used in [`Metadata`] to indicate whether an entry is a file, directory,
417/// or symbolic link.
418#[derive(Debug, Clone, Copy, PartialEq)]
419pub enum FileType {
420    /// Regular file containing data.
421    File,
422    /// Directory that can contain other entries.
423    Directory,
424    /// Symbolic link pointing to another path.
425    Symlink,
426    /// Named pipe (FIFO).
427    Fifo,
428}
429
430impl FileType {
431    /// Returns `true` if this is a regular file.
432    ///
433    /// # Example
434    ///
435    /// ```rust
436    /// use bashkit::FileType;
437    ///
438    /// assert!(FileType::File.is_file());
439    /// assert!(!FileType::Directory.is_file());
440    /// ```
441    pub fn is_file(&self) -> bool {
442        matches!(self, FileType::File)
443    }
444
445    /// Returns `true` if this is a directory.
446    ///
447    /// # Example
448    ///
449    /// ```rust
450    /// use bashkit::FileType;
451    ///
452    /// assert!(FileType::Directory.is_dir());
453    /// assert!(!FileType::File.is_dir());
454    /// ```
455    pub fn is_dir(&self) -> bool {
456        matches!(self, FileType::Directory)
457    }
458
459    /// Returns `true` if this is a symbolic link.
460    ///
461    /// # Example
462    ///
463    /// ```rust
464    /// use bashkit::FileType;
465    ///
466    /// assert!(FileType::Symlink.is_symlink());
467    /// assert!(!FileType::File.is_symlink());
468    /// ```
469    pub fn is_symlink(&self) -> bool {
470        matches!(self, FileType::Symlink)
471    }
472
473    /// Returns `true` if this is a named pipe (FIFO).
474    ///
475    /// # Example
476    ///
477    /// ```rust
478    /// use bashkit::FileType;
479    ///
480    /// assert!(FileType::Fifo.is_fifo());
481    /// assert!(!FileType::File.is_fifo());
482    /// ```
483    pub fn is_fifo(&self) -> bool {
484        matches!(self, FileType::Fifo)
485    }
486}
487
488/// An entry in a directory listing.
489///
490/// Returned by [`FileSystem::read_dir()`]. Contains the entry name (not the
491/// full path) and its metadata.
492///
493/// # Example
494///
495/// ```rust
496/// use bashkit::{Bash, FileSystem};
497/// use std::path::Path;
498///
499/// # #[tokio::main]
500/// # async fn main() -> bashkit::Result<()> {
501/// let bash = Bash::new();
502/// let fs = bash.fs();
503///
504/// fs.mkdir(Path::new("/data"), false).await?;
505/// fs.write_file(Path::new("/data/file.txt"), b"content").await?;
506///
507/// let entries = fs.read_dir(Path::new("/data")).await?;
508/// for entry in entries {
509///     println!("Name: {}, Size: {}", entry.name, entry.metadata.size);
510/// }
511/// # Ok(())
512/// # }
513/// ```
514#[derive(Debug, Clone)]
515pub struct DirEntry {
516    /// Entry name (filename only, not the full path).
517    pub name: String,
518    /// Metadata for this entry.
519    pub metadata: Metadata,
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    // --- fs_errors ---
527
528    #[test]
529    fn fs_error_is_a_directory_message() {
530        let err = fs_errors::is_a_directory();
531        let msg = format!("{err}");
532        assert!(msg.contains("is a directory"), "got: {msg}");
533    }
534
535    #[test]
536    fn fs_error_already_exists_message() {
537        let err = fs_errors::already_exists("path /tmp exists");
538        let msg = format!("{err}");
539        assert!(msg.contains("path /tmp exists"), "got: {msg}");
540    }
541
542    #[test]
543    fn fs_error_parent_not_found_message() {
544        let err = fs_errors::parent_not_found();
545        let msg = format!("{err}");
546        assert!(msg.contains("parent directory not found"), "got: {msg}");
547    }
548
549    #[test]
550    fn fs_error_not_found_message() {
551        let err = fs_errors::not_found("no such file");
552        let msg = format!("{err}");
553        assert!(msg.contains("no such file"), "got: {msg}");
554    }
555
556    #[test]
557    fn fs_error_not_a_directory_message() {
558        let err = fs_errors::not_a_directory();
559        let msg = format!("{err}");
560        assert!(msg.contains("not a directory"), "got: {msg}");
561    }
562
563    #[test]
564    fn fs_error_directory_not_empty_message() {
565        let err = fs_errors::directory_not_empty();
566        let msg = format!("{err}");
567        assert!(msg.contains("directory not empty"), "got: {msg}");
568    }
569
570    // --- FileType ---
571
572    #[test]
573    fn file_type_is_file() {
574        assert!(FileType::File.is_file());
575        assert!(!FileType::Directory.is_file());
576        assert!(!FileType::Symlink.is_file());
577    }
578
579    #[test]
580    fn file_type_is_dir() {
581        assert!(FileType::Directory.is_dir());
582        assert!(!FileType::File.is_dir());
583        assert!(!FileType::Symlink.is_dir());
584    }
585
586    #[test]
587    fn file_type_is_symlink() {
588        assert!(FileType::Symlink.is_symlink());
589        assert!(!FileType::File.is_symlink());
590        assert!(!FileType::Directory.is_symlink());
591    }
592
593    #[test]
594    fn file_type_equality() {
595        assert_eq!(FileType::File, FileType::File);
596        assert_eq!(FileType::Directory, FileType::Directory);
597        assert_eq!(FileType::Symlink, FileType::Symlink);
598        assert_ne!(FileType::File, FileType::Directory);
599        assert_ne!(FileType::File, FileType::Symlink);
600        assert_ne!(FileType::Directory, FileType::Symlink);
601    }
602
603    #[test]
604    fn file_type_debug() {
605        let dbg = format!("{:?}", FileType::File);
606        assert_eq!(dbg, "File");
607    }
608
609    // --- Metadata ---
610
611    #[test]
612    fn metadata_default_is_file() {
613        let m = Metadata::default();
614        assert!(m.file_type.is_file());
615        assert_eq!(m.size, 0);
616        assert_eq!(m.mode, 0o644);
617    }
618
619    #[test]
620    fn metadata_custom_fields() {
621        let now = SystemTime::now();
622        let m = Metadata {
623            file_type: FileType::Directory,
624            size: 4096,
625            mode: 0o755,
626            modified: now,
627            created: now,
628        };
629        assert!(m.file_type.is_dir());
630        assert_eq!(m.size, 4096);
631        assert_eq!(m.mode, 0o755);
632    }
633
634    #[test]
635    fn metadata_clone() {
636        let m = Metadata::default();
637        let cloned = m.clone();
638        assert_eq!(cloned.size, m.size);
639        assert_eq!(cloned.mode, m.mode);
640        assert!(cloned.file_type.is_file());
641    }
642
643    // --- DirEntry ---
644
645    #[test]
646    fn dir_entry_construction() {
647        let entry = DirEntry {
648            name: "test.txt".into(),
649            metadata: Metadata::default(),
650        };
651        assert_eq!(entry.name, "test.txt");
652        assert!(entry.metadata.file_type.is_file());
653    }
654
655    #[test]
656    fn dir_entry_with_directory_type() {
657        let now = SystemTime::now();
658        let entry = DirEntry {
659            name: "subdir".into(),
660            metadata: Metadata {
661                file_type: FileType::Directory,
662                size: 0,
663                mode: 0o755,
664                modified: now,
665                created: now,
666            },
667        };
668        assert_eq!(entry.name, "subdir");
669        assert!(entry.metadata.file_type.is_dir());
670    }
671
672    #[test]
673    fn dir_entry_debug() {
674        let entry = DirEntry {
675            name: "f".into(),
676            metadata: Metadata::default(),
677        };
678        let dbg = format!("{:?}", entry);
679        assert!(dbg.contains("DirEntry"));
680        assert!(dbg.contains("\"f\""));
681    }
682
683    // --- FileSystem default methods ---
684
685    #[test]
686    fn filesystem_default_usage_returns_zeros() {
687        // Test via a minimal struct that only implements the defaults
688        struct Dummy;
689
690        #[async_trait]
691        impl FileSystemExt for Dummy {}
692
693        #[async_trait]
694        impl FileSystem for Dummy {
695            async fn read_file(&self, _: &Path) -> crate::error::Result<Vec<u8>> {
696                unimplemented!()
697            }
698            async fn write_file(&self, _: &Path, _: &[u8]) -> crate::error::Result<()> {
699                unimplemented!()
700            }
701            async fn append_file(&self, _: &Path, _: &[u8]) -> crate::error::Result<()> {
702                unimplemented!()
703            }
704            async fn mkdir(&self, _: &Path, _: bool) -> crate::error::Result<()> {
705                unimplemented!()
706            }
707            async fn remove(&self, _: &Path, _: bool) -> crate::error::Result<()> {
708                unimplemented!()
709            }
710            async fn stat(&self, _: &Path) -> crate::error::Result<Metadata> {
711                unimplemented!()
712            }
713            async fn read_dir(&self, _: &Path) -> crate::error::Result<Vec<DirEntry>> {
714                unimplemented!()
715            }
716            async fn exists(&self, _: &Path) -> crate::error::Result<bool> {
717                unimplemented!()
718            }
719            async fn rename(&self, _: &Path, _: &Path) -> crate::error::Result<()> {
720                unimplemented!()
721            }
722            async fn copy(&self, _: &Path, _: &Path) -> crate::error::Result<()> {
723                unimplemented!()
724            }
725            async fn symlink(&self, _: &Path, _: &Path) -> crate::error::Result<()> {
726                unimplemented!()
727            }
728            async fn read_link(&self, _: &Path) -> crate::error::Result<std::path::PathBuf> {
729                unimplemented!()
730            }
731            async fn chmod(&self, _: &Path, _: u32) -> crate::error::Result<()> {
732                unimplemented!()
733            }
734        }
735
736        let d = Dummy;
737        let usage = d.usage();
738        assert_eq!(usage.total_bytes, 0);
739        assert_eq!(usage.file_count, 0);
740        assert_eq!(usage.dir_count, 0);
741
742        let limits = d.limits();
743        assert_eq!(limits.max_total_bytes, u64::MAX);
744    }
745}