Skip to main content

fresh/model/
filesystem.rs

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