1use std::io::{self, Read, Seek, Write};
13use std::path::{Path, PathBuf};
14use std::time::SystemTime;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum EntryType {
23 File,
24 Directory,
25 Symlink,
26}
27
28#[derive(Debug, Clone)]
30pub struct DirEntry {
31 pub path: PathBuf,
33 pub name: String,
35 pub entry_type: EntryType,
37 pub metadata: Option<FileMetadata>,
39 pub symlink_target_is_dir: bool,
41}
42
43impl DirEntry {
44 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 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 pub fn with_metadata(mut self, metadata: FileMetadata) -> Self {
68 self.metadata = Some(metadata);
69 self
70 }
71
72 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 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 pub fn is_symlink(&self) -> bool {
86 self.entry_type == EntryType::Symlink
87 }
88}
89
90#[derive(Debug, Clone)]
96pub struct FileMetadata {
97 pub size: u64,
99 pub modified: Option<SystemTime>,
101 pub permissions: Option<FilePermissions>,
103 pub is_hidden: bool,
105 pub is_readonly: bool,
107 #[cfg(unix)]
109 pub uid: Option<u32>,
110 #[cfg(unix)]
112 pub gid: Option<u32>,
113}
114
115impl FileMetadata {
116 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 pub fn with_modified(mut self, modified: SystemTime) -> Self {
133 self.modified = Some(modified);
134 self
135 }
136
137 pub fn with_hidden(mut self, hidden: bool) -> Self {
139 self.is_hidden = hidden;
140 self
141 }
142
143 pub fn with_readonly(mut self, readonly: bool) -> Self {
145 self.is_readonly = readonly;
146 self
147 }
148
149 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#[derive(Debug, Clone)]
164pub struct FilePermissions {
165 #[cfg(unix)]
166 mode: u32,
167 #[cfg(not(unix))]
168 readonly: bool,
169}
170
171impl FilePermissions {
172 #[cfg(unix)]
174 pub fn from_mode(mode: u32) -> Self {
175 Self { mode }
176 }
177
178 #[cfg(not(unix))]
180 pub fn from_mode(mode: u32) -> Self {
181 Self {
182 readonly: mode & 0o222 == 0,
183 }
184 }
185
186 #[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 #[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 #[cfg(unix)]
216 pub fn mode(&self) -> u32 {
217 self.mode
218 }
219
220 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 #[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 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
262pub trait FileWriter: Write + Send {
268 fn sync_all(&self) -> io::Result<()>;
270}
271
272#[derive(Debug, Clone)]
278pub enum WriteOp<'a> {
279 Copy { offset: u64, len: u64 },
281 Insert { data: &'a [u8] },
283}
284
285struct 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
304pub trait FileReader: Read + Seek + Send {}
306
307struct 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#[derive(Clone, Debug)]
339pub struct FileSearchOptions {
340 pub fixed_string: bool,
342 pub case_sensitive: bool,
344 pub whole_word: bool,
346 pub max_matches: usize,
348}
349
350#[derive(Clone, Debug)]
353pub struct FileSearchCursor {
354 pub offset: usize,
356 pub running_line: usize,
358 pub done: bool,
360 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 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#[derive(Clone, Debug)]
398pub struct SearchMatch {
399 pub byte_offset: usize,
401 pub length: usize,
403 pub line: usize,
405 pub column: usize,
407 pub context: String,
409}
410
411pub trait FileSystem: Send + Sync {
425 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
431
432 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>>;
434
435 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 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()>;
449
450 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
452
453 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>>;
455
456 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
458
459 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
461
462 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()>;
464
465 fn write_patched(&self, src_path: &Path, dst_path: &Path, ops: &[WriteOp]) -> io::Result<()> {
480 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 fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
502
503 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64>;
505
506 fn remove_file(&self, path: &Path) -> io::Result<()>;
508
509 fn remove_dir(&self, path: &Path) -> io::Result<()>;
511
512 fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
514 for entry in self.read_dir(path)? {
515 if entry.is_dir() {
516 self.remove_dir_all(&entry.path)?;
517 } else {
518 self.remove_file(&entry.path)?;
519 }
520 }
521 self.remove_dir(path)
522 }
523
524 fn copy_dir_all(&self, src: &Path, dst: &Path) -> io::Result<()> {
526 self.create_dir_all(dst)?;
527 for entry in self.read_dir(src)? {
528 let dst_child = dst.join(&entry.name);
529 if entry.is_dir() {
530 self.copy_dir_all(&entry.path, &dst_child)?;
531 } else {
532 self.copy(&entry.path, &dst_child)?;
533 }
534 }
535 Ok(())
536 }
537
538 fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
544
545 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
547
548 fn exists(&self, path: &Path) -> bool {
550 self.metadata(path).is_ok()
551 }
552
553 fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
555 self.metadata(path).ok()
556 }
557
558 fn is_dir(&self, path: &Path) -> io::Result<bool>;
560
561 fn is_file(&self, path: &Path) -> io::Result<bool>;
563
564 fn is_writable(&self, path: &Path) -> bool {
572 self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
573 }
574
575 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
577
578 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
584
585 fn create_dir(&self, path: &Path) -> io::Result<()>;
587
588 fn create_dir_all(&self, path: &Path) -> io::Result<()>;
590
591 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
597
598 fn current_uid(&self) -> u32;
604
605 fn is_owner(&self, path: &Path) -> bool {
607 #[cfg(unix)]
608 {
609 if let Ok(meta) = self.metadata(path) {
610 if let Some(uid) = meta.uid {
611 return uid == self.current_uid();
612 }
613 }
614 true
615 }
616 #[cfg(not(unix))]
617 {
618 let _ = path;
619 true
620 }
621 }
622
623 fn temp_path_for(&self, path: &Path) -> PathBuf {
625 path.with_extension("tmp")
626 }
627
628 fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
630 let temp_dir = std::env::temp_dir();
631 let file_name = dest_path
632 .file_name()
633 .unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
634 let timestamp = std::time::SystemTime::now()
635 .duration_since(std::time::UNIX_EPOCH)
636 .map(|d| d.as_nanos())
637 .unwrap_or(0);
638 temp_dir.join(format!(
639 "{}-{}-{}.tmp",
640 file_name.to_string_lossy(),
641 std::process::id(),
642 timestamp
643 ))
644 }
645
646 fn remote_connection_info(&self) -> Option<&str> {
655 None
656 }
657
658 fn is_remote_connected(&self) -> bool {
664 true
665 }
666
667 fn home_dir(&self) -> io::Result<PathBuf> {
672 dirs::home_dir()
673 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
674 }
675
676 fn search_file(
693 &self,
694 path: &Path,
695 pattern: &str,
696 opts: &FileSearchOptions,
697 cursor: &mut FileSearchCursor,
698 ) -> io::Result<Vec<SearchMatch>>;
699
700 fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
711 -> io::Result<()>;
712
713 fn walk_files(
740 &self,
741 root: &Path,
742 skip_dirs: &[&str],
743 cancel: &std::sync::atomic::AtomicBool,
744 on_file: &mut dyn FnMut(&Path, &str) -> bool,
745 ) -> io::Result<()>;
746}
747
748pub trait FileSystemExt: FileSystem {
773 fn read_file_async(
775 &self,
776 path: &Path,
777 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
778 async { self.read_file(path) }
779 }
780
781 fn read_range_async(
783 &self,
784 path: &Path,
785 offset: u64,
786 len: usize,
787 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
788 async move { self.read_range(path, offset, len) }
789 }
790
791 fn count_line_feeds_in_range_async(
793 &self,
794 path: &Path,
795 offset: u64,
796 len: usize,
797 ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
798 async move { self.count_line_feeds_in_range(path, offset, len) }
799 }
800
801 fn write_file_async(
803 &self,
804 path: &Path,
805 data: &[u8],
806 ) -> impl std::future::Future<Output = io::Result<()>> + Send {
807 async { self.write_file(path, data) }
808 }
809
810 fn metadata_async(
812 &self,
813 path: &Path,
814 ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
815 async { self.metadata(path) }
816 }
817
818 fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
820 async { self.exists(path) }
821 }
822
823 fn is_dir_async(
825 &self,
826 path: &Path,
827 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
828 async { self.is_dir(path) }
829 }
830
831 fn is_file_async(
833 &self,
834 path: &Path,
835 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
836 async { self.is_file(path) }
837 }
838
839 fn read_dir_async(
841 &self,
842 path: &Path,
843 ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
844 async { self.read_dir(path) }
845 }
846
847 fn canonicalize_async(
849 &self,
850 path: &Path,
851 ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
852 async { self.canonicalize(path) }
853 }
854}
855
856impl<T: FileSystem> FileSystemExt for T {}
858
859pub fn build_search_regex(
865 pattern: &str,
866 opts: &FileSearchOptions,
867) -> io::Result<regex::bytes::Regex> {
868 let re_pattern = if opts.fixed_string {
869 regex::escape(pattern)
870 } else {
871 pattern.to_string()
872 };
873 let re_pattern = if opts.whole_word {
874 format!(r"\b{}\b", re_pattern)
875 } else {
876 re_pattern
877 };
878 let re_pattern = if opts.case_sensitive {
879 re_pattern
880 } else {
881 format!("(?i){}", re_pattern)
882 };
883 regex::bytes::Regex::new(&re_pattern)
884 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
885}
886
887pub fn default_search_file(
891 fs: &dyn FileSystem,
892 path: &Path,
893 pattern: &str,
894 opts: &FileSearchOptions,
895 cursor: &mut FileSearchCursor,
896) -> io::Result<Vec<SearchMatch>> {
897 if cursor.done {
898 return Ok(vec![]);
899 }
900
901 const CHUNK_SIZE: usize = 1_048_576; let overlap = pattern.len().max(256);
903
904 let file_len = fs.metadata(path)?.size as usize;
905 let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
906
907 if cursor.offset == 0 && cursor.end_offset.is_none() {
909 if file_len == 0 {
910 cursor.done = true;
911 return Ok(vec![]);
912 }
913 let header_len = file_len.min(8192);
914 let header = fs.read_range(path, 0, header_len)?;
915 if header.contains(&0) {
916 cursor.done = true;
917 return Ok(vec![]);
918 }
919 }
920
921 if cursor.offset >= effective_end {
922 cursor.done = true;
923 return Ok(vec![]);
924 }
925
926 let regex = build_search_regex(pattern, opts)?;
927
928 let read_start = cursor.offset.saturating_sub(overlap);
930 let read_end = (read_start + CHUNK_SIZE).min(effective_end);
931 let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
932
933 let overlap_len = cursor.offset - read_start;
934
935 let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
937 let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
938 let mut counted_to = 0usize;
939 let mut matches = Vec::new();
940
941 for m in regex.find_iter(&chunk) {
942 if overlap_len > 0 && m.end() <= overlap_len {
944 continue;
945 }
946 if matches.len() >= opts.max_matches {
947 break;
948 }
949
950 line_at += chunk[counted_to..m.start()]
952 .iter()
953 .filter(|&&b| b == b'\n')
954 .count();
955 counted_to = m.start();
956
957 let line_start = chunk[..m.start()]
959 .iter()
960 .rposition(|&b| b == b'\n')
961 .map(|p| p + 1)
962 .unwrap_or(0);
963 let line_end = chunk[m.start()..]
964 .iter()
965 .position(|&b| b == b'\n')
966 .map(|p| m.start() + p)
967 .unwrap_or(chunk.len());
968
969 let column = m.start() - line_start + 1;
970 let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
971
972 matches.push(SearchMatch {
973 byte_offset: read_start + m.start(),
974 length: m.end() - m.start(),
975 line: line_at,
976 column,
977 context,
978 });
979 }
980
981 let new_data = &chunk[overlap_len..];
983 cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
984 cursor.offset = read_end;
985 if read_end >= effective_end {
986 cursor.done = true;
987 }
988
989 Ok(matches)
990}
991
992#[derive(Debug, Clone, Copy, Default)]
1000pub struct StdFileSystem;
1001
1002impl StdFileSystem {
1003 fn is_hidden(path: &Path) -> bool {
1005 path.file_name()
1006 .and_then(|n| n.to_str())
1007 .is_some_and(|n| n.starts_with('.'))
1008 }
1009
1010 #[cfg(unix)]
1012 pub fn current_user_groups() -> (u32, Vec<u32>) {
1013 let euid = unsafe { libc::geteuid() };
1015 let egid = unsafe { libc::getegid() };
1016 let mut groups = vec![egid];
1017
1018 let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1020 if ngroups > 0 {
1021 let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
1022 let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
1023 if n > 0 {
1024 sup_groups.truncate(n as usize);
1025 for g in sup_groups {
1026 if g != egid {
1027 groups.push(g);
1028 }
1029 }
1030 }
1031 }
1032
1033 (euid, groups)
1034 }
1035
1036 fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1038 #[cfg(unix)]
1039 {
1040 use std::os::unix::fs::MetadataExt;
1041 let file_uid = meta.uid();
1042 let file_gid = meta.gid();
1043 let permissions = FilePermissions::from_std(meta.permissions());
1044 let (euid, user_groups) = Self::current_user_groups();
1045 let is_readonly =
1046 permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups);
1047 FileMetadata {
1048 size: meta.len(),
1049 modified: meta.modified().ok(),
1050 permissions: Some(permissions),
1051 is_hidden: Self::is_hidden(path),
1052 is_readonly,
1053 uid: Some(file_uid),
1054 gid: Some(file_gid),
1055 }
1056 }
1057 #[cfg(not(unix))]
1058 {
1059 FileMetadata {
1060 size: meta.len(),
1061 modified: meta.modified().ok(),
1062 permissions: Some(FilePermissions::from_std(meta.permissions())),
1063 is_hidden: Self::is_hidden(path),
1064 is_readonly: meta.permissions().readonly(),
1065 }
1066 }
1067 }
1068}
1069
1070impl FileSystem for StdFileSystem {
1071 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1073 let data = std::fs::read(path)?;
1074 crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1075 Ok(data)
1076 }
1077
1078 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1079 let mut file = std::fs::File::open(path)?;
1080 file.seek(io::SeekFrom::Start(offset))?;
1081 let mut buffer = vec![0u8; len];
1082 file.read_exact(&mut buffer)?;
1083 crate::services::counters::global().inc_disk_bytes_read(len as u64);
1084 Ok(buffer)
1085 }
1086
1087 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1088 let original_metadata = self.metadata_if_exists(path);
1089 let temp_path = self.temp_path_for(path);
1090 {
1091 let mut file = self.create_file(&temp_path)?;
1092 file.write_all(data)?;
1093 file.sync_all()?;
1094 }
1095 if let Some(ref meta) = original_metadata {
1096 if let Some(ref perms) = meta.permissions {
1097 #[allow(clippy::let_underscore_must_use)]
1099 let _ = self.set_permissions(&temp_path, perms);
1100 }
1101 }
1102 self.rename(&temp_path, path)?;
1103 Ok(())
1104 }
1105
1106 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1107 let file = std::fs::File::create(path)?;
1108 Ok(Box::new(StdFileWriter(file)))
1109 }
1110
1111 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1112 let file = std::fs::File::open(path)?;
1113 Ok(Box::new(StdFileReader(file)))
1114 }
1115
1116 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1117 let file = std::fs::OpenOptions::new()
1118 .write(true)
1119 .truncate(true)
1120 .open(path)?;
1121 Ok(Box::new(StdFileWriter(file)))
1122 }
1123
1124 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1125 let file = std::fs::OpenOptions::new()
1126 .create(true)
1127 .append(true)
1128 .open(path)?;
1129 Ok(Box::new(StdFileWriter(file)))
1130 }
1131
1132 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1133 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1134 file.set_len(len)
1135 }
1136
1137 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1139 std::fs::rename(from, to)
1140 }
1141
1142 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1143 std::fs::copy(from, to)
1144 }
1145
1146 fn remove_file(&self, path: &Path) -> io::Result<()> {
1147 std::fs::remove_file(path)
1148 }
1149
1150 fn remove_dir(&self, path: &Path) -> io::Result<()> {
1151 std::fs::remove_dir(path)
1152 }
1153
1154 fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1156 let meta = std::fs::metadata(path)?;
1157 Ok(Self::build_metadata(path, &meta))
1158 }
1159
1160 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1161 let meta = std::fs::symlink_metadata(path)?;
1162 Ok(Self::build_metadata(path, &meta))
1163 }
1164
1165 fn is_dir(&self, path: &Path) -> io::Result<bool> {
1166 Ok(std::fs::metadata(path)?.is_dir())
1167 }
1168
1169 fn is_file(&self, path: &Path) -> io::Result<bool> {
1170 Ok(std::fs::metadata(path)?.is_file())
1171 }
1172
1173 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1174 std::fs::set_permissions(path, permissions.to_std())
1175 }
1176
1177 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1179 let mut entries = Vec::new();
1180 for entry in std::fs::read_dir(path)? {
1181 let entry = entry?;
1182 let path = entry.path();
1183 let name = entry.file_name().to_string_lossy().into_owned();
1184 let file_type = entry.file_type()?;
1185
1186 let entry_type = if file_type.is_dir() {
1187 EntryType::Directory
1188 } else if file_type.is_symlink() {
1189 EntryType::Symlink
1190 } else {
1191 EntryType::File
1192 };
1193
1194 let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1195
1196 if file_type.is_symlink() {
1198 dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1199 .map(|m| m.is_dir())
1200 .unwrap_or(false);
1201 }
1202
1203 entries.push(dir_entry);
1204 }
1205 Ok(entries)
1206 }
1207
1208 fn create_dir(&self, path: &Path) -> io::Result<()> {
1209 std::fs::create_dir(path)
1210 }
1211
1212 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1213 std::fs::create_dir_all(path)
1214 }
1215
1216 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1218 std::fs::canonicalize(path)
1219 }
1220
1221 fn current_uid(&self) -> u32 {
1223 #[cfg(all(unix, feature = "runtime"))]
1224 {
1225 unsafe { libc::getuid() }
1227 }
1228 #[cfg(not(all(unix, feature = "runtime")))]
1229 {
1230 0
1231 }
1232 }
1233
1234 fn sudo_write(
1235 &self,
1236 path: &Path,
1237 data: &[u8],
1238 mode: u32,
1239 uid: u32,
1240 gid: u32,
1241 ) -> io::Result<()> {
1242 use std::process::{Command, Stdio};
1243
1244 let mut child = Command::new("sudo")
1246 .args(["tee", &path.to_string_lossy()])
1247 .stdin(Stdio::piped())
1248 .stdout(Stdio::null())
1249 .stderr(Stdio::piped())
1250 .spawn()
1251 .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1252
1253 if let Some(mut stdin) = child.stdin.take() {
1254 use std::io::Write;
1255 stdin.write_all(data)?;
1256 }
1257
1258 let output = child.wait_with_output()?;
1259 if !output.status.success() {
1260 let stderr = String::from_utf8_lossy(&output.stderr);
1261 return Err(io::Error::new(
1262 io::ErrorKind::PermissionDenied,
1263 format!("sudo tee failed: {}", stderr.trim()),
1264 ));
1265 }
1266
1267 let status = Command::new("sudo")
1269 .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1270 .status()?;
1271 if !status.success() {
1272 return Err(io::Error::other("sudo chmod failed"));
1273 }
1274
1275 let status = Command::new("sudo")
1277 .args([
1278 "chown",
1279 &format!("{}:{}", uid, gid),
1280 &path.to_string_lossy(),
1281 ])
1282 .status()?;
1283 if !status.success() {
1284 return Err(io::Error::other("sudo chown failed"));
1285 }
1286
1287 Ok(())
1288 }
1289
1290 fn search_file(
1291 &self,
1292 path: &Path,
1293 pattern: &str,
1294 opts: &FileSearchOptions,
1295 cursor: &mut FileSearchCursor,
1296 ) -> io::Result<Vec<SearchMatch>> {
1297 default_search_file(self, path, pattern, opts, cursor)
1298 }
1299
1300 fn walk_files(
1301 &self,
1302 root: &Path,
1303 skip_dirs: &[&str],
1304 cancel: &std::sync::atomic::AtomicBool,
1305 on_file: &mut dyn FnMut(&Path, &str) -> bool,
1306 ) -> io::Result<()> {
1307 let mut stack = vec![root.to_path_buf()];
1308 while let Some(dir) = stack.pop() {
1309 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1310 return Ok(());
1311 }
1312
1313 let iter = match std::fs::read_dir(&dir) {
1317 Ok(it) => it,
1318 Err(_) => continue,
1319 };
1320
1321 for entry in iter {
1322 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1323 return Ok(());
1324 }
1325 let entry = match entry {
1326 Ok(e) => e,
1327 Err(_) => continue,
1328 };
1329 let name = entry.file_name();
1330 let name_str = name.to_string_lossy();
1331
1332 if name_str.starts_with('.') {
1334 continue;
1335 }
1336
1337 let ft = match entry.file_type() {
1338 Ok(ft) => ft,
1339 Err(_) => continue,
1340 };
1341 let path = entry.path();
1342
1343 if ft.is_file() {
1344 if let Ok(rel) = path.strip_prefix(root) {
1345 let rel_str = rel.to_string_lossy().replace('\\', "/");
1346 if !on_file(&path, &rel_str) {
1347 return Ok(());
1348 }
1349 }
1350 } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1351 stack.push(path);
1352 }
1353 }
1354 }
1355 Ok(())
1356 }
1357}
1358
1359#[derive(Debug, Clone, Copy, Default)]
1368pub struct NoopFileSystem;
1369
1370impl NoopFileSystem {
1371 fn unsupported<T>() -> io::Result<T> {
1372 Err(io::Error::new(
1373 io::ErrorKind::Unsupported,
1374 "Filesystem not available",
1375 ))
1376 }
1377}
1378
1379impl FileSystem for NoopFileSystem {
1380 fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1381 Self::unsupported()
1382 }
1383
1384 fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1385 Self::unsupported()
1386 }
1387
1388 fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1389 Self::unsupported()
1390 }
1391
1392 fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1393 Self::unsupported()
1394 }
1395
1396 fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1397 Self::unsupported()
1398 }
1399
1400 fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1401 Self::unsupported()
1402 }
1403
1404 fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1405 Self::unsupported()
1406 }
1407
1408 fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1409 Self::unsupported()
1410 }
1411
1412 fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1413 Self::unsupported()
1414 }
1415
1416 fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1417 Self::unsupported()
1418 }
1419
1420 fn remove_file(&self, _path: &Path) -> io::Result<()> {
1421 Self::unsupported()
1422 }
1423
1424 fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1425 Self::unsupported()
1426 }
1427
1428 fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1429 Self::unsupported()
1430 }
1431
1432 fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1433 Self::unsupported()
1434 }
1435
1436 fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1437 Self::unsupported()
1438 }
1439
1440 fn is_file(&self, _path: &Path) -> io::Result<bool> {
1441 Self::unsupported()
1442 }
1443
1444 fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1445 Self::unsupported()
1446 }
1447
1448 fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1449 Self::unsupported()
1450 }
1451
1452 fn create_dir(&self, _path: &Path) -> io::Result<()> {
1453 Self::unsupported()
1454 }
1455
1456 fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1457 Self::unsupported()
1458 }
1459
1460 fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1461 Self::unsupported()
1462 }
1463
1464 fn current_uid(&self) -> u32 {
1465 0
1466 }
1467
1468 fn search_file(
1469 &self,
1470 _path: &Path,
1471 _pattern: &str,
1472 _opts: &FileSearchOptions,
1473 _cursor: &mut FileSearchCursor,
1474 ) -> io::Result<Vec<SearchMatch>> {
1475 Self::unsupported()
1476 }
1477
1478 fn sudo_write(
1479 &self,
1480 _path: &Path,
1481 _data: &[u8],
1482 _mode: u32,
1483 _uid: u32,
1484 _gid: u32,
1485 ) -> io::Result<()> {
1486 Self::unsupported()
1487 }
1488
1489 fn walk_files(
1490 &self,
1491 _root: &Path,
1492 _skip_dirs: &[&str],
1493 _cancel: &std::sync::atomic::AtomicBool,
1494 _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1495 ) -> io::Result<()> {
1496 Self::unsupported()
1497 }
1498}
1499
1500#[cfg(test)]
1505mod tests {
1506 use super::*;
1507 use tempfile::NamedTempFile;
1508
1509 #[test]
1510 fn test_std_filesystem_read_write() {
1511 let fs = StdFileSystem;
1512 let mut temp = NamedTempFile::new().unwrap();
1513 let path = temp.path().to_path_buf();
1514
1515 std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1516 std::io::Write::flush(&mut temp).unwrap();
1517
1518 let content = fs.read_file(&path).unwrap();
1519 assert_eq!(content, b"Hello, World!");
1520
1521 let range = fs.read_range(&path, 7, 5).unwrap();
1522 assert_eq!(range, b"World");
1523
1524 let meta = fs.metadata(&path).unwrap();
1525 assert_eq!(meta.size, 13);
1526 }
1527
1528 #[test]
1529 fn test_noop_filesystem() {
1530 let fs = NoopFileSystem;
1531 let path = Path::new("/some/path");
1532
1533 assert!(fs.read_file(path).is_err());
1534 assert!(fs.read_range(path, 0, 10).is_err());
1535 assert!(fs.write_file(path, b"data").is_err());
1536 assert!(fs.metadata(path).is_err());
1537 assert!(fs.read_dir(path).is_err());
1538 }
1539
1540 #[test]
1541 fn test_create_and_write_file() {
1542 let fs = StdFileSystem;
1543 let temp_dir = tempfile::tempdir().unwrap();
1544 let path = temp_dir.path().join("test.txt");
1545
1546 {
1547 let mut writer = fs.create_file(&path).unwrap();
1548 writer.write_all(b"test content").unwrap();
1549 writer.sync_all().unwrap();
1550 }
1551
1552 let content = fs.read_file(&path).unwrap();
1553 assert_eq!(content, b"test content");
1554 }
1555
1556 #[test]
1557 fn test_read_dir() {
1558 let fs = StdFileSystem;
1559 let temp_dir = tempfile::tempdir().unwrap();
1560
1561 fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1563 fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1564 .unwrap();
1565 fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1566 .unwrap();
1567
1568 let entries = fs.read_dir(temp_dir.path()).unwrap();
1569 assert_eq!(entries.len(), 3);
1570
1571 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1572 assert!(names.contains(&"subdir"));
1573 assert!(names.contains(&"file1.txt"));
1574 assert!(names.contains(&"file2.txt"));
1575 }
1576
1577 #[test]
1578 fn test_dir_entry_types() {
1579 let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1580 assert!(file.is_file());
1581 assert!(!file.is_dir());
1582
1583 let dir = DirEntry::new(
1584 PathBuf::from("/dir"),
1585 "dir".to_string(),
1586 EntryType::Directory,
1587 );
1588 assert!(dir.is_dir());
1589 assert!(!dir.is_file());
1590
1591 let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1592 assert!(link_to_dir.is_symlink());
1593 assert!(link_to_dir.is_dir());
1594 }
1595
1596 #[test]
1597 fn test_metadata_builder() {
1598 let meta = FileMetadata::default()
1599 .with_hidden(true)
1600 .with_readonly(true);
1601 assert!(meta.is_hidden);
1602 assert!(meta.is_readonly);
1603 }
1604
1605 #[test]
1606 fn test_atomic_write() {
1607 let fs = StdFileSystem;
1608 let temp_dir = tempfile::tempdir().unwrap();
1609 let path = temp_dir.path().join("atomic_test.txt");
1610
1611 fs.write_file(&path, b"initial").unwrap();
1612 assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1613
1614 fs.write_file(&path, b"updated").unwrap();
1615 assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1616 }
1617
1618 #[test]
1619 fn test_write_patched_default_impl() {
1620 let fs = StdFileSystem;
1622 let temp_dir = tempfile::tempdir().unwrap();
1623 let src_path = temp_dir.path().join("source.txt");
1624 let dst_path = temp_dir.path().join("dest.txt");
1625
1626 fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1628
1629 let ops = vec![
1631 WriteOp::Copy { offset: 0, len: 3 }, WriteOp::Insert { data: b"XXX" }, WriteOp::Copy { offset: 6, len: 3 }, ];
1635
1636 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1637
1638 let result = fs.read_file(&dst_path).unwrap();
1639 assert_eq!(result, b"AAAXXXCCC");
1640 }
1641
1642 #[test]
1643 fn test_write_patched_same_file() {
1644 let fs = StdFileSystem;
1646 let temp_dir = tempfile::tempdir().unwrap();
1647 let path = temp_dir.path().join("file.txt");
1648
1649 fs.write_file(&path, b"Hello World").unwrap();
1651
1652 let ops = vec![
1654 WriteOp::Copy { offset: 0, len: 6 }, WriteOp::Insert { data: b"Rust" }, ];
1657
1658 fs.write_patched(&path, &path, &ops).unwrap();
1659
1660 let result = fs.read_file(&path).unwrap();
1661 assert_eq!(result, b"Hello Rust");
1662 }
1663
1664 #[test]
1665 fn test_write_patched_insert_only() {
1666 let fs = StdFileSystem;
1668 let temp_dir = tempfile::tempdir().unwrap();
1669 let src_path = temp_dir.path().join("empty.txt");
1670 let dst_path = temp_dir.path().join("new.txt");
1671
1672 fs.write_file(&src_path, b"").unwrap();
1674
1675 let ops = vec![WriteOp::Insert {
1676 data: b"All new content",
1677 }];
1678
1679 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1680
1681 let result = fs.read_file(&dst_path).unwrap();
1682 assert_eq!(result, b"All new content");
1683 }
1684
1685 fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1690 FileSearchOptions {
1691 fixed_string: pattern_is_fixed,
1692 case_sensitive: true,
1693 whole_word: false,
1694 max_matches: 100,
1695 }
1696 }
1697
1698 #[test]
1699 fn test_search_file_basic() {
1700 let fs = StdFileSystem;
1701 let temp_dir = tempfile::tempdir().unwrap();
1702 let path = temp_dir.path().join("test.txt");
1703 fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1704 .unwrap();
1705
1706 let opts = make_search_opts(true);
1707 let mut cursor = FileSearchCursor::new();
1708 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1709
1710 assert!(cursor.done);
1711 assert_eq!(matches.len(), 2);
1712
1713 assert_eq!(matches[0].line, 1);
1714 assert_eq!(matches[0].column, 1);
1715 assert_eq!(matches[0].context, "hello world");
1716
1717 assert_eq!(matches[1].line, 3);
1718 assert_eq!(matches[1].column, 1);
1719 assert_eq!(matches[1].context, "hello again");
1720 }
1721
1722 #[test]
1723 fn test_search_file_no_matches() {
1724 let fs = StdFileSystem;
1725 let temp_dir = tempfile::tempdir().unwrap();
1726 let path = temp_dir.path().join("test.txt");
1727 fs.write_file(&path, b"hello world\n").unwrap();
1728
1729 let opts = make_search_opts(true);
1730 let mut cursor = FileSearchCursor::new();
1731 let matches = fs
1732 .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1733 .unwrap();
1734
1735 assert!(cursor.done);
1736 assert!(matches.is_empty());
1737 }
1738
1739 #[test]
1740 fn test_search_file_case_insensitive() {
1741 let fs = StdFileSystem;
1742 let temp_dir = tempfile::tempdir().unwrap();
1743 let path = temp_dir.path().join("test.txt");
1744 fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1745
1746 let opts = FileSearchOptions {
1747 fixed_string: true,
1748 case_sensitive: false,
1749 whole_word: false,
1750 max_matches: 100,
1751 };
1752 let mut cursor = FileSearchCursor::new();
1753 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1754
1755 assert_eq!(matches.len(), 3);
1756 }
1757
1758 #[test]
1759 fn test_search_file_whole_word() {
1760 let fs = StdFileSystem;
1761 let temp_dir = tempfile::tempdir().unwrap();
1762 let path = temp_dir.path().join("test.txt");
1763 fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
1764
1765 let opts = FileSearchOptions {
1766 fixed_string: true,
1767 case_sensitive: true,
1768 whole_word: true,
1769 max_matches: 100,
1770 };
1771 let mut cursor = FileSearchCursor::new();
1772 let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
1773
1774 assert_eq!(matches.len(), 1);
1775 assert_eq!(matches[0].column, 1);
1776 }
1777
1778 #[test]
1779 fn test_search_file_regex() {
1780 let fs = StdFileSystem;
1781 let temp_dir = tempfile::tempdir().unwrap();
1782 let path = temp_dir.path().join("test.txt");
1783 fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
1784
1785 let opts = FileSearchOptions {
1786 fixed_string: false,
1787 case_sensitive: true,
1788 whole_word: false,
1789 max_matches: 100,
1790 };
1791 let mut cursor = FileSearchCursor::new();
1792 let matches = fs
1793 .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
1794 .unwrap();
1795
1796 assert_eq!(matches.len(), 2);
1797 assert_eq!(matches[0].context, "foo123 bar456 baz");
1798 }
1799
1800 #[test]
1801 fn test_search_file_binary_skipped() {
1802 let fs = StdFileSystem;
1803 let temp_dir = tempfile::tempdir().unwrap();
1804 let path = temp_dir.path().join("binary.dat");
1805 let mut data = b"hello world\n".to_vec();
1806 data.push(0); data.extend_from_slice(b"hello again\n");
1808 fs.write_file(&path, &data).unwrap();
1809
1810 let opts = make_search_opts(true);
1811 let mut cursor = FileSearchCursor::new();
1812 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1813
1814 assert!(cursor.done);
1815 assert!(matches.is_empty());
1816 }
1817
1818 #[test]
1819 fn test_search_file_empty_file() {
1820 let fs = StdFileSystem;
1821 let temp_dir = tempfile::tempdir().unwrap();
1822 let path = temp_dir.path().join("empty.txt");
1823 fs.write_file(&path, b"").unwrap();
1824
1825 let opts = make_search_opts(true);
1826 let mut cursor = FileSearchCursor::new();
1827 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1828
1829 assert!(cursor.done);
1830 assert!(matches.is_empty());
1831 }
1832
1833 #[test]
1834 fn test_search_file_max_matches() {
1835 let fs = StdFileSystem;
1836 let temp_dir = tempfile::tempdir().unwrap();
1837 let path = temp_dir.path().join("test.txt");
1838 fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
1839
1840 let opts = FileSearchOptions {
1841 fixed_string: true,
1842 case_sensitive: true,
1843 whole_word: false,
1844 max_matches: 2,
1845 };
1846 let mut cursor = FileSearchCursor::new();
1847 let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
1848
1849 assert_eq!(matches.len(), 2);
1850 }
1851
1852 #[test]
1853 fn test_search_file_cursor_multi_chunk() {
1854 let fs = StdFileSystem;
1855 let temp_dir = tempfile::tempdir().unwrap();
1856 let path = temp_dir.path().join("large.txt");
1857
1858 let mut content = Vec::new();
1860 for i in 0..100_000 {
1861 content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
1862 }
1863 fs.write_file(&path, &content).unwrap();
1864
1865 let opts = FileSearchOptions {
1866 fixed_string: true,
1867 case_sensitive: true,
1868 whole_word: false,
1869 max_matches: 1000,
1870 };
1871 let mut cursor = FileSearchCursor::new();
1872 let mut all_matches = Vec::new();
1873
1874 while !cursor.done {
1875 let batch = fs
1876 .search_file(&path, "line 5000", &opts, &mut cursor)
1877 .unwrap();
1878 all_matches.extend(batch);
1879 }
1880
1881 assert_eq!(all_matches.len(), 11);
1884
1885 let first = &all_matches[0];
1887 assert_eq!(first.line, 5001); assert_eq!(first.column, 1);
1889 assert!(first.context.starts_with("line 5000"));
1890 }
1891
1892 #[test]
1893 fn test_search_file_cursor_no_duplicates() {
1894 let fs = StdFileSystem;
1895 let temp_dir = tempfile::tempdir().unwrap();
1896 let path = temp_dir.path().join("large.txt");
1897
1898 let mut content = Vec::new();
1900 for i in 0..100_000 {
1901 content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
1902 }
1903 fs.write_file(&path, &content).unwrap();
1904
1905 let opts = FileSearchOptions {
1906 fixed_string: true,
1907 case_sensitive: true,
1908 whole_word: false,
1909 max_matches: 200_000,
1910 };
1911 let mut cursor = FileSearchCursor::new();
1912 let mut all_matches = Vec::new();
1913 let mut batches = 0;
1914
1915 while !cursor.done {
1916 let batch = fs
1917 .search_file(&path, "MARKER_", &opts, &mut cursor)
1918 .unwrap();
1919 all_matches.extend(batch);
1920 batches += 1;
1921 }
1922
1923 assert!(batches > 1, "Expected multiple batches, got {}", batches);
1925 assert_eq!(all_matches.len(), 100_000);
1927 let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
1929 offsets.sort();
1930 offsets.dedup();
1931 assert_eq!(offsets.len(), 100_000);
1932 }
1933
1934 #[test]
1935 fn test_search_file_line_numbers_across_chunks() {
1936 let fs = StdFileSystem;
1937 let temp_dir = tempfile::tempdir().unwrap();
1938 let path = temp_dir.path().join("large.txt");
1939
1940 let mut content = Vec::new();
1942 let total_lines = 100_000;
1943 for i in 0..total_lines {
1944 if i == 99_999 {
1945 content.extend_from_slice(b"FINDME at the end\n");
1946 } else {
1947 content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
1948 }
1949 }
1950 fs.write_file(&path, &content).unwrap();
1951
1952 let opts = make_search_opts(true);
1953 let mut cursor = FileSearchCursor::new();
1954 let mut all_matches = Vec::new();
1955
1956 while !cursor.done {
1957 let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
1958 all_matches.extend(batch);
1959 }
1960
1961 assert_eq!(all_matches.len(), 1);
1962 assert_eq!(all_matches[0].line, total_lines); assert_eq!(all_matches[0].context, "FINDME at the end");
1964 }
1965
1966 #[test]
1967 fn test_search_file_end_offset_bounds_search() {
1968 let fs = StdFileSystem;
1969 let temp_dir = tempfile::tempdir().unwrap();
1970 let path = temp_dir.path().join("bounded.txt");
1971
1972 fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
1974
1975 let opts = make_search_opts(true);
1977 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1978 let mut matches = Vec::new();
1979 while !cursor.done {
1980 matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
1981 }
1982 assert_eq!(matches.len(), 1);
1983 assert_eq!(matches[0].context, "AAA");
1984 assert_eq!(matches[0].line, 1);
1985
1986 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1988 let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
1989 assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
1990
1991 let mut cursor = FileSearchCursor::for_range(8, 16, 3);
1993 let mut matches = Vec::new();
1994 while !cursor.done {
1995 matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
1996 }
1997 assert_eq!(matches.len(), 1);
1998 assert_eq!(matches[0].context, "CCC");
1999 assert_eq!(matches[0].line, 3);
2000 }
2001
2002 fn make_walk_tree() -> tempfile::TempDir {
2009 let fs = StdFileSystem;
2010 let tmp = tempfile::tempdir().unwrap();
2011 let root = tmp.path();
2012
2013 fs.write_file(&root.join("a.txt"), b"a").unwrap();
2028 fs.write_file(&root.join("b.txt"), b"b").unwrap();
2029 fs.create_dir_all(&root.join("sub/deep")).unwrap();
2030 fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2031 fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2032 fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2033 fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2034 .unwrap();
2035 fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2036 fs.create_dir_all(&root.join("node_modules")).unwrap();
2037 fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2038 .unwrap();
2039 fs.create_dir_all(&root.join("target")).unwrap();
2040 fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2041
2042 tmp
2043 }
2044
2045 #[test]
2046 fn test_walk_files_std_basic() {
2047 let tmp = make_walk_tree();
2048 let fs = StdFileSystem;
2049 let cancel = std::sync::atomic::AtomicBool::new(false);
2050 let mut found: Vec<String> = Vec::new();
2051
2052 fs.walk_files(
2053 tmp.path(),
2054 &["node_modules", "target"],
2055 &cancel,
2056 &mut |_path, rel| {
2057 found.push(rel.to_string());
2058 true
2059 },
2060 )
2061 .unwrap();
2062
2063 found.sort();
2064 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2065 }
2066
2067 #[test]
2068 fn test_walk_files_std_skips_hidden() {
2069 let tmp = make_walk_tree();
2070 let fs = StdFileSystem;
2071 let cancel = std::sync::atomic::AtomicBool::new(false);
2072 let mut found: Vec<String> = Vec::new();
2073
2074 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2075 found.push(rel.to_string());
2076 true
2077 })
2078 .unwrap();
2079
2080 assert!(!found.iter().any(|f| f.contains(".hidden")));
2083 assert!(found.iter().any(|f| f.contains("node_modules")));
2084 assert!(found.iter().any(|f| f.contains("target")));
2085 }
2086
2087 #[test]
2088 fn test_walk_files_std_skip_dirs() {
2089 let tmp = make_walk_tree();
2090 let fs = StdFileSystem;
2091 let cancel = std::sync::atomic::AtomicBool::new(false);
2092 let mut found: Vec<String> = Vec::new();
2093
2094 fs.walk_files(
2095 tmp.path(),
2096 &["node_modules", "target", "deep"],
2097 &cancel,
2098 &mut |_path, rel| {
2099 found.push(rel.to_string());
2100 true
2101 },
2102 )
2103 .unwrap();
2104
2105 found.sort();
2106 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2108 }
2109
2110 #[test]
2111 fn test_walk_files_std_cancel() {
2112 let tmp = make_walk_tree();
2113 let fs = StdFileSystem;
2114 let cancel = std::sync::atomic::AtomicBool::new(false);
2115 let mut found: Vec<String> = Vec::new();
2116
2117 fs.walk_files(
2118 tmp.path(),
2119 &["node_modules", "target"],
2120 &cancel,
2121 &mut |_path, rel| {
2122 found.push(rel.to_string());
2123 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2125 true
2126 },
2127 )
2128 .unwrap();
2129
2130 assert_eq!(found.len(), 1, "Should stop after cancel is set");
2131 }
2132
2133 #[test]
2134 fn test_walk_files_std_on_file_returns_false() {
2135 let tmp = make_walk_tree();
2136 let fs = StdFileSystem;
2137 let cancel = std::sync::atomic::AtomicBool::new(false);
2138 let mut count = 0usize;
2139
2140 fs.walk_files(
2141 tmp.path(),
2142 &["node_modules", "target"],
2143 &cancel,
2144 &mut |_path, _rel| {
2145 count += 1;
2146 count < 2 },
2148 )
2149 .unwrap();
2150
2151 assert_eq!(count, 2, "Should stop when on_file returns false");
2152 }
2153
2154 #[test]
2155 fn test_walk_files_std_empty_dir() {
2156 let tmp = tempfile::tempdir().unwrap();
2157 let fs = StdFileSystem;
2158 let cancel = std::sync::atomic::AtomicBool::new(false);
2159 let mut found: Vec<String> = Vec::new();
2160
2161 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2162 found.push(rel.to_string());
2163 true
2164 })
2165 .unwrap();
2166
2167 assert!(found.is_empty());
2168 }
2169
2170 #[test]
2171 fn test_walk_files_std_nonexistent_root() {
2172 let fs = StdFileSystem;
2173 let cancel = std::sync::atomic::AtomicBool::new(false);
2174 let mut found: Vec<String> = Vec::new();
2175
2176 let result = fs.walk_files(
2178 Path::new("/nonexistent/path/that/does/not/exist"),
2179 &[],
2180 &cancel,
2181 &mut |_path, rel| {
2182 found.push(rel.to_string());
2183 true
2184 },
2185 );
2186
2187 assert!(result.is_ok());
2188 assert!(found.is_empty());
2189 }
2190
2191 #[test]
2192 fn test_walk_files_std_relative_paths_use_forward_slashes() {
2193 let tmp = make_walk_tree();
2194 let fs = StdFileSystem;
2195 let cancel = std::sync::atomic::AtomicBool::new(false);
2196 let mut found: Vec<String> = Vec::new();
2197
2198 fs.walk_files(
2199 tmp.path(),
2200 &["node_modules", "target"],
2201 &cancel,
2202 &mut |_path, rel| {
2203 found.push(rel.to_string());
2204 true
2205 },
2206 )
2207 .unwrap();
2208
2209 for path in &found {
2211 assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2212 }
2213 }
2214
2215 #[test]
2216 fn test_walk_files_noop_returns_error() {
2217 let fs = NoopFileSystem;
2218 let cancel = std::sync::atomic::AtomicBool::new(false);
2219
2220 let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2221 true
2222 });
2223
2224 assert!(result.is_err());
2225 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2226 }
2227}