Skip to main content

fresh/model/
filesystem.rs

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