Skip to main content

fresh/model/
filesystem.rs

1//! Unified filesystem abstraction for platform-independent file and directory operations
2//!
3//! This module provides a single trait for all filesystem operations, allowing the editor
4//! to work with different backends:
5//! - `StdFileSystem`: Native filesystem using `std::fs`
6//! - `VirtualFileSystem`: In-memory filesystem for WASM/browser (to be implemented)
7//! - Custom implementations for remote agents, network filesystems, etc.
8//!
9//! The trait is synchronous. For async UI operations (like the file explorer),
10//! callers should use `spawn_blocking` or similar patterns.
11
12use std::io::{self, Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16// ============================================================================
17// Directory Entry Types
18// ============================================================================
19
20/// Type of filesystem entry
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum EntryType {
23    File,
24    Directory,
25    Symlink,
26}
27
28/// A directory entry returned by `read_dir`
29#[derive(Debug, Clone)]
30pub struct DirEntry {
31    /// Full path to the entry
32    pub path: PathBuf,
33    /// File/directory name (last component of path)
34    pub name: String,
35    /// Type of entry
36    pub entry_type: EntryType,
37    /// Optional metadata (can be populated lazily)
38    pub metadata: Option<FileMetadata>,
39    /// For symlinks, whether the target is a directory
40    pub symlink_target_is_dir: bool,
41}
42
43impl DirEntry {
44    /// Create a new directory entry
45    pub fn new(path: PathBuf, name: String, entry_type: EntryType) -> Self {
46        Self {
47            path,
48            name,
49            entry_type,
50            metadata: None,
51            symlink_target_is_dir: false,
52        }
53    }
54
55    /// Create a symlink entry with target info
56    pub fn new_symlink(path: PathBuf, name: String, target_is_dir: bool) -> Self {
57        Self {
58            path,
59            name,
60            entry_type: EntryType::Symlink,
61            metadata: None,
62            symlink_target_is_dir: target_is_dir,
63        }
64    }
65
66    /// Add metadata to this entry
67    pub fn with_metadata(mut self, metadata: FileMetadata) -> Self {
68        self.metadata = Some(metadata);
69        self
70    }
71
72    /// Returns true if this entry is a directory OR a symlink pointing to a directory
73    pub fn is_dir(&self) -> bool {
74        self.entry_type == EntryType::Directory
75            || (self.entry_type == EntryType::Symlink && self.symlink_target_is_dir)
76    }
77
78    /// Returns true if this is a regular file (or symlink to file)
79    pub fn is_file(&self) -> bool {
80        self.entry_type == EntryType::File
81            || (self.entry_type == EntryType::Symlink && !self.symlink_target_is_dir)
82    }
83
84    /// Returns true if this is a symlink
85    pub fn is_symlink(&self) -> bool {
86        self.entry_type == EntryType::Symlink
87    }
88}
89
90// ============================================================================
91// Metadata Types
92// ============================================================================
93
94/// Metadata about a file or directory
95#[derive(Debug, Clone)]
96pub struct FileMetadata {
97    /// Size in bytes (0 for directories)
98    pub size: u64,
99    /// Last modification time
100    pub modified: Option<SystemTime>,
101    /// File permissions (opaque, platform-specific)
102    pub permissions: Option<FilePermissions>,
103    /// Whether this is a hidden file (starts with . on Unix, hidden attribute on Windows)
104    pub is_hidden: bool,
105    /// Whether the file is read-only
106    pub is_readonly: bool,
107    /// File owner UID (Unix only)
108    #[cfg(unix)]
109    pub uid: Option<u32>,
110    /// File owner GID (Unix only)
111    #[cfg(unix)]
112    pub gid: Option<u32>,
113}
114
115impl FileMetadata {
116    /// Create minimal metadata with just size
117    pub fn new(size: u64) -> Self {
118        Self {
119            size,
120            modified: None,
121            permissions: None,
122            is_hidden: false,
123            is_readonly: false,
124            #[cfg(unix)]
125            uid: None,
126            #[cfg(unix)]
127            gid: None,
128        }
129    }
130
131    /// Builder: set modified time
132    pub fn with_modified(mut self, modified: SystemTime) -> Self {
133        self.modified = Some(modified);
134        self
135    }
136
137    /// Builder: set hidden flag
138    pub fn with_hidden(mut self, hidden: bool) -> Self {
139        self.is_hidden = hidden;
140        self
141    }
142
143    /// Builder: set readonly flag
144    pub fn with_readonly(mut self, readonly: bool) -> Self {
145        self.is_readonly = readonly;
146        self
147    }
148
149    /// Builder: set permissions
150    pub fn with_permissions(mut self, permissions: FilePermissions) -> Self {
151        self.permissions = Some(permissions);
152        self
153    }
154}
155
156impl Default for FileMetadata {
157    fn default() -> Self {
158        Self::new(0)
159    }
160}
161
162/// Opaque file permissions wrapper
163#[derive(Debug, Clone)]
164pub struct FilePermissions {
165    #[cfg(unix)]
166    mode: u32,
167    #[cfg(not(unix))]
168    readonly: bool,
169}
170
171impl FilePermissions {
172    /// Create from raw Unix mode bits
173    #[cfg(unix)]
174    pub fn from_mode(mode: u32) -> Self {
175        Self { mode }
176    }
177
178    /// Create from raw mode (non-Unix: treated as readonly if no write bits)
179    #[cfg(not(unix))]
180    pub fn from_mode(mode: u32) -> Self {
181        Self {
182            readonly: mode & 0o222 == 0,
183        }
184    }
185
186    /// Create from std::fs::Permissions
187    #[cfg(unix)]
188    pub fn from_std(perms: std::fs::Permissions) -> Self {
189        use std::os::unix::fs::PermissionsExt;
190        Self { mode: perms.mode() }
191    }
192
193    #[cfg(not(unix))]
194    pub fn from_std(perms: std::fs::Permissions) -> Self {
195        Self {
196            readonly: perms.readonly(),
197        }
198    }
199
200    /// Convert to std::fs::Permissions
201    #[cfg(unix)]
202    pub fn to_std(&self) -> std::fs::Permissions {
203        use std::os::unix::fs::PermissionsExt;
204        std::fs::Permissions::from_mode(self.mode)
205    }
206
207    #[cfg(not(unix))]
208    pub fn to_std(&self) -> std::fs::Permissions {
209        let mut perms = std::fs::Permissions::from(std::fs::metadata(".").unwrap().permissions());
210        perms.set_readonly(self.readonly);
211        perms
212    }
213
214    /// Get the Unix mode (if available)
215    #[cfg(unix)]
216    pub fn mode(&self) -> u32 {
217        self.mode
218    }
219
220    /// Check if no write bits are set at all (any user).
221    ///
222    /// NOTE: On Unix, this only checks whether the mode has zero write bits.
223    /// It does NOT check whether the *current user* can write. For that,
224    /// use [`is_readonly_for_user`] with the appropriate uid/gid.
225    pub fn is_readonly(&self) -> bool {
226        #[cfg(unix)]
227        {
228            self.mode & 0o222 == 0
229        }
230        #[cfg(not(unix))]
231        {
232            self.readonly
233        }
234    }
235
236    /// Check if the file is read-only for a specific user identified by
237    /// `user_uid` and a set of group IDs the user belongs to.
238    ///
239    /// On non-Unix platforms, falls back to the simple readonly flag.
240    #[cfg(unix)]
241    pub fn is_readonly_for_user(
242        &self,
243        user_uid: u32,
244        file_uid: u32,
245        file_gid: u32,
246        user_groups: &[u32],
247    ) -> bool {
248        // root can write to anything
249        if user_uid == 0 {
250            return false;
251        }
252        if user_uid == file_uid {
253            return self.mode & 0o200 == 0;
254        }
255        if user_groups.contains(&file_gid) {
256            return self.mode & 0o020 == 0;
257        }
258        self.mode & 0o002 == 0
259    }
260}
261
262// ============================================================================
263// File Handle Traits
264// ============================================================================
265
266/// A writable file handle
267pub trait FileWriter: Write + Send {
268    /// Sync all data to disk
269    fn sync_all(&self) -> io::Result<()>;
270}
271
272// ============================================================================
273// Patch Operations for Efficient Remote Saves
274// ============================================================================
275
276/// An operation in a patched write - either copy from source or insert new data
277#[derive(Debug, Clone)]
278pub enum WriteOp<'a> {
279    /// Copy bytes from the source file at the given offset
280    Copy { offset: u64, len: u64 },
281    /// Insert new data
282    Insert { data: &'a [u8] },
283}
284
285/// Wrapper around std::fs::File that implements FileWriter
286struct StdFileWriter(std::fs::File);
287
288impl Write for StdFileWriter {
289    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
290        self.0.write(buf)
291    }
292
293    fn flush(&mut self) -> io::Result<()> {
294        self.0.flush()
295    }
296}
297
298impl FileWriter for StdFileWriter {
299    fn sync_all(&self) -> io::Result<()> {
300        self.0.sync_all()
301    }
302}
303
304/// A readable and seekable file handle
305pub trait FileReader: Read + Seek + Send {}
306
307/// Wrapper around std::fs::File that implements FileReader
308struct StdFileReader(std::fs::File);
309
310impl Read for StdFileReader {
311    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
312        self.0.read(buf)
313    }
314}
315
316impl Seek for StdFileReader {
317    fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
318        self.0.seek(pos)
319    }
320}
321
322impl FileReader for StdFileReader {}
323
324// ============================================================================
325// File Search Types
326// ============================================================================
327//
328// Used by `FileSystem::search_file` for project-wide search.  The search
329// runs where the data lives: `StdFileSystem` scans locally, `RemoteFileSystem`
330// delegates to the remote agent so only matches cross the network.
331//
332// For searching an already-open buffer's piece tree (in-editor Ctrl+F and
333// dirty buffers in project search), see `TextBuffer::search_scan_*` in
334// `buffer.rs` which uses the same `SearchMatch` type but reads from the
335// in-memory piece tree rather than from disk.
336
337/// Options for searching within a file via `FileSystem::search_file`.
338#[derive(Clone, Debug)]
339pub struct FileSearchOptions {
340    /// If true, treat pattern as a literal string (regex-escape it).
341    pub fixed_string: bool,
342    /// If true, match case-sensitively.
343    pub case_sensitive: bool,
344    /// If true, match whole words only (wrap with `\b`).
345    pub whole_word: bool,
346    /// Maximum number of matches to return per batch.
347    pub max_matches: usize,
348}
349
350/// Cursor for incremental `search_file` calls.  Each call searches one
351/// chunk and advances the cursor; the caller loops until `done`.
352#[derive(Clone, Debug)]
353pub struct FileSearchCursor {
354    /// Byte offset to resume searching from.
355    pub offset: usize,
356    /// 1-based line number at `offset` (tracks newlines across calls).
357    pub running_line: usize,
358    /// Set to true when the entire file has been searched.
359    pub done: bool,
360    /// Optional upper bound — stop searching at this byte offset instead
361    /// of EOF.  Used by hybrid search to restrict `search_file` to a
362    /// specific file range (e.g. an unloaded piece-tree region).
363    pub end_offset: Option<usize>,
364}
365
366impl Default for FileSearchCursor {
367    fn default() -> Self {
368        Self {
369            offset: 0,
370            running_line: 1,
371            done: false,
372            end_offset: None,
373        }
374    }
375}
376
377impl FileSearchCursor {
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    /// Create a cursor bounded to a specific file range.
383    pub fn for_range(offset: usize, end_offset: usize, running_line: usize) -> Self {
384        Self {
385            offset,
386            running_line,
387            done: false,
388            end_offset: Some(end_offset),
389        }
390    }
391}
392
393/// A single search match with position and context.
394///
395/// Shared between `FileSystem::search_file` (project grep on disk) and
396/// `TextBuffer::search_scan_*` (in-editor search on piece tree).
397#[derive(Clone, Debug)]
398pub struct SearchMatch {
399    /// Byte offset of the match in the file/buffer.
400    pub byte_offset: usize,
401    /// Length of the match in bytes.
402    pub length: usize,
403    /// 1-based line number.
404    pub line: usize,
405    /// 1-based byte column within the line.
406    pub column: usize,
407    /// Content of the line containing the match (no trailing newline).
408    pub context: String,
409}
410
411// ============================================================================
412// FileSystem Trait
413// ============================================================================
414
415/// Unified trait for all filesystem operations
416///
417/// This trait provides both file content I/O and directory operations.
418/// Implementations can be:
419/// - `StdFileSystem`: Native filesystem using `std::fs`
420/// - `VirtualFileSystem`: In-memory for WASM/browser
421/// - Custom backends for remote agents, network filesystems, etc.
422///
423/// All methods are synchronous. For async UI operations, use `spawn_blocking`.
424pub trait FileSystem: Send + Sync {
425    // ========================================================================
426    // File Content Operations
427    // ========================================================================
428
429    /// Read entire file into memory
430    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
431
432    /// Read a range of bytes from a file (for lazy loading large files)
433    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>>;
434
435    /// Count `\n` bytes in a file range without returning the data.
436    ///
437    /// Used by the line-feed scanner to count newlines in unloaded chunks.
438    /// Remote filesystem implementations can override this to count on the
439    /// server side, avoiding the transfer of chunk bytes over the network.
440    ///
441    /// The default implementation reads the range and counts locally.
442    fn count_line_feeds_in_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<usize> {
443        let data = self.read_range(path, offset, len)?;
444        Ok(data.iter().filter(|&&b| b == b'\n').count())
445    }
446
447    /// Write data to file atomically (temp file + rename)
448    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()>;
449
450    /// Create a file for writing, returns a writer handle
451    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
452
453    /// Open a file for reading, returns a reader handle
454    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>>;
455
456    /// Open a file for writing in-place (truncating, preserves ownership on Unix)
457    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
458
459    /// Open a file for appending (creates if doesn't exist)
460    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
461
462    /// Set file length (truncate or extend with zeros)
463    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()>;
464
465    /// Write a file using a patch recipe (optimized for remote filesystems).
466    ///
467    /// This allows saving edited files by specifying which parts to copy from
468    /// the original and which parts are new content. For remote filesystems,
469    /// this avoids transferring unchanged portions over the network.
470    ///
471    /// # Arguments
472    /// * `src_path` - The original file to read from (for Copy operations)
473    /// * `dst_path` - The destination file (often same as src_path)
474    /// * `ops` - The sequence of operations to build the new file
475    ///
476    /// The default implementation flattens all operations into memory and
477    /// calls `write_file`. Remote implementations can override this to send
478    /// the recipe and let the remote host do the reconstruction.
479    fn write_patched(&self, src_path: &Path, dst_path: &Path, ops: &[WriteOp]) -> io::Result<()> {
480        // Default implementation: flatten to buffer and write
481        let mut buffer = Vec::new();
482        for op in ops {
483            match op {
484                WriteOp::Copy { offset, len } => {
485                    let data = self.read_range(src_path, *offset, *len as usize)?;
486                    buffer.extend_from_slice(&data);
487                }
488                WriteOp::Insert { data } => {
489                    buffer.extend_from_slice(data);
490                }
491            }
492        }
493        self.write_file(dst_path, &buffer)
494    }
495
496    // ========================================================================
497    // File Operations
498    // ========================================================================
499
500    /// Rename/move a file or directory atomically
501    fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
502
503    /// Copy a file (fallback when rename fails across filesystems)
504    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64>;
505
506    /// Remove a file
507    fn remove_file(&self, path: &Path) -> io::Result<()>;
508
509    /// Remove an empty directory
510    fn remove_dir(&self, path: &Path) -> io::Result<()>;
511
512    // ========================================================================
513    // Metadata Operations
514    // ========================================================================
515
516    /// Get file/directory metadata
517    fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
518
519    /// Get symlink metadata (doesn't follow symlinks)
520    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
521
522    /// Check if path exists
523    fn exists(&self, path: &Path) -> bool {
524        self.metadata(path).is_ok()
525    }
526
527    /// Check if path exists, returns metadata if it does
528    fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
529        self.metadata(path).ok()
530    }
531
532    /// Check if path is a directory
533    fn is_dir(&self, path: &Path) -> io::Result<bool>;
534
535    /// Check if path is a file
536    fn is_file(&self, path: &Path) -> io::Result<bool>;
537
538    /// Check if the current user has write permission to the given path.
539    ///
540    /// On Unix, this considers file ownership, group membership (including
541    /// supplementary groups), and the relevant permission bits. On other
542    /// platforms it falls back to the standard readonly check.
543    ///
544    /// Returns `false` if the path doesn't exist or metadata can't be read.
545    fn is_writable(&self, path: &Path) -> bool {
546        self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
547    }
548
549    /// Set file permissions
550    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
551
552    // ========================================================================
553    // Directory Operations
554    // ========================================================================
555
556    /// List entries in a directory (non-recursive)
557    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
558
559    /// Create a directory
560    fn create_dir(&self, path: &Path) -> io::Result<()>;
561
562    /// Create a directory and all parent directories
563    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
564
565    // ========================================================================
566    // Path Operations
567    // ========================================================================
568
569    /// Get canonical (absolute, normalized) path
570    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
571
572    // ========================================================================
573    // Utility Methods
574    // ========================================================================
575
576    /// Get the current user's UID (Unix only, returns 0 on other platforms)
577    fn current_uid(&self) -> u32;
578
579    /// Check if the current user is the owner of the file
580    fn is_owner(&self, path: &Path) -> bool {
581        #[cfg(unix)]
582        {
583            if let Ok(meta) = self.metadata(path) {
584                if let Some(uid) = meta.uid {
585                    return uid == self.current_uid();
586                }
587            }
588            true
589        }
590        #[cfg(not(unix))]
591        {
592            let _ = path;
593            true
594        }
595    }
596
597    /// Get a temporary file path for atomic writes
598    fn temp_path_for(&self, path: &Path) -> PathBuf {
599        path.with_extension("tmp")
600    }
601
602    /// Get a unique temporary file path (using timestamp and PID)
603    fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
604        let temp_dir = std::env::temp_dir();
605        let file_name = dest_path
606            .file_name()
607            .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
608        let timestamp = std::time::SystemTime::now()
609            .duration_since(std::time::UNIX_EPOCH)
610            .map(|d| d.as_nanos())
611            .unwrap_or(0);
612        temp_dir.join(format!(
613            "{}-{}-{}.tmp",
614            file_name.to_string_lossy(),
615            std::process::id(),
616            timestamp
617        ))
618    }
619
620    // ========================================================================
621    // Remote Connection Info
622    // ========================================================================
623
624    /// Get remote connection info if this is a remote filesystem
625    ///
626    /// Returns `Some("user@host")` for remote filesystems, `None` for local.
627    /// Used to display remote connection status in the UI.
628    fn remote_connection_info(&self) -> Option<&str> {
629        None
630    }
631
632    /// Check if a remote filesystem is currently connected.
633    ///
634    /// Returns `true` for local filesystems (always "connected") and for
635    /// remote filesystems with a healthy connection. Returns `false` when
636    /// the remote connection has been lost (e.g., timeout, SSH disconnect).
637    fn is_remote_connected(&self) -> bool {
638        true
639    }
640
641    /// Get the home directory for this filesystem
642    ///
643    /// For local filesystems, returns the local home directory.
644    /// For remote filesystems, returns the remote home directory.
645    fn home_dir(&self) -> io::Result<PathBuf> {
646        dirs::home_dir()
647            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
648    }
649
650    // ========================================================================
651    // Search Operations
652    // ========================================================================
653
654    /// Search a file on disk for a pattern, returning one batch of matches.
655    ///
656    /// Call repeatedly with the same cursor until `cursor.done` is true.
657    /// Each call searches one chunk; the cursor tracks position and line
658    /// numbers across calls.
659    ///
660    /// The search runs where the data lives: `StdFileSystem` reads and
661    /// scans locally; `RemoteFileSystem` sends a stateless RPC to the
662    /// remote agent.  Only matches cross the network.
663    ///
664    /// For searching an already-open buffer with unsaved edits, use
665    /// `TextBuffer::search_scan_all` which reads from the piece tree.
666    fn search_file(
667        &self,
668        path: &Path,
669        pattern: &str,
670        opts: &FileSearchOptions,
671        cursor: &mut FileSearchCursor,
672    ) -> io::Result<Vec<SearchMatch>>;
673
674    /// Write file using sudo (for root-owned files).
675    ///
676    /// This writes the file with elevated privileges, preserving the specified
677    /// permissions and ownership. Used when normal write fails due to permissions.
678    ///
679    /// - `path`: Destination file path
680    /// - `data`: File contents to write
681    /// - `mode`: File permissions (e.g., 0o644)
682    /// - `uid`: Owner user ID
683    /// - `gid`: Owner group ID
684    fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
685        -> io::Result<()>;
686
687    // ========================================================================
688    // Directory Walking
689    // ========================================================================
690
691    /// Recursively walk a directory tree, invoking `on_file` for each file.
692    ///
693    /// Skips hidden entries (dot-prefixed names) and directories whose
694    /// basename appears in `skip_dirs`.  The walk stops early when:
695    /// - `on_file` returns `false` (caller reached its limit), or
696    /// - `cancel` is set to `true` (e.g. user closed the dialog).
697    ///
698    /// `on_file` receives `(absolute_path, path_relative_to_root)`.
699    ///
700    /// `skip_dirs` entries are **basenames** matched at every depth
701    /// (e.g. `"node_modules"` skips every `node_modules` directory in the
702    /// tree).
703    ///
704    /// // TODO: support .gitignore-style glob patterns in addition to
705    /// // basename matching, so callers can express richer ignore rules
706    /// // (e.g. `build/`, `*.o`, `vendor/**`).
707    ///
708    /// Each implementation must walk the filesystem it owns.  Local
709    /// implementations should iterate `std::fs::read_dir` lazily (not
710    /// collect into a Vec) so memory stays O(tree depth).  Remote
711    /// implementations should walk server-side and stream results back
712    /// via the channel, avoiding per-directory round-trips.
713    fn walk_files(
714        &self,
715        root: &Path,
716        skip_dirs: &[&str],
717        cancel: &std::sync::atomic::AtomicBool,
718        on_file: &mut dyn FnMut(&Path, &str) -> bool,
719    ) -> io::Result<()>;
720}
721
722// ============================================================================
723// FileSystemExt - Async Extension Trait
724// ============================================================================
725
726/// Async extension trait for FileSystem
727///
728/// This trait provides async versions of FileSystem methods using native
729/// Rust async fn (no async_trait crate needed). Default implementations
730/// simply call the sync methods, which works for local filesystem operations.
731///
732/// For truly async backends (network FS, remote agents), implementations
733/// can override these methods with actual async implementations.
734///
735/// Note: This trait is NOT object-safe due to async fn. Use generics
736/// (`impl FileSystem` or `F: FileSystem`) instead of `dyn FileSystem`
737/// when async methods are needed.
738///
739/// # Example
740///
741/// ```ignore
742/// async fn list_files<F: FileSystem>(fs: &F, path: &Path) -> io::Result<Vec<DirEntry>> {
743///     fs.read_dir_async(path).await
744/// }
745/// ```
746pub trait FileSystemExt: FileSystem {
747    /// Async version of read_file
748    fn read_file_async(
749        &self,
750        path: &Path,
751    ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
752        async { self.read_file(path) }
753    }
754
755    /// Async version of read_range
756    fn read_range_async(
757        &self,
758        path: &Path,
759        offset: u64,
760        len: usize,
761    ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
762        async move { self.read_range(path, offset, len) }
763    }
764
765    /// Async version of count_line_feeds_in_range
766    fn count_line_feeds_in_range_async(
767        &self,
768        path: &Path,
769        offset: u64,
770        len: usize,
771    ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
772        async move { self.count_line_feeds_in_range(path, offset, len) }
773    }
774
775    /// Async version of write_file
776    fn write_file_async(
777        &self,
778        path: &Path,
779        data: &[u8],
780    ) -> impl std::future::Future<Output = io::Result<()>> + Send {
781        async { self.write_file(path, data) }
782    }
783
784    /// Async version of metadata
785    fn metadata_async(
786        &self,
787        path: &Path,
788    ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
789        async { self.metadata(path) }
790    }
791
792    /// Async version of exists
793    fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
794        async { self.exists(path) }
795    }
796
797    /// Async version of is_dir
798    fn is_dir_async(
799        &self,
800        path: &Path,
801    ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
802        async { self.is_dir(path) }
803    }
804
805    /// Async version of is_file
806    fn is_file_async(
807        &self,
808        path: &Path,
809    ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
810        async { self.is_file(path) }
811    }
812
813    /// Async version of read_dir
814    fn read_dir_async(
815        &self,
816        path: &Path,
817    ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
818        async { self.read_dir(path) }
819    }
820
821    /// Async version of canonicalize
822    fn canonicalize_async(
823        &self,
824        path: &Path,
825    ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
826        async { self.canonicalize(path) }
827    }
828}
829
830/// Blanket implementation: all FileSystem types automatically get async methods
831impl<T: FileSystem> FileSystemExt for T {}
832
833// ============================================================================
834// Default search_file implementation
835// ============================================================================
836
837/// Build a `regex::bytes::Regex` from a user-facing pattern and search options.
838pub fn build_search_regex(
839    pattern: &str,
840    opts: &FileSearchOptions,
841) -> io::Result<regex::bytes::Regex> {
842    let re_pattern = if opts.fixed_string {
843        regex::escape(pattern)
844    } else {
845        pattern.to_string()
846    };
847    let re_pattern = if opts.whole_word {
848        format!(r"\b{}\b", re_pattern)
849    } else {
850        re_pattern
851    };
852    let re_pattern = if opts.case_sensitive {
853        re_pattern
854    } else {
855        format!("(?i){}", re_pattern)
856    };
857    regex::bytes::Regex::new(&re_pattern)
858        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
859}
860
861/// Default implementation of `FileSystem::search_file` that works for any
862/// filesystem backend.  Reads one chunk via `read_range`, scans with the
863/// given regex, and returns matches with line/column/context.
864pub fn default_search_file(
865    fs: &dyn FileSystem,
866    path: &Path,
867    pattern: &str,
868    opts: &FileSearchOptions,
869    cursor: &mut FileSearchCursor,
870) -> io::Result<Vec<SearchMatch>> {
871    if cursor.done {
872        return Ok(vec![]);
873    }
874
875    const CHUNK_SIZE: usize = 1_048_576; // 1 MB
876    let overlap = pattern.len().max(256);
877
878    let file_len = fs.metadata(path)?.size as usize;
879    let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
880
881    // Binary check on first call (only when starting from offset 0 with no range bound)
882    if cursor.offset == 0 && cursor.end_offset.is_none() {
883        if file_len == 0 {
884            cursor.done = true;
885            return Ok(vec![]);
886        }
887        let header_len = file_len.min(8192);
888        let header = fs.read_range(path, 0, header_len)?;
889        if header.contains(&0) {
890            cursor.done = true;
891            return Ok(vec![]);
892        }
893    }
894
895    if cursor.offset >= effective_end {
896        cursor.done = true;
897        return Ok(vec![]);
898    }
899
900    let regex = build_search_regex(pattern, opts)?;
901
902    // Read chunk with overlap from previous
903    let read_start = cursor.offset.saturating_sub(overlap);
904    let read_end = (read_start + CHUNK_SIZE).min(effective_end);
905    let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
906
907    let overlap_len = cursor.offset - read_start;
908
909    // Incremental line counting (same algorithm as search_scan_next_chunk)
910    let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
911    let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
912    let mut counted_to = 0usize;
913    let mut matches = Vec::new();
914
915    for m in regex.find_iter(&chunk) {
916        // Skip matches in overlap region (already reported in previous batch)
917        if overlap_len > 0 && m.end() <= overlap_len {
918            continue;
919        }
920        if matches.len() >= opts.max_matches {
921            break;
922        }
923
924        // Count newlines from last position to this match
925        line_at += chunk[counted_to..m.start()]
926            .iter()
927            .filter(|&&b| b == b'\n')
928            .count();
929        counted_to = m.start();
930
931        // Find line boundaries for context
932        let line_start = chunk[..m.start()]
933            .iter()
934            .rposition(|&b| b == b'\n')
935            .map(|p| p + 1)
936            .unwrap_or(0);
937        let line_end = chunk[m.start()..]
938            .iter()
939            .position(|&b| b == b'\n')
940            .map(|p| m.start() + p)
941            .unwrap_or(chunk.len());
942
943        let column = m.start() - line_start + 1;
944        let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
945
946        matches.push(SearchMatch {
947            byte_offset: read_start + m.start(),
948            length: m.end() - m.start(),
949            line: line_at,
950            column,
951            context,
952        });
953    }
954
955    // Advance cursor
956    let new_data = &chunk[overlap_len..];
957    cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
958    cursor.offset = read_end;
959    if read_end >= effective_end {
960        cursor.done = true;
961    }
962
963    Ok(matches)
964}
965
966// ============================================================================
967// StdFileSystem Implementation
968// ============================================================================
969
970/// Standard filesystem implementation using `std::fs`
971///
972/// This is the default implementation for native builds.
973#[derive(Debug, Clone, Copy, Default)]
974pub struct StdFileSystem;
975
976impl StdFileSystem {
977    /// Check if a file is hidden (platform-specific)
978    fn is_hidden(path: &Path) -> bool {
979        path.file_name()
980            .and_then(|n| n.to_str())
981            .is_some_and(|n| n.starts_with('.'))
982    }
983
984    /// Get the current user's effective UID and all group IDs (primary + supplementary).
985    #[cfg(unix)]
986    pub fn current_user_groups() -> (u32, Vec<u32>) {
987        // SAFETY: these libc calls are always safe and have no failure modes
988        let euid = unsafe { libc::geteuid() };
989        let egid = unsafe { libc::getegid() };
990        let mut groups = vec![egid];
991
992        // Get supplementary groups
993        let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
994        if ngroups > 0 {
995            let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
996            let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
997            if n > 0 {
998                sup_groups.truncate(n as usize);
999                for g in sup_groups {
1000                    if g != egid {
1001                        groups.push(g);
1002                    }
1003                }
1004            }
1005        }
1006
1007        (euid, groups)
1008    }
1009
1010    /// Build FileMetadata from std::fs::Metadata
1011    fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1012        #[cfg(unix)]
1013        {
1014            use std::os::unix::fs::MetadataExt;
1015            let file_uid = meta.uid();
1016            let file_gid = meta.gid();
1017            let permissions = FilePermissions::from_std(meta.permissions());
1018            let (euid, user_groups) = Self::current_user_groups();
1019            let is_readonly =
1020                permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups);
1021            FileMetadata {
1022                size: meta.len(),
1023                modified: meta.modified().ok(),
1024                permissions: Some(permissions),
1025                is_hidden: Self::is_hidden(path),
1026                is_readonly,
1027                uid: Some(file_uid),
1028                gid: Some(file_gid),
1029            }
1030        }
1031        #[cfg(not(unix))]
1032        {
1033            FileMetadata {
1034                size: meta.len(),
1035                modified: meta.modified().ok(),
1036                permissions: Some(FilePermissions::from_std(meta.permissions())),
1037                is_hidden: Self::is_hidden(path),
1038                is_readonly: meta.permissions().readonly(),
1039            }
1040        }
1041    }
1042}
1043
1044impl FileSystem for StdFileSystem {
1045    // File Content Operations
1046    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1047        let data = std::fs::read(path)?;
1048        crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1049        Ok(data)
1050    }
1051
1052    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1053        let mut file = std::fs::File::open(path)?;
1054        file.seek(io::SeekFrom::Start(offset))?;
1055        let mut buffer = vec![0u8; len];
1056        file.read_exact(&mut buffer)?;
1057        crate::services::counters::global().inc_disk_bytes_read(len as u64);
1058        Ok(buffer)
1059    }
1060
1061    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1062        let original_metadata = self.metadata_if_exists(path);
1063        let temp_path = self.temp_path_for(path);
1064        {
1065            let mut file = self.create_file(&temp_path)?;
1066            file.write_all(data)?;
1067            file.sync_all()?;
1068        }
1069        if let Some(ref meta) = original_metadata {
1070            if let Some(ref perms) = meta.permissions {
1071                // Best-effort permission restore; rename will proceed regardless
1072                #[allow(clippy::let_underscore_must_use)]
1073                let _ = self.set_permissions(&temp_path, perms);
1074            }
1075        }
1076        self.rename(&temp_path, path)?;
1077        Ok(())
1078    }
1079
1080    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1081        let file = std::fs::File::create(path)?;
1082        Ok(Box::new(StdFileWriter(file)))
1083    }
1084
1085    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1086        let file = std::fs::File::open(path)?;
1087        Ok(Box::new(StdFileReader(file)))
1088    }
1089
1090    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1091        let file = std::fs::OpenOptions::new()
1092            .write(true)
1093            .truncate(true)
1094            .open(path)?;
1095        Ok(Box::new(StdFileWriter(file)))
1096    }
1097
1098    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1099        let file = std::fs::OpenOptions::new()
1100            .create(true)
1101            .append(true)
1102            .open(path)?;
1103        Ok(Box::new(StdFileWriter(file)))
1104    }
1105
1106    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1107        let file = std::fs::OpenOptions::new().write(true).open(path)?;
1108        file.set_len(len)
1109    }
1110
1111    // File Operations
1112    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1113        std::fs::rename(from, to)
1114    }
1115
1116    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1117        std::fs::copy(from, to)
1118    }
1119
1120    fn remove_file(&self, path: &Path) -> io::Result<()> {
1121        std::fs::remove_file(path)
1122    }
1123
1124    fn remove_dir(&self, path: &Path) -> io::Result<()> {
1125        std::fs::remove_dir(path)
1126    }
1127
1128    // Metadata Operations
1129    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1130        let meta = std::fs::metadata(path)?;
1131        Ok(Self::build_metadata(path, &meta))
1132    }
1133
1134    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1135        let meta = std::fs::symlink_metadata(path)?;
1136        Ok(Self::build_metadata(path, &meta))
1137    }
1138
1139    fn is_dir(&self, path: &Path) -> io::Result<bool> {
1140        Ok(std::fs::metadata(path)?.is_dir())
1141    }
1142
1143    fn is_file(&self, path: &Path) -> io::Result<bool> {
1144        Ok(std::fs::metadata(path)?.is_file())
1145    }
1146
1147    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1148        std::fs::set_permissions(path, permissions.to_std())
1149    }
1150
1151    // Directory Operations
1152    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1153        let mut entries = Vec::new();
1154        for entry in std::fs::read_dir(path)? {
1155            let entry = entry?;
1156            let path = entry.path();
1157            let name = entry.file_name().to_string_lossy().into_owned();
1158            let file_type = entry.file_type()?;
1159
1160            let entry_type = if file_type.is_dir() {
1161                EntryType::Directory
1162            } else if file_type.is_symlink() {
1163                EntryType::Symlink
1164            } else {
1165                EntryType::File
1166            };
1167
1168            let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1169
1170            // For symlinks, check if target is a directory
1171            if file_type.is_symlink() {
1172                dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1173                    .map(|m| m.is_dir())
1174                    .unwrap_or(false);
1175            }
1176
1177            entries.push(dir_entry);
1178        }
1179        Ok(entries)
1180    }
1181
1182    fn create_dir(&self, path: &Path) -> io::Result<()> {
1183        std::fs::create_dir(path)
1184    }
1185
1186    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1187        std::fs::create_dir_all(path)
1188    }
1189
1190    // Path Operations
1191    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1192        std::fs::canonicalize(path)
1193    }
1194
1195    // Utility
1196    fn current_uid(&self) -> u32 {
1197        #[cfg(all(unix, feature = "runtime"))]
1198        {
1199            // SAFETY: getuid() is a simple syscall with no arguments
1200            unsafe { libc::getuid() }
1201        }
1202        #[cfg(not(all(unix, feature = "runtime")))]
1203        {
1204            0
1205        }
1206    }
1207
1208    fn sudo_write(
1209        &self,
1210        path: &Path,
1211        data: &[u8],
1212        mode: u32,
1213        uid: u32,
1214        gid: u32,
1215    ) -> io::Result<()> {
1216        use std::process::{Command, Stdio};
1217
1218        // Write data via sudo tee
1219        let mut child = Command::new("sudo")
1220            .args(["tee", &path.to_string_lossy()])
1221            .stdin(Stdio::piped())
1222            .stdout(Stdio::null())
1223            .stderr(Stdio::piped())
1224            .spawn()
1225            .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1226
1227        if let Some(mut stdin) = child.stdin.take() {
1228            use std::io::Write;
1229            stdin.write_all(data)?;
1230        }
1231
1232        let output = child.wait_with_output()?;
1233        if !output.status.success() {
1234            let stderr = String::from_utf8_lossy(&output.stderr);
1235            return Err(io::Error::new(
1236                io::ErrorKind::PermissionDenied,
1237                format!("sudo tee failed: {}", stderr.trim()),
1238            ));
1239        }
1240
1241        // Set permissions via sudo chmod
1242        let status = Command::new("sudo")
1243            .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1244            .status()?;
1245        if !status.success() {
1246            return Err(io::Error::other("sudo chmod failed"));
1247        }
1248
1249        // Set ownership via sudo chown
1250        let status = Command::new("sudo")
1251            .args([
1252                "chown",
1253                &format!("{}:{}", uid, gid),
1254                &path.to_string_lossy(),
1255            ])
1256            .status()?;
1257        if !status.success() {
1258            return Err(io::Error::other("sudo chown failed"));
1259        }
1260
1261        Ok(())
1262    }
1263
1264    fn search_file(
1265        &self,
1266        path: &Path,
1267        pattern: &str,
1268        opts: &FileSearchOptions,
1269        cursor: &mut FileSearchCursor,
1270    ) -> io::Result<Vec<SearchMatch>> {
1271        default_search_file(self, path, pattern, opts, cursor)
1272    }
1273
1274    fn walk_files(
1275        &self,
1276        root: &Path,
1277        skip_dirs: &[&str],
1278        cancel: &std::sync::atomic::AtomicBool,
1279        on_file: &mut dyn FnMut(&Path, &str) -> bool,
1280    ) -> io::Result<()> {
1281        let mut stack = vec![root.to_path_buf()];
1282        while let Some(dir) = stack.pop() {
1283            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1284                return Ok(());
1285            }
1286
1287            // Use std::fs::read_dir iterator directly — NOT self.read_dir()
1288            // which collects into a Vec.  This keeps memory O(1) per directory
1289            // even for directories with millions of entries.
1290            let iter = match std::fs::read_dir(&dir) {
1291                Ok(it) => it,
1292                Err(_) => continue,
1293            };
1294
1295            for entry in iter {
1296                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1297                    return Ok(());
1298                }
1299                let entry = match entry {
1300                    Ok(e) => e,
1301                    Err(_) => continue,
1302                };
1303                let name = entry.file_name();
1304                let name_str = name.to_string_lossy();
1305
1306                // Skip hidden entries
1307                if name_str.starts_with('.') {
1308                    continue;
1309                }
1310
1311                let ft = match entry.file_type() {
1312                    Ok(ft) => ft,
1313                    Err(_) => continue,
1314                };
1315                let path = entry.path();
1316
1317                if ft.is_file() {
1318                    if let Ok(rel) = path.strip_prefix(root) {
1319                        let rel_str = rel.to_string_lossy().replace('\\', "/");
1320                        if !on_file(&path, &rel_str) {
1321                            return Ok(());
1322                        }
1323                    }
1324                } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1325                    stack.push(path);
1326                }
1327            }
1328        }
1329        Ok(())
1330    }
1331}
1332
1333// ============================================================================
1334// NoopFileSystem Implementation
1335// ============================================================================
1336
1337/// No-op filesystem that returns errors for all operations
1338///
1339/// Used as a placeholder or in WASM builds where a VirtualFileSystem
1340/// should be used instead.
1341#[derive(Debug, Clone, Copy, Default)]
1342pub struct NoopFileSystem;
1343
1344impl NoopFileSystem {
1345    fn unsupported<T>() -> io::Result<T> {
1346        Err(io::Error::new(
1347            io::ErrorKind::Unsupported,
1348            "Filesystem not available",
1349        ))
1350    }
1351}
1352
1353impl FileSystem for NoopFileSystem {
1354    fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1355        Self::unsupported()
1356    }
1357
1358    fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1359        Self::unsupported()
1360    }
1361
1362    fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1363        Self::unsupported()
1364    }
1365
1366    fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1367        Self::unsupported()
1368    }
1369
1370    fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1371        Self::unsupported()
1372    }
1373
1374    fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1375        Self::unsupported()
1376    }
1377
1378    fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1379        Self::unsupported()
1380    }
1381
1382    fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1383        Self::unsupported()
1384    }
1385
1386    fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1387        Self::unsupported()
1388    }
1389
1390    fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1391        Self::unsupported()
1392    }
1393
1394    fn remove_file(&self, _path: &Path) -> io::Result<()> {
1395        Self::unsupported()
1396    }
1397
1398    fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1399        Self::unsupported()
1400    }
1401
1402    fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1403        Self::unsupported()
1404    }
1405
1406    fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1407        Self::unsupported()
1408    }
1409
1410    fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1411        Self::unsupported()
1412    }
1413
1414    fn is_file(&self, _path: &Path) -> io::Result<bool> {
1415        Self::unsupported()
1416    }
1417
1418    fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1419        Self::unsupported()
1420    }
1421
1422    fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1423        Self::unsupported()
1424    }
1425
1426    fn create_dir(&self, _path: &Path) -> io::Result<()> {
1427        Self::unsupported()
1428    }
1429
1430    fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1431        Self::unsupported()
1432    }
1433
1434    fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1435        Self::unsupported()
1436    }
1437
1438    fn current_uid(&self) -> u32 {
1439        0
1440    }
1441
1442    fn search_file(
1443        &self,
1444        _path: &Path,
1445        _pattern: &str,
1446        _opts: &FileSearchOptions,
1447        _cursor: &mut FileSearchCursor,
1448    ) -> io::Result<Vec<SearchMatch>> {
1449        Self::unsupported()
1450    }
1451
1452    fn sudo_write(
1453        &self,
1454        _path: &Path,
1455        _data: &[u8],
1456        _mode: u32,
1457        _uid: u32,
1458        _gid: u32,
1459    ) -> io::Result<()> {
1460        Self::unsupported()
1461    }
1462
1463    fn walk_files(
1464        &self,
1465        _root: &Path,
1466        _skip_dirs: &[&str],
1467        _cancel: &std::sync::atomic::AtomicBool,
1468        _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1469    ) -> io::Result<()> {
1470        Self::unsupported()
1471    }
1472}
1473
1474// ============================================================================
1475// Tests
1476// ============================================================================
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481    use tempfile::NamedTempFile;
1482
1483    #[test]
1484    fn test_std_filesystem_read_write() {
1485        let fs = StdFileSystem;
1486        let mut temp = NamedTempFile::new().unwrap();
1487        let path = temp.path().to_path_buf();
1488
1489        std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1490        std::io::Write::flush(&mut temp).unwrap();
1491
1492        let content = fs.read_file(&path).unwrap();
1493        assert_eq!(content, b"Hello, World!");
1494
1495        let range = fs.read_range(&path, 7, 5).unwrap();
1496        assert_eq!(range, b"World");
1497
1498        let meta = fs.metadata(&path).unwrap();
1499        assert_eq!(meta.size, 13);
1500    }
1501
1502    #[test]
1503    fn test_noop_filesystem() {
1504        let fs = NoopFileSystem;
1505        let path = Path::new("/some/path");
1506
1507        assert!(fs.read_file(path).is_err());
1508        assert!(fs.read_range(path, 0, 10).is_err());
1509        assert!(fs.write_file(path, b"data").is_err());
1510        assert!(fs.metadata(path).is_err());
1511        assert!(fs.read_dir(path).is_err());
1512    }
1513
1514    #[test]
1515    fn test_create_and_write_file() {
1516        let fs = StdFileSystem;
1517        let temp_dir = tempfile::tempdir().unwrap();
1518        let path = temp_dir.path().join("test.txt");
1519
1520        {
1521            let mut writer = fs.create_file(&path).unwrap();
1522            writer.write_all(b"test content").unwrap();
1523            writer.sync_all().unwrap();
1524        }
1525
1526        let content = fs.read_file(&path).unwrap();
1527        assert_eq!(content, b"test content");
1528    }
1529
1530    #[test]
1531    fn test_read_dir() {
1532        let fs = StdFileSystem;
1533        let temp_dir = tempfile::tempdir().unwrap();
1534
1535        // Create some files and directories
1536        fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1537        fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1538            .unwrap();
1539        fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1540            .unwrap();
1541
1542        let entries = fs.read_dir(temp_dir.path()).unwrap();
1543        assert_eq!(entries.len(), 3);
1544
1545        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1546        assert!(names.contains(&"subdir"));
1547        assert!(names.contains(&"file1.txt"));
1548        assert!(names.contains(&"file2.txt"));
1549    }
1550
1551    #[test]
1552    fn test_dir_entry_types() {
1553        let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1554        assert!(file.is_file());
1555        assert!(!file.is_dir());
1556
1557        let dir = DirEntry::new(
1558            PathBuf::from("/dir"),
1559            "dir".to_string(),
1560            EntryType::Directory,
1561        );
1562        assert!(dir.is_dir());
1563        assert!(!dir.is_file());
1564
1565        let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1566        assert!(link_to_dir.is_symlink());
1567        assert!(link_to_dir.is_dir());
1568    }
1569
1570    #[test]
1571    fn test_metadata_builder() {
1572        let meta = FileMetadata::default()
1573            .with_hidden(true)
1574            .with_readonly(true);
1575        assert!(meta.is_hidden);
1576        assert!(meta.is_readonly);
1577    }
1578
1579    #[test]
1580    fn test_atomic_write() {
1581        let fs = StdFileSystem;
1582        let temp_dir = tempfile::tempdir().unwrap();
1583        let path = temp_dir.path().join("atomic_test.txt");
1584
1585        fs.write_file(&path, b"initial").unwrap();
1586        assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1587
1588        fs.write_file(&path, b"updated").unwrap();
1589        assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1590    }
1591
1592    #[test]
1593    fn test_write_patched_default_impl() {
1594        // Test that the default write_patched implementation works correctly
1595        let fs = StdFileSystem;
1596        let temp_dir = tempfile::tempdir().unwrap();
1597        let src_path = temp_dir.path().join("source.txt");
1598        let dst_path = temp_dir.path().join("dest.txt");
1599
1600        // Create source file with known content
1601        fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1602
1603        // Apply patch: copy first 3 bytes, insert "XXX", copy last 3 bytes
1604        let ops = vec![
1605            WriteOp::Copy { offset: 0, len: 3 }, // "AAA"
1606            WriteOp::Insert { data: b"XXX" },    // "XXX"
1607            WriteOp::Copy { offset: 6, len: 3 }, // "CCC"
1608        ];
1609
1610        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1611
1612        let result = fs.read_file(&dst_path).unwrap();
1613        assert_eq!(result, b"AAAXXXCCC");
1614    }
1615
1616    #[test]
1617    fn test_write_patched_same_file() {
1618        // Test patching a file in-place (src == dst)
1619        let fs = StdFileSystem;
1620        let temp_dir = tempfile::tempdir().unwrap();
1621        let path = temp_dir.path().join("file.txt");
1622
1623        // Create file
1624        fs.write_file(&path, b"Hello World").unwrap();
1625
1626        // Replace "World" with "Rust"
1627        let ops = vec![
1628            WriteOp::Copy { offset: 0, len: 6 }, // "Hello "
1629            WriteOp::Insert { data: b"Rust" },   // "Rust"
1630        ];
1631
1632        fs.write_patched(&path, &path, &ops).unwrap();
1633
1634        let result = fs.read_file(&path).unwrap();
1635        assert_eq!(result, b"Hello Rust");
1636    }
1637
1638    #[test]
1639    fn test_write_patched_insert_only() {
1640        // Test a patch with only inserts (new file)
1641        let fs = StdFileSystem;
1642        let temp_dir = tempfile::tempdir().unwrap();
1643        let src_path = temp_dir.path().join("empty.txt");
1644        let dst_path = temp_dir.path().join("new.txt");
1645
1646        // Create empty source (won't be read from)
1647        fs.write_file(&src_path, b"").unwrap();
1648
1649        let ops = vec![WriteOp::Insert {
1650            data: b"All new content",
1651        }];
1652
1653        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1654
1655        let result = fs.read_file(&dst_path).unwrap();
1656        assert_eq!(result, b"All new content");
1657    }
1658
1659    // ====================================================================
1660    // search_file tests
1661    // ====================================================================
1662
1663    fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1664        FileSearchOptions {
1665            fixed_string: pattern_is_fixed,
1666            case_sensitive: true,
1667            whole_word: false,
1668            max_matches: 100,
1669        }
1670    }
1671
1672    #[test]
1673    fn test_search_file_basic() {
1674        let fs = StdFileSystem;
1675        let temp_dir = tempfile::tempdir().unwrap();
1676        let path = temp_dir.path().join("test.txt");
1677        fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1678            .unwrap();
1679
1680        let opts = make_search_opts(true);
1681        let mut cursor = FileSearchCursor::new();
1682        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1683
1684        assert!(cursor.done);
1685        assert_eq!(matches.len(), 2);
1686
1687        assert_eq!(matches[0].line, 1);
1688        assert_eq!(matches[0].column, 1);
1689        assert_eq!(matches[0].context, "hello world");
1690
1691        assert_eq!(matches[1].line, 3);
1692        assert_eq!(matches[1].column, 1);
1693        assert_eq!(matches[1].context, "hello again");
1694    }
1695
1696    #[test]
1697    fn test_search_file_no_matches() {
1698        let fs = StdFileSystem;
1699        let temp_dir = tempfile::tempdir().unwrap();
1700        let path = temp_dir.path().join("test.txt");
1701        fs.write_file(&path, b"hello world\n").unwrap();
1702
1703        let opts = make_search_opts(true);
1704        let mut cursor = FileSearchCursor::new();
1705        let matches = fs
1706            .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1707            .unwrap();
1708
1709        assert!(cursor.done);
1710        assert!(matches.is_empty());
1711    }
1712
1713    #[test]
1714    fn test_search_file_case_insensitive() {
1715        let fs = StdFileSystem;
1716        let temp_dir = tempfile::tempdir().unwrap();
1717        let path = temp_dir.path().join("test.txt");
1718        fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1719
1720        let opts = FileSearchOptions {
1721            fixed_string: true,
1722            case_sensitive: false,
1723            whole_word: false,
1724            max_matches: 100,
1725        };
1726        let mut cursor = FileSearchCursor::new();
1727        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1728
1729        assert_eq!(matches.len(), 3);
1730    }
1731
1732    #[test]
1733    fn test_search_file_whole_word() {
1734        let fs = StdFileSystem;
1735        let temp_dir = tempfile::tempdir().unwrap();
1736        let path = temp_dir.path().join("test.txt");
1737        fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
1738
1739        let opts = FileSearchOptions {
1740            fixed_string: true,
1741            case_sensitive: true,
1742            whole_word: true,
1743            max_matches: 100,
1744        };
1745        let mut cursor = FileSearchCursor::new();
1746        let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
1747
1748        assert_eq!(matches.len(), 1);
1749        assert_eq!(matches[0].column, 1);
1750    }
1751
1752    #[test]
1753    fn test_search_file_regex() {
1754        let fs = StdFileSystem;
1755        let temp_dir = tempfile::tempdir().unwrap();
1756        let path = temp_dir.path().join("test.txt");
1757        fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
1758
1759        let opts = FileSearchOptions {
1760            fixed_string: false,
1761            case_sensitive: true,
1762            whole_word: false,
1763            max_matches: 100,
1764        };
1765        let mut cursor = FileSearchCursor::new();
1766        let matches = fs
1767            .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
1768            .unwrap();
1769
1770        assert_eq!(matches.len(), 2);
1771        assert_eq!(matches[0].context, "foo123 bar456 baz");
1772    }
1773
1774    #[test]
1775    fn test_search_file_binary_skipped() {
1776        let fs = StdFileSystem;
1777        let temp_dir = tempfile::tempdir().unwrap();
1778        let path = temp_dir.path().join("binary.dat");
1779        let mut data = b"hello world\n".to_vec();
1780        data.push(0); // null byte makes it binary
1781        data.extend_from_slice(b"hello again\n");
1782        fs.write_file(&path, &data).unwrap();
1783
1784        let opts = make_search_opts(true);
1785        let mut cursor = FileSearchCursor::new();
1786        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1787
1788        assert!(cursor.done);
1789        assert!(matches.is_empty());
1790    }
1791
1792    #[test]
1793    fn test_search_file_empty_file() {
1794        let fs = StdFileSystem;
1795        let temp_dir = tempfile::tempdir().unwrap();
1796        let path = temp_dir.path().join("empty.txt");
1797        fs.write_file(&path, b"").unwrap();
1798
1799        let opts = make_search_opts(true);
1800        let mut cursor = FileSearchCursor::new();
1801        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1802
1803        assert!(cursor.done);
1804        assert!(matches.is_empty());
1805    }
1806
1807    #[test]
1808    fn test_search_file_max_matches() {
1809        let fs = StdFileSystem;
1810        let temp_dir = tempfile::tempdir().unwrap();
1811        let path = temp_dir.path().join("test.txt");
1812        fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
1813
1814        let opts = FileSearchOptions {
1815            fixed_string: true,
1816            case_sensitive: true,
1817            whole_word: false,
1818            max_matches: 2,
1819        };
1820        let mut cursor = FileSearchCursor::new();
1821        let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
1822
1823        assert_eq!(matches.len(), 2);
1824    }
1825
1826    #[test]
1827    fn test_search_file_cursor_multi_chunk() {
1828        let fs = StdFileSystem;
1829        let temp_dir = tempfile::tempdir().unwrap();
1830        let path = temp_dir.path().join("large.txt");
1831
1832        // Create a file larger than 1MB chunk size to test cursor continuation
1833        let mut content = Vec::new();
1834        for i in 0..100_000 {
1835            content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
1836        }
1837        fs.write_file(&path, &content).unwrap();
1838
1839        let opts = FileSearchOptions {
1840            fixed_string: true,
1841            case_sensitive: true,
1842            whole_word: false,
1843            max_matches: 1000,
1844        };
1845        let mut cursor = FileSearchCursor::new();
1846        let mut all_matches = Vec::new();
1847
1848        while !cursor.done {
1849            let batch = fs
1850                .search_file(&path, "line 5000", &opts, &mut cursor)
1851                .unwrap();
1852            all_matches.extend(batch);
1853        }
1854
1855        // "line 5000" matches: "line 5000 ", "line 50000 "..  "line 50009 "
1856        // = 11 matches (5000, 50000-50009)
1857        assert_eq!(all_matches.len(), 11);
1858
1859        // Verify line numbers are correct
1860        let first = &all_matches[0];
1861        assert_eq!(first.line, 5001); // 0-indexed lines, 1-based line numbers
1862        assert_eq!(first.column, 1);
1863        assert!(first.context.starts_with("line 5000"));
1864    }
1865
1866    #[test]
1867    fn test_search_file_cursor_no_duplicates() {
1868        let fs = StdFileSystem;
1869        let temp_dir = tempfile::tempdir().unwrap();
1870        let path = temp_dir.path().join("large.txt");
1871
1872        // Create file with matches near chunk boundaries
1873        let mut content = Vec::new();
1874        for i in 0..100_000 {
1875            content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
1876        }
1877        fs.write_file(&path, &content).unwrap();
1878
1879        let opts = FileSearchOptions {
1880            fixed_string: true,
1881            case_sensitive: true,
1882            whole_word: false,
1883            max_matches: 200_000,
1884        };
1885        let mut cursor = FileSearchCursor::new();
1886        let mut all_matches = Vec::new();
1887        let mut batches = 0;
1888
1889        while !cursor.done {
1890            let batch = fs
1891                .search_file(&path, "MARKER_", &opts, &mut cursor)
1892                .unwrap();
1893            all_matches.extend(batch);
1894            batches += 1;
1895        }
1896
1897        // Must have multiple batches (file > 1MB)
1898        assert!(batches > 1, "Expected multiple batches, got {}", batches);
1899        // Exactly one match per line, no duplicates
1900        assert_eq!(all_matches.len(), 100_000);
1901        // Check no duplicate byte offsets
1902        let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
1903        offsets.sort();
1904        offsets.dedup();
1905        assert_eq!(offsets.len(), 100_000);
1906    }
1907
1908    #[test]
1909    fn test_search_file_line_numbers_across_chunks() {
1910        let fs = StdFileSystem;
1911        let temp_dir = tempfile::tempdir().unwrap();
1912        let path = temp_dir.path().join("large.txt");
1913
1914        // Create file where we know exact line numbers
1915        let mut content = Vec::new();
1916        let total_lines = 100_000;
1917        for i in 0..total_lines {
1918            if i == 99_999 {
1919                content.extend_from_slice(b"FINDME at the end\n");
1920            } else {
1921                content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
1922            }
1923        }
1924        fs.write_file(&path, &content).unwrap();
1925
1926        let opts = make_search_opts(true);
1927        let mut cursor = FileSearchCursor::new();
1928        let mut all_matches = Vec::new();
1929
1930        while !cursor.done {
1931            let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
1932            all_matches.extend(batch);
1933        }
1934
1935        assert_eq!(all_matches.len(), 1);
1936        assert_eq!(all_matches[0].line, total_lines); // last line
1937        assert_eq!(all_matches[0].context, "FINDME at the end");
1938    }
1939
1940    #[test]
1941    fn test_search_file_end_offset_bounds_search() {
1942        let fs = StdFileSystem;
1943        let temp_dir = tempfile::tempdir().unwrap();
1944        let path = temp_dir.path().join("bounded.txt");
1945
1946        // "AAA\nBBB\nCCC\nDDD\n" — each line is 4 bytes
1947        fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
1948
1949        // Search only the first 8 bytes ("AAA\nBBB\n") — should find AAA and BBB
1950        let opts = make_search_opts(true);
1951        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1952        let mut matches = Vec::new();
1953        while !cursor.done {
1954            matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
1955        }
1956        assert_eq!(matches.len(), 1);
1957        assert_eq!(matches[0].context, "AAA");
1958        assert_eq!(matches[0].line, 1);
1959
1960        // CCC is at byte 8, outside the first 8 bytes
1961        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1962        let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
1963        assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
1964
1965        // Search bytes 8..16 ("CCC\nDDD\n") — should find CCC
1966        let mut cursor = FileSearchCursor::for_range(8, 16, 3);
1967        let mut matches = Vec::new();
1968        while !cursor.done {
1969            matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
1970        }
1971        assert_eq!(matches.len(), 1);
1972        assert_eq!(matches[0].context, "CCC");
1973        assert_eq!(matches[0].line, 3);
1974    }
1975
1976    // ====================================================================
1977    // walk_files tests
1978    // ====================================================================
1979
1980    /// Helper: create a directory tree for walk_files tests.
1981    /// Returns the tempdir (must be kept alive for the duration of the test).
1982    fn make_walk_tree() -> tempfile::TempDir {
1983        let fs = StdFileSystem;
1984        let tmp = tempfile::tempdir().unwrap();
1985        let root = tmp.path();
1986
1987        // root/
1988        //   a.txt
1989        //   b.txt
1990        //   sub/
1991        //     c.txt
1992        //     deep/
1993        //       d.txt
1994        //   .hidden_dir/
1995        //     secret.txt
1996        //   .hidden_file
1997        //   node_modules/
1998        //     pkg.json
1999        //   target/
2000        //     debug.o
2001        fs.write_file(&root.join("a.txt"), b"a").unwrap();
2002        fs.write_file(&root.join("b.txt"), b"b").unwrap();
2003        fs.create_dir_all(&root.join("sub/deep")).unwrap();
2004        fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2005        fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2006        fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2007        fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2008            .unwrap();
2009        fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2010        fs.create_dir_all(&root.join("node_modules")).unwrap();
2011        fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2012            .unwrap();
2013        fs.create_dir_all(&root.join("target")).unwrap();
2014        fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2015
2016        tmp
2017    }
2018
2019    #[test]
2020    fn test_walk_files_std_basic() {
2021        let tmp = make_walk_tree();
2022        let fs = StdFileSystem;
2023        let cancel = std::sync::atomic::AtomicBool::new(false);
2024        let mut found: Vec<String> = Vec::new();
2025
2026        fs.walk_files(
2027            tmp.path(),
2028            &["node_modules", "target"],
2029            &cancel,
2030            &mut |_path, rel| {
2031                found.push(rel.to_string());
2032                true
2033            },
2034        )
2035        .unwrap();
2036
2037        found.sort();
2038        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2039    }
2040
2041    #[test]
2042    fn test_walk_files_std_skips_hidden() {
2043        let tmp = make_walk_tree();
2044        let fs = StdFileSystem;
2045        let cancel = std::sync::atomic::AtomicBool::new(false);
2046        let mut found: Vec<String> = Vec::new();
2047
2048        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2049            found.push(rel.to_string());
2050            true
2051        })
2052        .unwrap();
2053
2054        // Hidden files/dirs should be excluded, but node_modules and target
2055        // are NOT skipped (empty skip list)
2056        assert!(!found.iter().any(|f| f.contains(".hidden")));
2057        assert!(found.iter().any(|f| f.contains("node_modules")));
2058        assert!(found.iter().any(|f| f.contains("target")));
2059    }
2060
2061    #[test]
2062    fn test_walk_files_std_skip_dirs() {
2063        let tmp = make_walk_tree();
2064        let fs = StdFileSystem;
2065        let cancel = std::sync::atomic::AtomicBool::new(false);
2066        let mut found: Vec<String> = Vec::new();
2067
2068        fs.walk_files(
2069            tmp.path(),
2070            &["node_modules", "target", "deep"],
2071            &cancel,
2072            &mut |_path, rel| {
2073                found.push(rel.to_string());
2074                true
2075            },
2076        )
2077        .unwrap();
2078
2079        found.sort();
2080        // "deep" dir is also skipped, so d.txt should not appear
2081        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2082    }
2083
2084    #[test]
2085    fn test_walk_files_std_cancel() {
2086        let tmp = make_walk_tree();
2087        let fs = StdFileSystem;
2088        let cancel = std::sync::atomic::AtomicBool::new(false);
2089        let mut found: Vec<String> = Vec::new();
2090
2091        fs.walk_files(
2092            tmp.path(),
2093            &["node_modules", "target"],
2094            &cancel,
2095            &mut |_path, rel| {
2096                found.push(rel.to_string());
2097                // Cancel after finding the first file
2098                cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2099                true
2100            },
2101        )
2102        .unwrap();
2103
2104        assert_eq!(found.len(), 1, "Should stop after cancel is set");
2105    }
2106
2107    #[test]
2108    fn test_walk_files_std_on_file_returns_false() {
2109        let tmp = make_walk_tree();
2110        let fs = StdFileSystem;
2111        let cancel = std::sync::atomic::AtomicBool::new(false);
2112        let mut count = 0usize;
2113
2114        fs.walk_files(
2115            tmp.path(),
2116            &["node_modules", "target"],
2117            &cancel,
2118            &mut |_path, _rel| {
2119                count += 1;
2120                count < 2 // stop after 2 files
2121            },
2122        )
2123        .unwrap();
2124
2125        assert_eq!(count, 2, "Should stop when on_file returns false");
2126    }
2127
2128    #[test]
2129    fn test_walk_files_std_empty_dir() {
2130        let tmp = tempfile::tempdir().unwrap();
2131        let fs = StdFileSystem;
2132        let cancel = std::sync::atomic::AtomicBool::new(false);
2133        let mut found: Vec<String> = Vec::new();
2134
2135        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2136            found.push(rel.to_string());
2137            true
2138        })
2139        .unwrap();
2140
2141        assert!(found.is_empty());
2142    }
2143
2144    #[test]
2145    fn test_walk_files_std_nonexistent_root() {
2146        let fs = StdFileSystem;
2147        let cancel = std::sync::atomic::AtomicBool::new(false);
2148        let mut found: Vec<String> = Vec::new();
2149
2150        // Non-existent root should not panic, just return Ok with no files
2151        let result = fs.walk_files(
2152            Path::new("/nonexistent/path/that/does/not/exist"),
2153            &[],
2154            &cancel,
2155            &mut |_path, rel| {
2156                found.push(rel.to_string());
2157                true
2158            },
2159        );
2160
2161        assert!(result.is_ok());
2162        assert!(found.is_empty());
2163    }
2164
2165    #[test]
2166    fn test_walk_files_std_relative_paths_use_forward_slashes() {
2167        let tmp = make_walk_tree();
2168        let fs = StdFileSystem;
2169        let cancel = std::sync::atomic::AtomicBool::new(false);
2170        let mut found: Vec<String> = Vec::new();
2171
2172        fs.walk_files(
2173            tmp.path(),
2174            &["node_modules", "target"],
2175            &cancel,
2176            &mut |_path, rel| {
2177                found.push(rel.to_string());
2178                true
2179            },
2180        )
2181        .unwrap();
2182
2183        // All paths should use forward slashes (even on Windows)
2184        for path in &found {
2185            assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2186        }
2187    }
2188
2189    #[test]
2190    fn test_walk_files_noop_returns_error() {
2191        let fs = NoopFileSystem;
2192        let cancel = std::sync::atomic::AtomicBool::new(false);
2193
2194        let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2195            true
2196        });
2197
2198        assert!(result.is_err());
2199        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2200    }
2201}