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}