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 crate::services::process_hidden::HideWindow;
1243        use std::process::{Command, Stdio};
1244
1245        // Write data via sudo tee
1246        let mut child = Command::new("sudo")
1247            .args(["tee", &path.to_string_lossy()])
1248            .stdin(Stdio::piped())
1249            .stdout(Stdio::null())
1250            .stderr(Stdio::piped())
1251            .hide_window()
1252            .spawn()
1253            .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1254
1255        if let Some(mut stdin) = child.stdin.take() {
1256            use std::io::Write;
1257            stdin.write_all(data)?;
1258        }
1259
1260        let output = child.wait_with_output()?;
1261        if !output.status.success() {
1262            let stderr = String::from_utf8_lossy(&output.stderr);
1263            return Err(io::Error::new(
1264                io::ErrorKind::PermissionDenied,
1265                format!("sudo tee failed: {}", stderr.trim()),
1266            ));
1267        }
1268
1269        // Set permissions via sudo chmod
1270        let status = Command::new("sudo")
1271            .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1272            .hide_window()
1273            .status()?;
1274        if !status.success() {
1275            return Err(io::Error::other("sudo chmod failed"));
1276        }
1277
1278        // Set ownership via sudo chown
1279        let status = Command::new("sudo")
1280            .args([
1281                "chown",
1282                &format!("{}:{}", uid, gid),
1283                &path.to_string_lossy(),
1284            ])
1285            .hide_window()
1286            .status()?;
1287        if !status.success() {
1288            return Err(io::Error::other("sudo chown failed"));
1289        }
1290
1291        Ok(())
1292    }
1293
1294    fn search_file(
1295        &self,
1296        path: &Path,
1297        pattern: &str,
1298        opts: &FileSearchOptions,
1299        cursor: &mut FileSearchCursor,
1300    ) -> io::Result<Vec<SearchMatch>> {
1301        default_search_file(self, path, pattern, opts, cursor)
1302    }
1303
1304    fn walk_files(
1305        &self,
1306        root: &Path,
1307        skip_dirs: &[&str],
1308        cancel: &std::sync::atomic::AtomicBool,
1309        on_file: &mut dyn FnMut(&Path, &str) -> bool,
1310    ) -> io::Result<()> {
1311        let mut stack = vec![root.to_path_buf()];
1312        while let Some(dir) = stack.pop() {
1313            if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1314                return Ok(());
1315            }
1316
1317            // Use std::fs::read_dir iterator directly — NOT self.read_dir()
1318            // which collects into a Vec.  This keeps memory O(1) per directory
1319            // even for directories with millions of entries.
1320            let iter = match std::fs::read_dir(&dir) {
1321                Ok(it) => it,
1322                Err(_) => continue,
1323            };
1324
1325            for entry in iter {
1326                if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1327                    return Ok(());
1328                }
1329                let entry = match entry {
1330                    Ok(e) => e,
1331                    Err(_) => continue,
1332                };
1333                let name = entry.file_name();
1334                let name_str = name.to_string_lossy();
1335
1336                // Skip hidden entries
1337                if name_str.starts_with('.') {
1338                    continue;
1339                }
1340
1341                let ft = match entry.file_type() {
1342                    Ok(ft) => ft,
1343                    Err(_) => continue,
1344                };
1345                let path = entry.path();
1346
1347                if ft.is_file() {
1348                    if let Ok(rel) = path.strip_prefix(root) {
1349                        let rel_str = rel.to_string_lossy().replace('\\', "/");
1350                        if !on_file(&path, &rel_str) {
1351                            return Ok(());
1352                        }
1353                    }
1354                } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1355                    stack.push(path);
1356                }
1357            }
1358        }
1359        Ok(())
1360    }
1361}
1362
1363// ============================================================================
1364// NoopFileSystem Implementation
1365// ============================================================================
1366
1367/// No-op filesystem that returns errors for all operations
1368///
1369/// Used as a placeholder or in WASM builds where a VirtualFileSystem
1370/// should be used instead.
1371#[derive(Debug, Clone, Copy, Default)]
1372pub struct NoopFileSystem;
1373
1374impl NoopFileSystem {
1375    fn unsupported<T>() -> io::Result<T> {
1376        Err(io::Error::new(
1377            io::ErrorKind::Unsupported,
1378            "Filesystem not available",
1379        ))
1380    }
1381}
1382
1383impl FileSystem for NoopFileSystem {
1384    fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1385        Self::unsupported()
1386    }
1387
1388    fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1389        Self::unsupported()
1390    }
1391
1392    fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1393        Self::unsupported()
1394    }
1395
1396    fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1397        Self::unsupported()
1398    }
1399
1400    fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1401        Self::unsupported()
1402    }
1403
1404    fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1405        Self::unsupported()
1406    }
1407
1408    fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1409        Self::unsupported()
1410    }
1411
1412    fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1413        Self::unsupported()
1414    }
1415
1416    fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1417        Self::unsupported()
1418    }
1419
1420    fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1421        Self::unsupported()
1422    }
1423
1424    fn remove_file(&self, _path: &Path) -> io::Result<()> {
1425        Self::unsupported()
1426    }
1427
1428    fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1429        Self::unsupported()
1430    }
1431
1432    fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1433        Self::unsupported()
1434    }
1435
1436    fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1437        Self::unsupported()
1438    }
1439
1440    fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1441        Self::unsupported()
1442    }
1443
1444    fn is_file(&self, _path: &Path) -> io::Result<bool> {
1445        Self::unsupported()
1446    }
1447
1448    fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1449        Self::unsupported()
1450    }
1451
1452    fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1453        Self::unsupported()
1454    }
1455
1456    fn create_dir(&self, _path: &Path) -> io::Result<()> {
1457        Self::unsupported()
1458    }
1459
1460    fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1461        Self::unsupported()
1462    }
1463
1464    fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1465        Self::unsupported()
1466    }
1467
1468    fn current_uid(&self) -> u32 {
1469        0
1470    }
1471
1472    fn search_file(
1473        &self,
1474        _path: &Path,
1475        _pattern: &str,
1476        _opts: &FileSearchOptions,
1477        _cursor: &mut FileSearchCursor,
1478    ) -> io::Result<Vec<SearchMatch>> {
1479        Self::unsupported()
1480    }
1481
1482    fn sudo_write(
1483        &self,
1484        _path: &Path,
1485        _data: &[u8],
1486        _mode: u32,
1487        _uid: u32,
1488        _gid: u32,
1489    ) -> io::Result<()> {
1490        Self::unsupported()
1491    }
1492
1493    fn walk_files(
1494        &self,
1495        _root: &Path,
1496        _skip_dirs: &[&str],
1497        _cancel: &std::sync::atomic::AtomicBool,
1498        _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1499    ) -> io::Result<()> {
1500        Self::unsupported()
1501    }
1502}
1503
1504// ============================================================================
1505// Tests
1506// ============================================================================
1507
1508#[cfg(test)]
1509mod tests {
1510    use super::*;
1511    use tempfile::NamedTempFile;
1512
1513    #[test]
1514    fn test_std_filesystem_read_write() {
1515        let fs = StdFileSystem;
1516        let mut temp = NamedTempFile::new().unwrap();
1517        let path = temp.path().to_path_buf();
1518
1519        std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1520        std::io::Write::flush(&mut temp).unwrap();
1521
1522        let content = fs.read_file(&path).unwrap();
1523        assert_eq!(content, b"Hello, World!");
1524
1525        let range = fs.read_range(&path, 7, 5).unwrap();
1526        assert_eq!(range, b"World");
1527
1528        let meta = fs.metadata(&path).unwrap();
1529        assert_eq!(meta.size, 13);
1530    }
1531
1532    #[test]
1533    fn test_noop_filesystem() {
1534        let fs = NoopFileSystem;
1535        let path = Path::new("/some/path");
1536
1537        assert!(fs.read_file(path).is_err());
1538        assert!(fs.read_range(path, 0, 10).is_err());
1539        assert!(fs.write_file(path, b"data").is_err());
1540        assert!(fs.metadata(path).is_err());
1541        assert!(fs.read_dir(path).is_err());
1542    }
1543
1544    #[test]
1545    fn test_create_and_write_file() {
1546        let fs = StdFileSystem;
1547        let temp_dir = tempfile::tempdir().unwrap();
1548        let path = temp_dir.path().join("test.txt");
1549
1550        {
1551            let mut writer = fs.create_file(&path).unwrap();
1552            writer.write_all(b"test content").unwrap();
1553            writer.sync_all().unwrap();
1554        }
1555
1556        let content = fs.read_file(&path).unwrap();
1557        assert_eq!(content, b"test content");
1558    }
1559
1560    #[test]
1561    fn test_read_dir() {
1562        let fs = StdFileSystem;
1563        let temp_dir = tempfile::tempdir().unwrap();
1564
1565        // Create some files and directories
1566        fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1567        fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1568            .unwrap();
1569        fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1570            .unwrap();
1571
1572        let entries = fs.read_dir(temp_dir.path()).unwrap();
1573        assert_eq!(entries.len(), 3);
1574
1575        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1576        assert!(names.contains(&"subdir"));
1577        assert!(names.contains(&"file1.txt"));
1578        assert!(names.contains(&"file2.txt"));
1579    }
1580
1581    #[test]
1582    fn test_dir_entry_types() {
1583        let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1584        assert!(file.is_file());
1585        assert!(!file.is_dir());
1586
1587        let dir = DirEntry::new(
1588            PathBuf::from("/dir"),
1589            "dir".to_string(),
1590            EntryType::Directory,
1591        );
1592        assert!(dir.is_dir());
1593        assert!(!dir.is_file());
1594
1595        let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1596        assert!(link_to_dir.is_symlink());
1597        assert!(link_to_dir.is_dir());
1598    }
1599
1600    #[test]
1601    fn test_metadata_builder() {
1602        let meta = FileMetadata::default()
1603            .with_hidden(true)
1604            .with_readonly(true);
1605        assert!(meta.is_hidden);
1606        assert!(meta.is_readonly);
1607    }
1608
1609    #[test]
1610    fn test_atomic_write() {
1611        let fs = StdFileSystem;
1612        let temp_dir = tempfile::tempdir().unwrap();
1613        let path = temp_dir.path().join("atomic_test.txt");
1614
1615        fs.write_file(&path, b"initial").unwrap();
1616        assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1617
1618        fs.write_file(&path, b"updated").unwrap();
1619        assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1620    }
1621
1622    #[test]
1623    fn test_write_patched_default_impl() {
1624        // Test that the default write_patched implementation works correctly
1625        let fs = StdFileSystem;
1626        let temp_dir = tempfile::tempdir().unwrap();
1627        let src_path = temp_dir.path().join("source.txt");
1628        let dst_path = temp_dir.path().join("dest.txt");
1629
1630        // Create source file with known content
1631        fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1632
1633        // Apply patch: copy first 3 bytes, insert "XXX", copy last 3 bytes
1634        let ops = vec![
1635            WriteOp::Copy { offset: 0, len: 3 }, // "AAA"
1636            WriteOp::Insert { data: b"XXX" },    // "XXX"
1637            WriteOp::Copy { offset: 6, len: 3 }, // "CCC"
1638        ];
1639
1640        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1641
1642        let result = fs.read_file(&dst_path).unwrap();
1643        assert_eq!(result, b"AAAXXXCCC");
1644    }
1645
1646    #[test]
1647    fn test_write_patched_same_file() {
1648        // Test patching a file in-place (src == dst)
1649        let fs = StdFileSystem;
1650        let temp_dir = tempfile::tempdir().unwrap();
1651        let path = temp_dir.path().join("file.txt");
1652
1653        // Create file
1654        fs.write_file(&path, b"Hello World").unwrap();
1655
1656        // Replace "World" with "Rust"
1657        let ops = vec![
1658            WriteOp::Copy { offset: 0, len: 6 }, // "Hello "
1659            WriteOp::Insert { data: b"Rust" },   // "Rust"
1660        ];
1661
1662        fs.write_patched(&path, &path, &ops).unwrap();
1663
1664        let result = fs.read_file(&path).unwrap();
1665        assert_eq!(result, b"Hello Rust");
1666    }
1667
1668    #[test]
1669    fn test_write_patched_insert_only() {
1670        // Test a patch with only inserts (new file)
1671        let fs = StdFileSystem;
1672        let temp_dir = tempfile::tempdir().unwrap();
1673        let src_path = temp_dir.path().join("empty.txt");
1674        let dst_path = temp_dir.path().join("new.txt");
1675
1676        // Create empty source (won't be read from)
1677        fs.write_file(&src_path, b"").unwrap();
1678
1679        let ops = vec![WriteOp::Insert {
1680            data: b"All new content",
1681        }];
1682
1683        fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1684
1685        let result = fs.read_file(&dst_path).unwrap();
1686        assert_eq!(result, b"All new content");
1687    }
1688
1689    // ====================================================================
1690    // search_file tests
1691    // ====================================================================
1692
1693    fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1694        FileSearchOptions {
1695            fixed_string: pattern_is_fixed,
1696            case_sensitive: true,
1697            whole_word: false,
1698            max_matches: 100,
1699        }
1700    }
1701
1702    #[test]
1703    fn test_search_file_basic() {
1704        let fs = StdFileSystem;
1705        let temp_dir = tempfile::tempdir().unwrap();
1706        let path = temp_dir.path().join("test.txt");
1707        fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1708            .unwrap();
1709
1710        let opts = make_search_opts(true);
1711        let mut cursor = FileSearchCursor::new();
1712        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1713
1714        assert!(cursor.done);
1715        assert_eq!(matches.len(), 2);
1716
1717        assert_eq!(matches[0].line, 1);
1718        assert_eq!(matches[0].column, 1);
1719        assert_eq!(matches[0].context, "hello world");
1720
1721        assert_eq!(matches[1].line, 3);
1722        assert_eq!(matches[1].column, 1);
1723        assert_eq!(matches[1].context, "hello again");
1724    }
1725
1726    #[test]
1727    fn test_search_file_no_matches() {
1728        let fs = StdFileSystem;
1729        let temp_dir = tempfile::tempdir().unwrap();
1730        let path = temp_dir.path().join("test.txt");
1731        fs.write_file(&path, b"hello world\n").unwrap();
1732
1733        let opts = make_search_opts(true);
1734        let mut cursor = FileSearchCursor::new();
1735        let matches = fs
1736            .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1737            .unwrap();
1738
1739        assert!(cursor.done);
1740        assert!(matches.is_empty());
1741    }
1742
1743    #[test]
1744    fn test_search_file_case_insensitive() {
1745        let fs = StdFileSystem;
1746        let temp_dir = tempfile::tempdir().unwrap();
1747        let path = temp_dir.path().join("test.txt");
1748        fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1749
1750        let opts = FileSearchOptions {
1751            fixed_string: true,
1752            case_sensitive: false,
1753            whole_word: false,
1754            max_matches: 100,
1755        };
1756        let mut cursor = FileSearchCursor::new();
1757        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1758
1759        assert_eq!(matches.len(), 3);
1760    }
1761
1762    #[test]
1763    fn test_search_file_whole_word() {
1764        let fs = StdFileSystem;
1765        let temp_dir = tempfile::tempdir().unwrap();
1766        let path = temp_dir.path().join("test.txt");
1767        fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
1768
1769        let opts = FileSearchOptions {
1770            fixed_string: true,
1771            case_sensitive: true,
1772            whole_word: true,
1773            max_matches: 100,
1774        };
1775        let mut cursor = FileSearchCursor::new();
1776        let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
1777
1778        assert_eq!(matches.len(), 1);
1779        assert_eq!(matches[0].column, 1);
1780    }
1781
1782    #[test]
1783    fn test_search_file_regex() {
1784        let fs = StdFileSystem;
1785        let temp_dir = tempfile::tempdir().unwrap();
1786        let path = temp_dir.path().join("test.txt");
1787        fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
1788
1789        let opts = FileSearchOptions {
1790            fixed_string: false,
1791            case_sensitive: true,
1792            whole_word: false,
1793            max_matches: 100,
1794        };
1795        let mut cursor = FileSearchCursor::new();
1796        let matches = fs
1797            .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
1798            .unwrap();
1799
1800        assert_eq!(matches.len(), 2);
1801        assert_eq!(matches[0].context, "foo123 bar456 baz");
1802    }
1803
1804    #[test]
1805    fn test_search_file_binary_skipped() {
1806        let fs = StdFileSystem;
1807        let temp_dir = tempfile::tempdir().unwrap();
1808        let path = temp_dir.path().join("binary.dat");
1809        let mut data = b"hello world\n".to_vec();
1810        data.push(0); // null byte makes it binary
1811        data.extend_from_slice(b"hello again\n");
1812        fs.write_file(&path, &data).unwrap();
1813
1814        let opts = make_search_opts(true);
1815        let mut cursor = FileSearchCursor::new();
1816        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1817
1818        assert!(cursor.done);
1819        assert!(matches.is_empty());
1820    }
1821
1822    #[test]
1823    fn test_search_file_empty_file() {
1824        let fs = StdFileSystem;
1825        let temp_dir = tempfile::tempdir().unwrap();
1826        let path = temp_dir.path().join("empty.txt");
1827        fs.write_file(&path, b"").unwrap();
1828
1829        let opts = make_search_opts(true);
1830        let mut cursor = FileSearchCursor::new();
1831        let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1832
1833        assert!(cursor.done);
1834        assert!(matches.is_empty());
1835    }
1836
1837    #[test]
1838    fn test_search_file_max_matches() {
1839        let fs = StdFileSystem;
1840        let temp_dir = tempfile::tempdir().unwrap();
1841        let path = temp_dir.path().join("test.txt");
1842        fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
1843
1844        let opts = FileSearchOptions {
1845            fixed_string: true,
1846            case_sensitive: true,
1847            whole_word: false,
1848            max_matches: 2,
1849        };
1850        let mut cursor = FileSearchCursor::new();
1851        let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
1852
1853        assert_eq!(matches.len(), 2);
1854    }
1855
1856    #[test]
1857    fn test_search_file_cursor_multi_chunk() {
1858        let fs = StdFileSystem;
1859        let temp_dir = tempfile::tempdir().unwrap();
1860        let path = temp_dir.path().join("large.txt");
1861
1862        // Create a file larger than 1MB chunk size to test cursor continuation
1863        let mut content = Vec::new();
1864        for i in 0..100_000 {
1865            content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
1866        }
1867        fs.write_file(&path, &content).unwrap();
1868
1869        let opts = FileSearchOptions {
1870            fixed_string: true,
1871            case_sensitive: true,
1872            whole_word: false,
1873            max_matches: 1000,
1874        };
1875        let mut cursor = FileSearchCursor::new();
1876        let mut all_matches = Vec::new();
1877
1878        while !cursor.done {
1879            let batch = fs
1880                .search_file(&path, "line 5000", &opts, &mut cursor)
1881                .unwrap();
1882            all_matches.extend(batch);
1883        }
1884
1885        // "line 5000" matches: "line 5000 ", "line 50000 "..  "line 50009 "
1886        // = 11 matches (5000, 50000-50009)
1887        assert_eq!(all_matches.len(), 11);
1888
1889        // Verify line numbers are correct
1890        let first = &all_matches[0];
1891        assert_eq!(first.line, 5001); // 0-indexed lines, 1-based line numbers
1892        assert_eq!(first.column, 1);
1893        assert!(first.context.starts_with("line 5000"));
1894    }
1895
1896    #[test]
1897    fn test_search_file_cursor_no_duplicates() {
1898        let fs = StdFileSystem;
1899        let temp_dir = tempfile::tempdir().unwrap();
1900        let path = temp_dir.path().join("large.txt");
1901
1902        // Create file with matches near chunk boundaries
1903        let mut content = Vec::new();
1904        for i in 0..100_000 {
1905            content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
1906        }
1907        fs.write_file(&path, &content).unwrap();
1908
1909        let opts = FileSearchOptions {
1910            fixed_string: true,
1911            case_sensitive: true,
1912            whole_word: false,
1913            max_matches: 200_000,
1914        };
1915        let mut cursor = FileSearchCursor::new();
1916        let mut all_matches = Vec::new();
1917        let mut batches = 0;
1918
1919        while !cursor.done {
1920            let batch = fs
1921                .search_file(&path, "MARKER_", &opts, &mut cursor)
1922                .unwrap();
1923            all_matches.extend(batch);
1924            batches += 1;
1925        }
1926
1927        // Must have multiple batches (file > 1MB)
1928        assert!(batches > 1, "Expected multiple batches, got {}", batches);
1929        // Exactly one match per line, no duplicates
1930        assert_eq!(all_matches.len(), 100_000);
1931        // Check no duplicate byte offsets
1932        let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
1933        offsets.sort();
1934        offsets.dedup();
1935        assert_eq!(offsets.len(), 100_000);
1936    }
1937
1938    #[test]
1939    fn test_search_file_line_numbers_across_chunks() {
1940        let fs = StdFileSystem;
1941        let temp_dir = tempfile::tempdir().unwrap();
1942        let path = temp_dir.path().join("large.txt");
1943
1944        // Create file where we know exact line numbers
1945        let mut content = Vec::new();
1946        let total_lines = 100_000;
1947        for i in 0..total_lines {
1948            if i == 99_999 {
1949                content.extend_from_slice(b"FINDME at the end\n");
1950            } else {
1951                content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
1952            }
1953        }
1954        fs.write_file(&path, &content).unwrap();
1955
1956        let opts = make_search_opts(true);
1957        let mut cursor = FileSearchCursor::new();
1958        let mut all_matches = Vec::new();
1959
1960        while !cursor.done {
1961            let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
1962            all_matches.extend(batch);
1963        }
1964
1965        assert_eq!(all_matches.len(), 1);
1966        assert_eq!(all_matches[0].line, total_lines); // last line
1967        assert_eq!(all_matches[0].context, "FINDME at the end");
1968    }
1969
1970    #[test]
1971    fn test_search_file_end_offset_bounds_search() {
1972        let fs = StdFileSystem;
1973        let temp_dir = tempfile::tempdir().unwrap();
1974        let path = temp_dir.path().join("bounded.txt");
1975
1976        // "AAA\nBBB\nCCC\nDDD\n" — each line is 4 bytes
1977        fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
1978
1979        // Search only the first 8 bytes ("AAA\nBBB\n") — should find AAA and BBB
1980        let opts = make_search_opts(true);
1981        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1982        let mut matches = Vec::new();
1983        while !cursor.done {
1984            matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
1985        }
1986        assert_eq!(matches.len(), 1);
1987        assert_eq!(matches[0].context, "AAA");
1988        assert_eq!(matches[0].line, 1);
1989
1990        // CCC is at byte 8, outside the first 8 bytes
1991        let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1992        let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
1993        assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
1994
1995        // Search bytes 8..16 ("CCC\nDDD\n") — should find CCC
1996        let mut cursor = FileSearchCursor::for_range(8, 16, 3);
1997        let mut matches = Vec::new();
1998        while !cursor.done {
1999            matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
2000        }
2001        assert_eq!(matches.len(), 1);
2002        assert_eq!(matches[0].context, "CCC");
2003        assert_eq!(matches[0].line, 3);
2004    }
2005
2006    // ====================================================================
2007    // walk_files tests
2008    // ====================================================================
2009
2010    /// Helper: create a directory tree for walk_files tests.
2011    /// Returns the tempdir (must be kept alive for the duration of the test).
2012    fn make_walk_tree() -> tempfile::TempDir {
2013        let fs = StdFileSystem;
2014        let tmp = tempfile::tempdir().unwrap();
2015        let root = tmp.path();
2016
2017        // root/
2018        //   a.txt
2019        //   b.txt
2020        //   sub/
2021        //     c.txt
2022        //     deep/
2023        //       d.txt
2024        //   .hidden_dir/
2025        //     secret.txt
2026        //   .hidden_file
2027        //   node_modules/
2028        //     pkg.json
2029        //   target/
2030        //     debug.o
2031        fs.write_file(&root.join("a.txt"), b"a").unwrap();
2032        fs.write_file(&root.join("b.txt"), b"b").unwrap();
2033        fs.create_dir_all(&root.join("sub/deep")).unwrap();
2034        fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2035        fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2036        fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2037        fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2038            .unwrap();
2039        fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2040        fs.create_dir_all(&root.join("node_modules")).unwrap();
2041        fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2042            .unwrap();
2043        fs.create_dir_all(&root.join("target")).unwrap();
2044        fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2045
2046        tmp
2047    }
2048
2049    #[test]
2050    fn test_walk_files_std_basic() {
2051        let tmp = make_walk_tree();
2052        let fs = StdFileSystem;
2053        let cancel = std::sync::atomic::AtomicBool::new(false);
2054        let mut found: Vec<String> = Vec::new();
2055
2056        fs.walk_files(
2057            tmp.path(),
2058            &["node_modules", "target"],
2059            &cancel,
2060            &mut |_path, rel| {
2061                found.push(rel.to_string());
2062                true
2063            },
2064        )
2065        .unwrap();
2066
2067        found.sort();
2068        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2069    }
2070
2071    #[test]
2072    fn test_walk_files_std_skips_hidden() {
2073        let tmp = make_walk_tree();
2074        let fs = StdFileSystem;
2075        let cancel = std::sync::atomic::AtomicBool::new(false);
2076        let mut found: Vec<String> = Vec::new();
2077
2078        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2079            found.push(rel.to_string());
2080            true
2081        })
2082        .unwrap();
2083
2084        // Hidden files/dirs should be excluded, but node_modules and target
2085        // are NOT skipped (empty skip list)
2086        assert!(!found.iter().any(|f| f.contains(".hidden")));
2087        assert!(found.iter().any(|f| f.contains("node_modules")));
2088        assert!(found.iter().any(|f| f.contains("target")));
2089    }
2090
2091    #[test]
2092    fn test_walk_files_std_skip_dirs() {
2093        let tmp = make_walk_tree();
2094        let fs = StdFileSystem;
2095        let cancel = std::sync::atomic::AtomicBool::new(false);
2096        let mut found: Vec<String> = Vec::new();
2097
2098        fs.walk_files(
2099            tmp.path(),
2100            &["node_modules", "target", "deep"],
2101            &cancel,
2102            &mut |_path, rel| {
2103                found.push(rel.to_string());
2104                true
2105            },
2106        )
2107        .unwrap();
2108
2109        found.sort();
2110        // "deep" dir is also skipped, so d.txt should not appear
2111        assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2112    }
2113
2114    #[test]
2115    fn test_walk_files_std_cancel() {
2116        let tmp = make_walk_tree();
2117        let fs = StdFileSystem;
2118        let cancel = std::sync::atomic::AtomicBool::new(false);
2119        let mut found: Vec<String> = Vec::new();
2120
2121        fs.walk_files(
2122            tmp.path(),
2123            &["node_modules", "target"],
2124            &cancel,
2125            &mut |_path, rel| {
2126                found.push(rel.to_string());
2127                // Cancel after finding the first file
2128                cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2129                true
2130            },
2131        )
2132        .unwrap();
2133
2134        assert_eq!(found.len(), 1, "Should stop after cancel is set");
2135    }
2136
2137    #[test]
2138    fn test_walk_files_std_on_file_returns_false() {
2139        let tmp = make_walk_tree();
2140        let fs = StdFileSystem;
2141        let cancel = std::sync::atomic::AtomicBool::new(false);
2142        let mut count = 0usize;
2143
2144        fs.walk_files(
2145            tmp.path(),
2146            &["node_modules", "target"],
2147            &cancel,
2148            &mut |_path, _rel| {
2149                count += 1;
2150                count < 2 // stop after 2 files
2151            },
2152        )
2153        .unwrap();
2154
2155        assert_eq!(count, 2, "Should stop when on_file returns false");
2156    }
2157
2158    #[test]
2159    fn test_walk_files_std_empty_dir() {
2160        let tmp = tempfile::tempdir().unwrap();
2161        let fs = StdFileSystem;
2162        let cancel = std::sync::atomic::AtomicBool::new(false);
2163        let mut found: Vec<String> = Vec::new();
2164
2165        fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2166            found.push(rel.to_string());
2167            true
2168        })
2169        .unwrap();
2170
2171        assert!(found.is_empty());
2172    }
2173
2174    #[test]
2175    fn test_walk_files_std_nonexistent_root() {
2176        let fs = StdFileSystem;
2177        let cancel = std::sync::atomic::AtomicBool::new(false);
2178        let mut found: Vec<String> = Vec::new();
2179
2180        // Non-existent root should not panic, just return Ok with no files
2181        let result = fs.walk_files(
2182            Path::new("/nonexistent/path/that/does/not/exist"),
2183            &[],
2184            &cancel,
2185            &mut |_path, rel| {
2186                found.push(rel.to_string());
2187                true
2188            },
2189        );
2190
2191        assert!(result.is_ok());
2192        assert!(found.is_empty());
2193    }
2194
2195    #[test]
2196    fn test_walk_files_std_relative_paths_use_forward_slashes() {
2197        let tmp = make_walk_tree();
2198        let fs = StdFileSystem;
2199        let cancel = std::sync::atomic::AtomicBool::new(false);
2200        let mut found: Vec<String> = Vec::new();
2201
2202        fs.walk_files(
2203            tmp.path(),
2204            &["node_modules", "target"],
2205            &cancel,
2206            &mut |_path, rel| {
2207                found.push(rel.to_string());
2208                true
2209            },
2210        )
2211        .unwrap();
2212
2213        // All paths should use forward slashes (even on Windows)
2214        for path in &found {
2215            assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2216        }
2217    }
2218
2219    #[test]
2220    fn test_walk_files_noop_returns_error() {
2221        let fs = NoopFileSystem;
2222        let cancel = std::sync::atomic::AtomicBool::new(false);
2223
2224        let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2225            true
2226        });
2227
2228        assert!(result.is_err());
2229        assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2230    }
2231}