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    /// Recursively remove a directory and all its contents
513    fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
514        for entry in self.read_dir(path)? {
515            if entry.is_dir() {
516                self.remove_dir_all(&entry.path)?;
517            } else {
518                self.remove_file(&entry.path)?;
519            }
520        }
521        self.remove_dir(path)
522    }
523
524    /// Recursively copy a directory and all its contents to dst
525    fn copy_dir_all(&self, src: &Path, dst: &Path) -> io::Result<()> {
526        self.create_dir_all(dst)?;
527        for entry in self.read_dir(src)? {
528            let dst_child = dst.join(&entry.name);
529            if entry.is_dir() {
530                self.copy_dir_all(&entry.path, &dst_child)?;
531            } else {
532                self.copy(&entry.path, &dst_child)?;
533            }
534        }
535        Ok(())
536    }
537
538    // ========================================================================
539    // Metadata Operations
540    // ========================================================================
541
542    /// Get file/directory metadata
543    fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
544
545    /// Get symlink metadata (doesn't follow symlinks)
546    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
547
548    /// Check if path exists
549    fn exists(&self, path: &Path) -> bool {
550        self.metadata(path).is_ok()
551    }
552
553    /// Check if path exists, returns metadata if it does
554    fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
555        self.metadata(path).ok()
556    }
557
558    /// Check if path is a directory
559    fn is_dir(&self, path: &Path) -> io::Result<bool>;
560
561    /// Check if path is a file
562    fn is_file(&self, path: &Path) -> io::Result<bool>;
563
564    /// Check if the current user has write permission to the given path.
565    ///
566    /// On Unix, this considers file ownership, group membership (including
567    /// supplementary groups), and the relevant permission bits. On other
568    /// platforms it falls back to the standard readonly check.
569    ///
570    /// Returns `false` if the path doesn't exist or metadata can't be read.
571    fn is_writable(&self, path: &Path) -> bool {
572        self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
573    }
574
575    /// Set file permissions
576    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
577
578    // ========================================================================
579    // Directory Operations
580    // ========================================================================
581
582    /// List entries in a directory (non-recursive)
583    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
584
585    /// Create a directory
586    fn create_dir(&self, path: &Path) -> io::Result<()>;
587
588    /// Create a directory and all parent directories
589    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
590
591    // ========================================================================
592    // Path Operations
593    // ========================================================================
594
595    /// Get canonical (absolute, normalized) path
596    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
597
598    // ========================================================================
599    // Utility Methods
600    // ========================================================================
601
602    /// Get the current user's UID (Unix only, returns 0 on other platforms)
603    fn current_uid(&self) -> u32;
604
605    /// Check if the current user is the owner of the file
606    fn is_owner(&self, path: &Path) -> bool {
607        #[cfg(unix)]
608        {
609            if let Ok(meta) = self.metadata(path) {
610                if let Some(uid) = meta.uid {
611                    return uid == self.current_uid();
612                }
613            }
614            true
615        }
616        #[cfg(not(unix))]
617        {
618            let _ = path;
619            true
620        }
621    }
622
623    /// Get a temporary file path for atomic writes
624    fn temp_path_for(&self, path: &Path) -> PathBuf {
625        path.with_extension("tmp")
626    }
627
628    /// Get a unique temporary file path (using timestamp and PID)
629    fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
630        let temp_dir = std::env::temp_dir();
631        let file_name = dest_path
632            .file_name()
633            .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
634        let timestamp = std::time::SystemTime::now()
635            .duration_since(std::time::UNIX_EPOCH)
636            .map(|d| d.as_nanos())
637            .unwrap_or(0);
638        temp_dir.join(format!(
639            "{}-{}-{}.tmp",
640            file_name.to_string_lossy(),
641            std::process::id(),
642            timestamp
643        ))
644    }
645
646    // ========================================================================
647    // Remote Connection Info
648    // ========================================================================
649
650    /// Get remote connection info if this is a remote filesystem
651    ///
652    /// Returns `Some("user@host")` for remote filesystems, `None` for local.
653    /// Used to display remote connection status in the UI.
654    fn remote_connection_info(&self) -> Option<&str> {
655        None
656    }
657
658    /// Check if a remote filesystem is currently connected.
659    ///
660    /// Returns `true` for local filesystems (always "connected") and for
661    /// remote filesystems with a healthy connection. Returns `false` when
662    /// the remote connection has been lost (e.g., timeout, SSH disconnect).
663    fn is_remote_connected(&self) -> bool {
664        true
665    }
666
667    /// Get the home directory for this filesystem
668    ///
669    /// For local filesystems, returns the local home directory.
670    /// For remote filesystems, returns the remote home directory.
671    fn home_dir(&self) -> io::Result<PathBuf> {
672        dirs::home_dir()
673            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
674    }
675
676    // ========================================================================
677    // Search Operations
678    // ========================================================================
679
680    /// Search a file on disk for a pattern, returning one batch of matches.
681    ///
682    /// Call repeatedly with the same cursor until `cursor.done` is true.
683    /// Each call searches one chunk; the cursor tracks position and line
684    /// numbers across calls.
685    ///
686    /// The search runs where the data lives: `StdFileSystem` reads and
687    /// scans locally; `RemoteFileSystem` sends a stateless RPC to the
688    /// remote agent.  Only matches cross the network.
689    ///
690    /// For searching an already-open buffer with unsaved edits, use
691    /// `TextBuffer::search_scan_all` which reads from the piece tree.
692    fn search_file(
693        &self,
694        path: &Path,
695        pattern: &str,
696        opts: &FileSearchOptions,
697        cursor: &mut FileSearchCursor,
698    ) -> io::Result<Vec<SearchMatch>>;
699
700    /// Write file using sudo (for root-owned files).
701    ///
702    /// This writes the file with elevated privileges, preserving the specified
703    /// permissions and ownership. Used when normal write fails due to permissions.
704    ///
705    /// - `path`: Destination file path
706    /// - `data`: File contents to write
707    /// - `mode`: File permissions (e.g., 0o644)
708    /// - `uid`: Owner user ID
709    /// - `gid`: Owner group ID
710    fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
711        -> io::Result<()>;
712
713    // ========================================================================
714    // Directory Walking
715    // ========================================================================
716
717    /// Recursively walk a directory tree, invoking `on_file` for each file.
718    ///
719    /// Skips hidden entries (dot-prefixed names) and directories whose
720    /// basename appears in `skip_dirs`.  The walk stops early when:
721    /// - `on_file` returns `false` (caller reached its limit), or
722    /// - `cancel` is set to `true` (e.g. user closed the dialog).
723    ///
724    /// `on_file` receives `(absolute_path, path_relative_to_root)`.
725    ///
726    /// `skip_dirs` entries are **basenames** matched at every depth
727    /// (e.g. `"node_modules"` skips every `node_modules` directory in the
728    /// tree).
729    ///
730    /// // TODO: support .gitignore-style glob patterns in addition to
731    /// // basename matching, so callers can express richer ignore rules
732    /// // (e.g. `build/`, `*.o`, `vendor/**`).
733    ///
734    /// Each implementation must walk the filesystem it owns.  Local
735    /// implementations should iterate `std::fs::read_dir` lazily (not
736    /// collect into a Vec) so memory stays O(tree depth).  Remote
737    /// implementations should walk server-side and stream results back
738    /// via the channel, avoiding per-directory round-trips.
739    fn walk_files(
740        &self,
741        root: &Path,
742        skip_dirs: &[&str],
743        cancel: &std::sync::atomic::AtomicBool,
744        on_file: &mut dyn FnMut(&Path, &str) -> bool,
745    ) -> io::Result<()>;
746}
747
748// ============================================================================
749// FileSystemExt - Async Extension Trait
750// ============================================================================
751
752/// Async extension trait for FileSystem
753///
754/// This trait provides async versions of FileSystem methods using native
755/// Rust async fn (no async_trait crate needed). Default implementations
756/// simply call the sync methods, which works for local filesystem operations.
757///
758/// For truly async backends (network FS, remote agents), implementations
759/// can override these methods with actual async implementations.
760///
761/// Note: This trait is NOT object-safe due to async fn. Use generics
762/// (`impl FileSystem` or `F: FileSystem`) instead of `dyn FileSystem`
763/// when async methods are needed.
764///
765/// # Example
766///
767/// ```ignore
768/// async fn list_files<F: FileSystem>(fs: &F, path: &Path) -> io::Result<Vec<DirEntry>> {
769///     fs.read_dir_async(path).await
770/// }
771/// ```
772pub trait FileSystemExt: FileSystem {
773    /// Async version of read_file
774    fn read_file_async(
775        &self,
776        path: &Path,
777    ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
778        async { self.read_file(path) }
779    }
780
781    /// Async version of read_range
782    fn read_range_async(
783        &self,
784        path: &Path,
785        offset: u64,
786        len: usize,
787    ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
788        async move { self.read_range(path, offset, len) }
789    }
790
791    /// Async version of count_line_feeds_in_range
792    fn count_line_feeds_in_range_async(
793        &self,
794        path: &Path,
795        offset: u64,
796        len: usize,
797    ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
798        async move { self.count_line_feeds_in_range(path, offset, len) }
799    }
800
801    /// Async version of write_file
802    fn write_file_async(
803        &self,
804        path: &Path,
805        data: &[u8],
806    ) -> impl std::future::Future<Output = io::Result<()>> + Send {
807        async { self.write_file(path, data) }
808    }
809
810    /// Async version of metadata
811    fn metadata_async(
812        &self,
813        path: &Path,
814    ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
815        async { self.metadata(path) }
816    }
817
818    /// Async version of exists
819    fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
820        async { self.exists(path) }
821    }
822
823    /// Async version of is_dir
824    fn is_dir_async(
825        &self,
826        path: &Path,
827    ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
828        async { self.is_dir(path) }
829    }
830
831    /// Async version of is_file
832    fn is_file_async(
833        &self,
834        path: &Path,
835    ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
836        async { self.is_file(path) }
837    }
838
839    /// Async version of read_dir
840    fn read_dir_async(
841        &self,
842        path: &Path,
843    ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
844        async { self.read_dir(path) }
845    }
846
847    /// Async version of canonicalize
848    fn canonicalize_async(
849        &self,
850        path: &Path,
851    ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
852        async { self.canonicalize(path) }
853    }
854}
855
856/// Blanket implementation: all FileSystem types automatically get async methods
857impl<T: FileSystem> FileSystemExt for T {}
858
859// ============================================================================
860// Default search_file implementation
861// ============================================================================
862
863/// Build a `regex::bytes::Regex` from a user-facing pattern and search options.
864pub fn build_search_regex(
865    pattern: &str,
866    opts: &FileSearchOptions,
867) -> io::Result<regex::bytes::Regex> {
868    let re_pattern = if opts.fixed_string {
869        regex::escape(pattern)
870    } else {
871        pattern.to_string()
872    };
873    let re_pattern = if opts.whole_word {
874        format!(r"\b{}\b", re_pattern)
875    } else {
876        re_pattern
877    };
878    let re_pattern = if opts.case_sensitive {
879        re_pattern
880    } else {
881        format!("(?i){}", re_pattern)
882    };
883    regex::bytes::Regex::new(&re_pattern)
884        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
885}
886
887/// Default implementation of `FileSystem::search_file` that works for any
888/// filesystem backend.  Reads one chunk via `read_range`, scans with the
889/// given regex, and returns matches with line/column/context.
890pub fn default_search_file(
891    fs: &dyn FileSystem,
892    path: &Path,
893    pattern: &str,
894    opts: &FileSearchOptions,
895    cursor: &mut FileSearchCursor,
896) -> io::Result<Vec<SearchMatch>> {
897    if cursor.done {
898        return Ok(vec![]);
899    }
900
901    const CHUNK_SIZE: usize = 1_048_576; // 1 MB
902    let overlap = pattern.len().max(256);
903
904    let file_len = fs.metadata(path)?.size as usize;
905    let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
906
907    // Binary check on first call (only when starting from offset 0 with no range bound)
908    if cursor.offset == 0 && cursor.end_offset.is_none() {
909        if file_len == 0 {
910            cursor.done = true;
911            return Ok(vec![]);
912        }
913        let header_len = file_len.min(8192);
914        let header = fs.read_range(path, 0, header_len)?;
915        if header.contains(&0) {
916            cursor.done = true;
917            return Ok(vec![]);
918        }
919    }
920
921    if cursor.offset >= effective_end {
922        cursor.done = true;
923        return Ok(vec![]);
924    }
925
926    let regex = build_search_regex(pattern, opts)?;
927
928    // Read chunk with overlap from previous
929    let read_start = cursor.offset.saturating_sub(overlap);
930    let read_end = (read_start + CHUNK_SIZE).min(effective_end);
931    let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
932
933    let overlap_len = cursor.offset - read_start;
934
935    // Incremental line counting (same algorithm as search_scan_next_chunk)
936    let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
937    let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
938    let mut counted_to = 0usize;
939    let mut matches = Vec::new();
940
941    for m in regex.find_iter(&chunk) {
942        // Skip matches in overlap region (already reported in previous batch)
943        if overlap_len > 0 && m.end() <= overlap_len {
944            continue;
945        }
946        if matches.len() >= opts.max_matches {
947            break;
948        }
949
950        // Count newlines from last position to this match
951        line_at += chunk[counted_to..m.start()]
952            .iter()
953            .filter(|&&b| b == b'\n')
954            .count();
955        counted_to = m.start();
956
957        // Find line boundaries for context
958        let line_start = chunk[..m.start()]
959            .iter()
960            .rposition(|&b| b == b'\n')
961            .map(|p| p + 1)
962            .unwrap_or(0);
963        let line_end = chunk[m.start()..]
964            .iter()
965            .position(|&b| b == b'\n')
966            .map(|p| m.start() + p)
967            .unwrap_or(chunk.len());
968
969        let column = m.start() - line_start + 1;
970        let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
971
972        matches.push(SearchMatch {
973            byte_offset: read_start + m.start(),
974            length: m.end() - m.start(),
975            line: line_at,
976            column,
977            context,
978        });
979    }
980
981    // Advance cursor
982    let new_data = &chunk[overlap_len..];
983    cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
984    cursor.offset = read_end;
985    if read_end >= effective_end {
986        cursor.done = true;
987    }
988
989    Ok(matches)
990}
991
992// ============================================================================
993// StdFileSystem Implementation
994// ============================================================================
995
996/// Standard filesystem implementation using `std::fs`
997///
998/// This is the default implementation for native builds.
999#[derive(Debug, Clone, Copy, Default)]
1000pub struct StdFileSystem;
1001
1002impl StdFileSystem {
1003    /// Check if a file is hidden (platform-specific)
1004    fn is_hidden(path: &Path) -> bool {
1005        path.file_name()
1006            .and_then(|n| n.to_str())
1007            .is_some_and(|n| n.starts_with('.'))
1008    }
1009
1010    /// Get the current user's effective UID and all group IDs (primary + supplementary).
1011    #[cfg(unix)]
1012    pub fn current_user_groups() -> (u32, Vec<u32>) {
1013        // SAFETY: these libc calls are always safe and have no failure modes
1014        let euid = unsafe { libc::geteuid() };
1015        let egid = unsafe { libc::getegid() };
1016        let mut groups = vec![egid];
1017
1018        // Get supplementary groups
1019        let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1020        if ngroups > 0 {
1021            let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1022            let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1023            if n > 0 {
1024                sup_groups.truncate(n as usize);
1025                for g in sup_groups {
1026                    if g != egid {
1027                        groups.push(g);
1028                    }
1029                }
1030            }
1031        }
1032
1033        (euid, groups)
1034    }
1035
1036    /// Build FileMetadata from std::fs::Metadata
1037    fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1038        #[cfg(unix)]
1039        {
1040            use std::os::unix::fs::MetadataExt;
1041            let file_uid = meta.uid();
1042            let file_gid = meta.gid();
1043            let permissions = FilePermissions::from_std(meta.permissions());
1044            let (euid, user_groups) = Self::current_user_groups();
1045            let is_readonly =
1046                permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups);
1047            FileMetadata {
1048                size: meta.len(),
1049                modified: meta.modified().ok(),
1050                permissions: Some(permissions),
1051                is_hidden: Self::is_hidden(path),
1052                is_readonly,
1053                uid: Some(file_uid),
1054                gid: Some(file_gid),
1055            }
1056        }
1057        #[cfg(not(unix))]
1058        {
1059            FileMetadata {
1060                size: meta.len(),
1061                modified: meta.modified().ok(),
1062                permissions: Some(FilePermissions::from_std(meta.permissions())),
1063                is_hidden: Self::is_hidden(path),
1064                is_readonly: meta.permissions().readonly(),
1065            }
1066        }
1067    }
1068}
1069
1070impl FileSystem for StdFileSystem {
1071    // File Content Operations
1072    fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1073        let data = std::fs::read(path)?;
1074        crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1075        Ok(data)
1076    }
1077
1078    fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1079        let mut file = std::fs::File::open(path)?;
1080        file.seek(io::SeekFrom::Start(offset))?;
1081        let mut buffer = vec![0u8; len];
1082        file.read_exact(&mut buffer)?;
1083        crate::services::counters::global().inc_disk_bytes_read(len as u64);
1084        Ok(buffer)
1085    }
1086
1087    fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1088        let original_metadata = self.metadata_if_exists(path);
1089        let temp_path = self.temp_path_for(path);
1090        {
1091            let mut file = self.create_file(&temp_path)?;
1092            file.write_all(data)?;
1093            file.sync_all()?;
1094        }
1095        if let Some(ref meta) = original_metadata {
1096            if let Some(ref perms) = meta.permissions {
1097                // Best-effort permission restore; rename will proceed regardless
1098                #[allow(clippy::let_underscore_must_use)]
1099                let _ = self.set_permissions(&temp_path, perms);
1100            }
1101        }
1102        self.rename(&temp_path, path)?;
1103        Ok(())
1104    }
1105
1106    fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1107        let file = std::fs::File::create(path)?;
1108        Ok(Box::new(StdFileWriter(file)))
1109    }
1110
1111    fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1112        let file = std::fs::File::open(path)?;
1113        Ok(Box::new(StdFileReader(file)))
1114    }
1115
1116    fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1117        let file = std::fs::OpenOptions::new()
1118            .write(true)
1119            .truncate(true)
1120            .open(path)?;
1121        Ok(Box::new(StdFileWriter(file)))
1122    }
1123
1124    fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1125        let file = std::fs::OpenOptions::new()
1126            .create(true)
1127            .append(true)
1128            .open(path)?;
1129        Ok(Box::new(StdFileWriter(file)))
1130    }
1131
1132    fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1133        let file = std::fs::OpenOptions::new().write(true).open(path)?;
1134        file.set_len(len)
1135    }
1136
1137    // File Operations
1138    fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1139        std::fs::rename(from, to)
1140    }
1141
1142    fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1143        std::fs::copy(from, to)
1144    }
1145
1146    fn remove_file(&self, path: &Path) -> io::Result<()> {
1147        std::fs::remove_file(path)
1148    }
1149
1150    fn remove_dir(&self, path: &Path) -> io::Result<()> {
1151        std::fs::remove_dir(path)
1152    }
1153
1154    // Metadata Operations
1155    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1156        let meta = std::fs::metadata(path)?;
1157        Ok(Self::build_metadata(path, &meta))
1158    }
1159
1160    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1161        let meta = std::fs::symlink_metadata(path)?;
1162        Ok(Self::build_metadata(path, &meta))
1163    }
1164
1165    fn is_dir(&self, path: &Path) -> io::Result<bool> {
1166        Ok(std::fs::metadata(path)?.is_dir())
1167    }
1168
1169    fn is_file(&self, path: &Path) -> io::Result<bool> {
1170        Ok(std::fs::metadata(path)?.is_file())
1171    }
1172
1173    fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1174        std::fs::set_permissions(path, permissions.to_std())
1175    }
1176
1177    // Directory Operations
1178    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1179        let mut entries = Vec::new();
1180        for entry in std::fs::read_dir(path)? {
1181            let entry = entry?;
1182            let path = entry.path();
1183            let name = entry.file_name().to_string_lossy().into_owned();
1184            let file_type = entry.file_type()?;
1185
1186            let entry_type = if file_type.is_dir() {
1187                EntryType::Directory
1188            } else if file_type.is_symlink() {
1189                EntryType::Symlink
1190            } else {
1191                EntryType::File
1192            };
1193
1194            let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1195
1196            // For symlinks, check if target is a directory
1197            if file_type.is_symlink() {
1198                dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1199                    .map(|m| m.is_dir())
1200                    .unwrap_or(false);
1201            }
1202
1203            entries.push(dir_entry);
1204        }
1205        Ok(entries)
1206    }
1207
1208    fn create_dir(&self, path: &Path) -> io::Result<()> {
1209        std::fs::create_dir(path)
1210    }
1211
1212    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1213        std::fs::create_dir_all(path)
1214    }
1215
1216    // Path Operations
1217    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1218        std::fs::canonicalize(path)
1219    }
1220
1221    // Utility
1222    fn current_uid(&self) -> u32 {
1223        #[cfg(all(unix, feature = "runtime"))]
1224        {
1225            // SAFETY: getuid() is a simple syscall with no arguments
1226            unsafe { libc::getuid() }
1227        }
1228        #[cfg(not(all(unix, feature = "runtime")))]
1229        {
1230            0
1231        }
1232    }
1233
1234    fn sudo_write(
1235        &self,
1236        path: &Path,
1237        data: &[u8],
1238        mode: u32,
1239        uid: u32,
1240        gid: u32,
1241    ) -> io::Result<()> {
1242        use std::process::{Command, Stdio};
1243
1244        // Write data via sudo tee
1245        let mut child = Command::new("sudo")
1246            .args(["tee", &path.to_string_lossy()])
1247            .stdin(Stdio::piped())
1248            .stdout(Stdio::null())
1249            .stderr(Stdio::piped())
1250            .spawn()
1251            .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1252
1253        if let Some(mut stdin) = child.stdin.take() {
1254            use std::io::Write;
1255            stdin.write_all(data)?;
1256        }
1257
1258        let output = child.wait_with_output()?;
1259        if !output.status.success() {
1260            let stderr = String::from_utf8_lossy(&output.stderr);
1261            return Err(io::Error::new(
1262                io::ErrorKind::PermissionDenied,
1263                format!("sudo tee failed: {}", stderr.trim()),
1264            ));
1265        }
1266
1267        // Set permissions via sudo chmod
1268        let status = Command::new("sudo")
1269            .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1270            .status()?;
1271        if !status.success() {
1272            return Err(io::Error::other("sudo chmod failed"));
1273        }
1274
1275        // Set ownership via sudo chown
1276        let status = Command::new("sudo")
1277            .args([
1278                "chown",
1279                &format!("{}:{}", uid, gid),
1280                &path.to_string_lossy(),
1281            ])
1282            .status()?;
1283        if !status.success() {
1284            return Err(io::Error::other("sudo chown failed"));
1285        }
1286
1287        Ok(())
1288    }
1289
1290    fn search_file(
1291        &self,
1292        path: &Path,
1293        pattern: &str,
1294        opts: &FileSearchOptions,
1295        cursor: &mut FileSearchCursor,
1296    ) -> io::Result<Vec<SearchMatch>> {
1297        default_search_file(self, path, pattern, opts, cursor)
1298    }
1299
1300    fn walk_files(
1301        &self,
1302        root: &Path,
1303        skip_dirs: &[&str],
1304        cancel: &std::sync::atomic::AtomicBool,
1305        on_file: &mut dyn FnMut(&Path, &str) -> bool,
1306    ) -> io::Result<()> {
1307        let mut stack = vec![root.to_path_buf()];
1308        while let Some(dir) = stack.pop() {
1309            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1310                return Ok(());
1311            }
1312
1313            // Use std::fs::read_dir iterator directly — NOT self.read_dir()
1314            // which collects into a Vec.  This keeps memory O(1) per directory
1315            // even for directories with millions of entries.
1316            let iter = match std::fs::read_dir(&dir) {
1317                Ok(it) => it,
1318                Err(_) => continue,
1319            };
1320
1321            for entry in iter {
1322                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1323                    return Ok(());
1324                }
1325                let entry = match entry {
1326                    Ok(e) => e,
1327                    Err(_) => continue,
1328                };
1329                let name = entry.file_name();
1330                let name_str = name.to_string_lossy();
1331
1332                // Skip hidden entries
1333                if name_str.starts_with('.') {
1334                    continue;
1335                }
1336
1337                let ft = match entry.file_type() {
1338                    Ok(ft) => ft,
1339                    Err(_) => continue,
1340                };
1341                let path = entry.path();
1342
1343                if ft.is_file() {
1344                    if let Ok(rel) = path.strip_prefix(root) {
1345                        let rel_str = rel.to_string_lossy().replace('\\', "/");
1346                        if !on_file(&path, &rel_str) {
1347                            return Ok(());
1348                        }
1349                    }
1350                } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1351                    stack.push(path);
1352                }
1353            }
1354        }
1355        Ok(())
1356    }
1357}
1358
1359// ============================================================================
1360// NoopFileSystem Implementation
1361// ============================================================================
1362
1363/// No-op filesystem that returns errors for all operations
1364///
1365/// Used as a placeholder or in WASM builds where a VirtualFileSystem
1366/// should be used instead.
1367#[derive(Debug, Clone, Copy, Default)]
1368pub struct NoopFileSystem;
1369
1370impl NoopFileSystem {
1371    fn unsupported<T>() -> io::Result<T> {
1372        Err(io::Error::new(
1373            io::ErrorKind::Unsupported,
1374            "Filesystem not available",
1375        ))
1376    }
1377}
1378
1379impl FileSystem for NoopFileSystem {
1380    fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1381        Self::unsupported()
1382    }
1383
1384    fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1385        Self::unsupported()
1386    }
1387
1388    fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1389        Self::unsupported()
1390    }
1391
1392    fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1393        Self::unsupported()
1394    }
1395
1396    fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1397        Self::unsupported()
1398    }
1399
1400    fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1401        Self::unsupported()
1402    }
1403
1404    fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1405        Self::unsupported()
1406    }
1407
1408    fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1409        Self::unsupported()
1410    }
1411
1412    fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1413        Self::unsupported()
1414    }
1415
1416    fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1417        Self::unsupported()
1418    }
1419
1420    fn remove_file(&self, _path: &Path) -> io::Result<()> {
1421        Self::unsupported()
1422    }
1423
1424    fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1425        Self::unsupported()
1426    }
1427
1428    fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1429        Self::unsupported()
1430    }
1431
1432    fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1433        Self::unsupported()
1434    }
1435
1436    fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1437        Self::unsupported()
1438    }
1439
1440    fn is_file(&self, _path: &Path) -> io::Result<bool> {
1441        Self::unsupported()
1442    }
1443
1444    fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1445        Self::unsupported()
1446    }
1447
1448    fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1449        Self::unsupported()
1450    }
1451
1452    fn create_dir(&self, _path: &Path) -> io::Result<()> {
1453        Self::unsupported()
1454    }
1455
1456    fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1457        Self::unsupported()
1458    }
1459
1460    fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1461        Self::unsupported()
1462    }
1463
1464    fn current_uid(&self) -> u32 {
1465        0
1466    }
1467
1468    fn search_file(
1469        &self,
1470        _path: &Path,
1471        _pattern: &str,
1472        _opts: &FileSearchOptions,
1473        _cursor: &mut FileSearchCursor,
1474    ) -> io::Result<Vec<SearchMatch>> {
1475        Self::unsupported()
1476    }
1477
1478    fn sudo_write(
1479        &self,
1480        _path: &Path,
1481        _data: &[u8],
1482        _mode: u32,
1483        _uid: u32,
1484        _gid: u32,
1485    ) -> io::Result<()> {
1486        Self::unsupported()
1487    }
1488
1489    fn walk_files(
1490        &self,
1491        _root: &Path,
1492        _skip_dirs: &[&str],
1493        _cancel: &std::sync::atomic::AtomicBool,
1494        _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1495    ) -> io::Result<()> {
1496        Self::unsupported()
1497    }
1498}
1499
1500// ============================================================================
1501// Tests
1502// ============================================================================
1503
1504#[cfg(test)]
1505mod tests {
1506    use super::*;
1507    use tempfile::NamedTempFile;
1508
1509    #[test]
1510    fn test_std_filesystem_read_write() {
1511        let fs = StdFileSystem;
1512        let mut temp = NamedTempFile::new().unwrap();
1513        let path = temp.path().to_path_buf();
1514
1515        std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1516        std::io::Write::flush(&mut temp).unwrap();
1517
1518        let content = fs.read_file(&path).unwrap();
1519        assert_eq!(content, b"Hello, World!");
1520
1521        let range = fs.read_range(&path, 7, 5).unwrap();
1522        assert_eq!(range, b"World");
1523
1524        let meta = fs.metadata(&path).unwrap();
1525        assert_eq!(meta.size, 13);
1526    }
1527
1528    #[test]
1529    fn test_noop_filesystem() {
1530        let fs = NoopFileSystem;
1531        let path = Path::new("/some/path");
1532
1533        assert!(fs.read_file(path).is_err());
1534        assert!(fs.read_range(path, 0, 10).is_err());
1535        assert!(fs.write_file(path, b"data").is_err());
1536        assert!(fs.metadata(path).is_err());
1537        assert!(fs.read_dir(path).is_err());
1538    }
1539
1540    #[test]
1541    fn test_create_and_write_file() {
1542        let fs = StdFileSystem;
1543        let temp_dir = tempfile::tempdir().unwrap();
1544        let path = temp_dir.path().join("test.txt");
1545
1546        {
1547            let mut writer = fs.create_file(&path).unwrap();
1548            writer.write_all(b"test content").unwrap();
1549            writer.sync_all().unwrap();
1550        }
1551
1552        let content = fs.read_file(&path).unwrap();
1553        assert_eq!(content, b"test content");
1554    }
1555
1556    #[test]
1557    fn test_read_dir() {
1558        let fs = StdFileSystem;
1559        let temp_dir = tempfile::tempdir().unwrap();
1560
1561        // Create some files and directories
1562        fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1563        fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1564            .unwrap();
1565        fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1566            .unwrap();
1567
1568        let entries = fs.read_dir(temp_dir.path()).unwrap();
1569        assert_eq!(entries.len(), 3);
1570
1571        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1572        assert!(names.contains(&"subdir"));
1573        assert!(names.contains(&"file1.txt"));
1574        assert!(names.contains(&"file2.txt"));
1575    }
1576
1577    #[test]
1578    fn test_dir_entry_types() {
1579        let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1580        assert!(file.is_file());
1581        assert!(!file.is_dir());
1582
1583        let dir = DirEntry::new(
1584            PathBuf::from("/dir"),
1585            "dir".to_string(),
1586            EntryType::Directory,
1587        );
1588        assert!(dir.is_dir());
1589        assert!(!dir.is_file());
1590
1591        let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1592        assert!(link_to_dir.is_symlink());
1593        assert!(link_to_dir.is_dir());
1594    }
1595
1596    #[test]
1597    fn test_metadata_builder() {
1598        let meta = FileMetadata::default()
1599            .with_hidden(true)
1600            .with_readonly(true);
1601        assert!(meta.is_hidden);
1602        assert!(meta.is_readonly);
1603    }
1604
1605    #[test]
1606    fn test_atomic_write() {
1607        let fs = StdFileSystem;
1608        let temp_dir = tempfile::tempdir().unwrap();
1609        let path = temp_dir.path().join("atomic_test.txt");
1610
1611        fs.write_file(&path, b"initial").unwrap();
1612        assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1613
1614        fs.write_file(&path, b"updated").unwrap();
1615        assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1616    }
1617
1618    #[test]
1619    fn test_write_patched_default_impl() {
1620        // Test that the default write_patched implementation works correctly
1621        let fs = StdFileSystem;
1622        let temp_dir = tempfile::tempdir().unwrap();
1623        let src_path = temp_dir.path().join("source.txt");
1624        let dst_path = temp_dir.path().join("dest.txt");
1625
1626        // Create source file with known content
1627        fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1628
1629        // Apply patch: copy first 3 bytes, insert "XXX", copy last 3 bytes
1630        let ops = vec![
1631            WriteOp::Copy { offset: 0, len: 3 }, // "AAA"
1632            WriteOp::Insert { data: b"XXX" },    // "XXX"
1633            WriteOp::Copy { offset: 6, len: 3 }, // "CCC"
1634        ];
1635
1636        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1637
1638        let result = fs.read_file(&dst_path).unwrap();
1639        assert_eq!(result, b"AAAXXXCCC");
1640    }
1641
1642    #[test]
1643    fn test_write_patched_same_file() {
1644        // Test patching a file in-place (src == dst)
1645        let fs = StdFileSystem;
1646        let temp_dir = tempfile::tempdir().unwrap();
1647        let path = temp_dir.path().join("file.txt");
1648
1649        // Create file
1650        fs.write_file(&path, b"Hello World").unwrap();
1651
1652        // Replace "World" with "Rust"
1653        let ops = vec![
1654            WriteOp::Copy { offset: 0, len: 6 }, // "Hello "
1655            WriteOp::Insert { data: b"Rust" },   // "Rust"
1656        ];
1657
1658        fs.write_patched(&path, &path, &ops).unwrap();
1659
1660        let result = fs.read_file(&path).unwrap();
1661        assert_eq!(result, b"Hello Rust");
1662    }
1663
1664    #[test]
1665    fn test_write_patched_insert_only() {
1666        // Test a patch with only inserts (new file)
1667        let fs = StdFileSystem;
1668        let temp_dir = tempfile::tempdir().unwrap();
1669        let src_path = temp_dir.path().join("empty.txt");
1670        let dst_path = temp_dir.path().join("new.txt");
1671
1672        // Create empty source (won't be read from)
1673        fs.write_file(&src_path, b"").unwrap();
1674
1675        let ops = vec![WriteOp::Insert {
1676            data: b"All new content",
1677        }];
1678
1679        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1680
1681        let result = fs.read_file(&dst_path).unwrap();
1682        assert_eq!(result, b"All new content");
1683    }
1684
1685    // ====================================================================
1686    // search_file tests
1687    // ====================================================================
1688
1689    fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1690        FileSearchOptions {
1691            fixed_string: pattern_is_fixed,
1692            case_sensitive: true,
1693            whole_word: false,
1694            max_matches: 100,
1695        }
1696    }
1697
1698    #[test]
1699    fn test_search_file_basic() {
1700        let fs = StdFileSystem;
1701        let temp_dir = tempfile::tempdir().unwrap();
1702        let path = temp_dir.path().join("test.txt");
1703        fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1704            .unwrap();
1705
1706        let opts = make_search_opts(true);
1707        let mut cursor = FileSearchCursor::new();
1708        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1709
1710        assert!(cursor.done);
1711        assert_eq!(matches.len(), 2);
1712
1713        assert_eq!(matches[0].line, 1);
1714        assert_eq!(matches[0].column, 1);
1715        assert_eq!(matches[0].context, "hello world");
1716
1717        assert_eq!(matches[1].line, 3);
1718        assert_eq!(matches[1].column, 1);
1719        assert_eq!(matches[1].context, "hello again");
1720    }
1721
1722    #[test]
1723    fn test_search_file_no_matches() {
1724        let fs = StdFileSystem;
1725        let temp_dir = tempfile::tempdir().unwrap();
1726        let path = temp_dir.path().join("test.txt");
1727        fs.write_file(&path, b"hello world\n").unwrap();
1728
1729        let opts = make_search_opts(true);
1730        let mut cursor = FileSearchCursor::new();
1731        let matches = fs
1732            .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1733            .unwrap();
1734
1735        assert!(cursor.done);
1736        assert!(matches.is_empty());
1737    }
1738
1739    #[test]
1740    fn test_search_file_case_insensitive() {
1741        let fs = StdFileSystem;
1742        let temp_dir = tempfile::tempdir().unwrap();
1743        let path = temp_dir.path().join("test.txt");
1744        fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1745
1746        let opts = FileSearchOptions {
1747            fixed_string: true,
1748            case_sensitive: false,
1749            whole_word: false,
1750            max_matches: 100,
1751        };
1752        let mut cursor = FileSearchCursor::new();
1753        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1754
1755        assert_eq!(matches.len(), 3);
1756    }
1757
1758    #[test]
1759    fn test_search_file_whole_word() {
1760        let fs = StdFileSystem;
1761        let temp_dir = tempfile::tempdir().unwrap();
1762        let path = temp_dir.path().join("test.txt");
1763        fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
1764
1765        let opts = FileSearchOptions {
1766            fixed_string: true,
1767            case_sensitive: true,
1768            whole_word: true,
1769            max_matches: 100,
1770        };
1771        let mut cursor = FileSearchCursor::new();
1772        let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
1773
1774        assert_eq!(matches.len(), 1);
1775        assert_eq!(matches[0].column, 1);
1776    }
1777
1778    #[test]
1779    fn test_search_file_regex() {
1780        let fs = StdFileSystem;
1781        let temp_dir = tempfile::tempdir().unwrap();
1782        let path = temp_dir.path().join("test.txt");
1783        fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
1784
1785        let opts = FileSearchOptions {
1786            fixed_string: false,
1787            case_sensitive: true,
1788            whole_word: false,
1789            max_matches: 100,
1790        };
1791        let mut cursor = FileSearchCursor::new();
1792        let matches = fs
1793            .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
1794            .unwrap();
1795
1796        assert_eq!(matches.len(), 2);
1797        assert_eq!(matches[0].context, "foo123 bar456 baz");
1798    }
1799
1800    #[test]
1801    fn test_search_file_binary_skipped() {
1802        let fs = StdFileSystem;
1803        let temp_dir = tempfile::tempdir().unwrap();
1804        let path = temp_dir.path().join("binary.dat");
1805        let mut data = b"hello world\n".to_vec();
1806        data.push(0); // null byte makes it binary
1807        data.extend_from_slice(b"hello again\n");
1808        fs.write_file(&path, &data).unwrap();
1809
1810        let opts = make_search_opts(true);
1811        let mut cursor = FileSearchCursor::new();
1812        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1813
1814        assert!(cursor.done);
1815        assert!(matches.is_empty());
1816    }
1817
1818    #[test]
1819    fn test_search_file_empty_file() {
1820        let fs = StdFileSystem;
1821        let temp_dir = tempfile::tempdir().unwrap();
1822        let path = temp_dir.path().join("empty.txt");
1823        fs.write_file(&path, b"").unwrap();
1824
1825        let opts = make_search_opts(true);
1826        let mut cursor = FileSearchCursor::new();
1827        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1828
1829        assert!(cursor.done);
1830        assert!(matches.is_empty());
1831    }
1832
1833    #[test]
1834    fn test_search_file_max_matches() {
1835        let fs = StdFileSystem;
1836        let temp_dir = tempfile::tempdir().unwrap();
1837        let path = temp_dir.path().join("test.txt");
1838        fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
1839
1840        let opts = FileSearchOptions {
1841            fixed_string: true,
1842            case_sensitive: true,
1843            whole_word: false,
1844            max_matches: 2,
1845        };
1846        let mut cursor = FileSearchCursor::new();
1847        let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
1848
1849        assert_eq!(matches.len(), 2);
1850    }
1851
1852    #[test]
1853    fn test_search_file_cursor_multi_chunk() {
1854        let fs = StdFileSystem;
1855        let temp_dir = tempfile::tempdir().unwrap();
1856        let path = temp_dir.path().join("large.txt");
1857
1858        // Create a file larger than 1MB chunk size to test cursor continuation
1859        let mut content = Vec::new();
1860        for i in 0..100_000 {
1861            content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
1862        }
1863        fs.write_file(&path, &content).unwrap();
1864
1865        let opts = FileSearchOptions {
1866            fixed_string: true,
1867            case_sensitive: true,
1868            whole_word: false,
1869            max_matches: 1000,
1870        };
1871        let mut cursor = FileSearchCursor::new();
1872        let mut all_matches = Vec::new();
1873
1874        while !cursor.done {
1875            let batch = fs
1876                .search_file(&path, "line 5000", &opts, &mut cursor)
1877                .unwrap();
1878            all_matches.extend(batch);
1879        }
1880
1881        // "line 5000" matches: "line 5000 ", "line 50000 "..  "line 50009 "
1882        // = 11 matches (5000, 50000-50009)
1883        assert_eq!(all_matches.len(), 11);
1884
1885        // Verify line numbers are correct
1886        let first = &all_matches[0];
1887        assert_eq!(first.line, 5001); // 0-indexed lines, 1-based line numbers
1888        assert_eq!(first.column, 1);
1889        assert!(first.context.starts_with("line 5000"));
1890    }
1891
1892    #[test]
1893    fn test_search_file_cursor_no_duplicates() {
1894        let fs = StdFileSystem;
1895        let temp_dir = tempfile::tempdir().unwrap();
1896        let path = temp_dir.path().join("large.txt");
1897
1898        // Create file with matches near chunk boundaries
1899        let mut content = Vec::new();
1900        for i in 0..100_000 {
1901            content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
1902        }
1903        fs.write_file(&path, &content).unwrap();
1904
1905        let opts = FileSearchOptions {
1906            fixed_string: true,
1907            case_sensitive: true,
1908            whole_word: false,
1909            max_matches: 200_000,
1910        };
1911        let mut cursor = FileSearchCursor::new();
1912        let mut all_matches = Vec::new();
1913        let mut batches = 0;
1914
1915        while !cursor.done {
1916            let batch = fs
1917                .search_file(&path, "MARKER_", &opts, &mut cursor)
1918                .unwrap();
1919            all_matches.extend(batch);
1920            batches += 1;
1921        }
1922
1923        // Must have multiple batches (file > 1MB)
1924        assert!(batches > 1, "Expected multiple batches, got {}", batches);
1925        // Exactly one match per line, no duplicates
1926        assert_eq!(all_matches.len(), 100_000);
1927        // Check no duplicate byte offsets
1928        let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
1929        offsets.sort();
1930        offsets.dedup();
1931        assert_eq!(offsets.len(), 100_000);
1932    }
1933
1934    #[test]
1935    fn test_search_file_line_numbers_across_chunks() {
1936        let fs = StdFileSystem;
1937        let temp_dir = tempfile::tempdir().unwrap();
1938        let path = temp_dir.path().join("large.txt");
1939
1940        // Create file where we know exact line numbers
1941        let mut content = Vec::new();
1942        let total_lines = 100_000;
1943        for i in 0..total_lines {
1944            if i == 99_999 {
1945                content.extend_from_slice(b"FINDME at the end\n");
1946            } else {
1947                content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
1948            }
1949        }
1950        fs.write_file(&path, &content).unwrap();
1951
1952        let opts = make_search_opts(true);
1953        let mut cursor = FileSearchCursor::new();
1954        let mut all_matches = Vec::new();
1955
1956        while !cursor.done {
1957            let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
1958            all_matches.extend(batch);
1959        }
1960
1961        assert_eq!(all_matches.len(), 1);
1962        assert_eq!(all_matches[0].line, total_lines); // last line
1963        assert_eq!(all_matches[0].context, "FINDME at the end");
1964    }
1965
1966    #[test]
1967    fn test_search_file_end_offset_bounds_search() {
1968        let fs = StdFileSystem;
1969        let temp_dir = tempfile::tempdir().unwrap();
1970        let path = temp_dir.path().join("bounded.txt");
1971
1972        // "AAA\nBBB\nCCC\nDDD\n" — each line is 4 bytes
1973        fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
1974
1975        // Search only the first 8 bytes ("AAA\nBBB\n") — should find AAA and BBB
1976        let opts = make_search_opts(true);
1977        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1978        let mut matches = Vec::new();
1979        while !cursor.done {
1980            matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
1981        }
1982        assert_eq!(matches.len(), 1);
1983        assert_eq!(matches[0].context, "AAA");
1984        assert_eq!(matches[0].line, 1);
1985
1986        // CCC is at byte 8, outside the first 8 bytes
1987        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1988        let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
1989        assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
1990
1991        // Search bytes 8..16 ("CCC\nDDD\n") — should find CCC
1992        let mut cursor = FileSearchCursor::for_range(8, 16, 3);
1993        let mut matches = Vec::new();
1994        while !cursor.done {
1995            matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
1996        }
1997        assert_eq!(matches.len(), 1);
1998        assert_eq!(matches[0].context, "CCC");
1999        assert_eq!(matches[0].line, 3);
2000    }
2001
2002    // ====================================================================
2003    // walk_files tests
2004    // ====================================================================
2005
2006    /// Helper: create a directory tree for walk_files tests.
2007    /// Returns the tempdir (must be kept alive for the duration of the test).
2008    fn make_walk_tree() -> tempfile::TempDir {
2009        let fs = StdFileSystem;
2010        let tmp = tempfile::tempdir().unwrap();
2011        let root = tmp.path();
2012
2013        // root/
2014        //   a.txt
2015        //   b.txt
2016        //   sub/
2017        //     c.txt
2018        //     deep/
2019        //       d.txt
2020        //   .hidden_dir/
2021        //     secret.txt
2022        //   .hidden_file
2023        //   node_modules/
2024        //     pkg.json
2025        //   target/
2026        //     debug.o
2027        fs.write_file(&root.join("a.txt"), b"a").unwrap();
2028        fs.write_file(&root.join("b.txt"), b"b").unwrap();
2029        fs.create_dir_all(&root.join("sub/deep")).unwrap();
2030        fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2031        fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2032        fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2033        fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2034            .unwrap();
2035        fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2036        fs.create_dir_all(&root.join("node_modules")).unwrap();
2037        fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2038            .unwrap();
2039        fs.create_dir_all(&root.join("target")).unwrap();
2040        fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2041
2042        tmp
2043    }
2044
2045    #[test]
2046    fn test_walk_files_std_basic() {
2047        let tmp = make_walk_tree();
2048        let fs = StdFileSystem;
2049        let cancel = std::sync::atomic::AtomicBool::new(false);
2050        let mut found: Vec<String> = Vec::new();
2051
2052        fs.walk_files(
2053            tmp.path(),
2054            &["node_modules", "target"],
2055            &cancel,
2056            &mut |_path, rel| {
2057                found.push(rel.to_string());
2058                true
2059            },
2060        )
2061        .unwrap();
2062
2063        found.sort();
2064        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2065    }
2066
2067    #[test]
2068    fn test_walk_files_std_skips_hidden() {
2069        let tmp = make_walk_tree();
2070        let fs = StdFileSystem;
2071        let cancel = std::sync::atomic::AtomicBool::new(false);
2072        let mut found: Vec<String> = Vec::new();
2073
2074        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2075            found.push(rel.to_string());
2076            true
2077        })
2078        .unwrap();
2079
2080        // Hidden files/dirs should be excluded, but node_modules and target
2081        // are NOT skipped (empty skip list)
2082        assert!(!found.iter().any(|f| f.contains(".hidden")));
2083        assert!(found.iter().any(|f| f.contains("node_modules")));
2084        assert!(found.iter().any(|f| f.contains("target")));
2085    }
2086
2087    #[test]
2088    fn test_walk_files_std_skip_dirs() {
2089        let tmp = make_walk_tree();
2090        let fs = StdFileSystem;
2091        let cancel = std::sync::atomic::AtomicBool::new(false);
2092        let mut found: Vec<String> = Vec::new();
2093
2094        fs.walk_files(
2095            tmp.path(),
2096            &["node_modules", "target", "deep"],
2097            &cancel,
2098            &mut |_path, rel| {
2099                found.push(rel.to_string());
2100                true
2101            },
2102        )
2103        .unwrap();
2104
2105        found.sort();
2106        // "deep" dir is also skipped, so d.txt should not appear
2107        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2108    }
2109
2110    #[test]
2111    fn test_walk_files_std_cancel() {
2112        let tmp = make_walk_tree();
2113        let fs = StdFileSystem;
2114        let cancel = std::sync::atomic::AtomicBool::new(false);
2115        let mut found: Vec<String> = Vec::new();
2116
2117        fs.walk_files(
2118            tmp.path(),
2119            &["node_modules", "target"],
2120            &cancel,
2121            &mut |_path, rel| {
2122                found.push(rel.to_string());
2123                // Cancel after finding the first file
2124                cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2125                true
2126            },
2127        )
2128        .unwrap();
2129
2130        assert_eq!(found.len(), 1, "Should stop after cancel is set");
2131    }
2132
2133    #[test]
2134    fn test_walk_files_std_on_file_returns_false() {
2135        let tmp = make_walk_tree();
2136        let fs = StdFileSystem;
2137        let cancel = std::sync::atomic::AtomicBool::new(false);
2138        let mut count = 0usize;
2139
2140        fs.walk_files(
2141            tmp.path(),
2142            &["node_modules", "target"],
2143            &cancel,
2144            &mut |_path, _rel| {
2145                count += 1;
2146                count < 2 // stop after 2 files
2147            },
2148        )
2149        .unwrap();
2150
2151        assert_eq!(count, 2, "Should stop when on_file returns false");
2152    }
2153
2154    #[test]
2155    fn test_walk_files_std_empty_dir() {
2156        let tmp = tempfile::tempdir().unwrap();
2157        let fs = StdFileSystem;
2158        let cancel = std::sync::atomic::AtomicBool::new(false);
2159        let mut found: Vec<String> = Vec::new();
2160
2161        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2162            found.push(rel.to_string());
2163            true
2164        })
2165        .unwrap();
2166
2167        assert!(found.is_empty());
2168    }
2169
2170    #[test]
2171    fn test_walk_files_std_nonexistent_root() {
2172        let fs = StdFileSystem;
2173        let cancel = std::sync::atomic::AtomicBool::new(false);
2174        let mut found: Vec<String> = Vec::new();
2175
2176        // Non-existent root should not panic, just return Ok with no files
2177        let result = fs.walk_files(
2178            Path::new("/nonexistent/path/that/does/not/exist"),
2179            &[],
2180            &cancel,
2181            &mut |_path, rel| {
2182                found.push(rel.to_string());
2183                true
2184            },
2185        );
2186
2187        assert!(result.is_ok());
2188        assert!(found.is_empty());
2189    }
2190
2191    #[test]
2192    fn test_walk_files_std_relative_paths_use_forward_slashes() {
2193        let tmp = make_walk_tree();
2194        let fs = StdFileSystem;
2195        let cancel = std::sync::atomic::AtomicBool::new(false);
2196        let mut found: Vec<String> = Vec::new();
2197
2198        fs.walk_files(
2199            tmp.path(),
2200            &["node_modules", "target"],
2201            &cancel,
2202            &mut |_path, rel| {
2203                found.push(rel.to_string());
2204                true
2205            },
2206        )
2207        .unwrap();
2208
2209        // All paths should use forward slashes (even on Windows)
2210        for path in &found {
2211            assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2212        }
2213    }
2214
2215    #[test]
2216    fn test_walk_files_noop_returns_error() {
2217        let fs = NoopFileSystem;
2218        let cancel = std::sync::atomic::AtomicBool::new(false);
2219
2220        let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2221            true
2222        });
2223
2224        assert!(result.is_err());
2225        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2226    }
2227}