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 metadata(&self, path: &Path) -> io::Result<FileMetadata>;
518
519 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
521
522 fn exists(&self, path: &Path) -> bool {
524 self.metadata(path).is_ok()
525 }
526
527 fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
529 self.metadata(path).ok()
530 }
531
532 fn is_dir(&self, path: &Path) -> io::Result<bool>;
534
535 fn is_file(&self, path: &Path) -> io::Result<bool>;
537
538 fn is_writable(&self, path: &Path) -> bool {
546 self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
547 }
548
549 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
551
552 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
558
559 fn create_dir(&self, path: &Path) -> io::Result<()>;
561
562 fn create_dir_all(&self, path: &Path) -> io::Result<()>;
564
565 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
571
572 fn current_uid(&self) -> u32;
578
579 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 fn temp_path_for(&self, path: &Path) -> PathBuf {
599 path.with_extension("tmp")
600 }
601
602 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 fn remote_connection_info(&self) -> Option<&str> {
629 None
630 }
631
632 fn is_remote_connected(&self) -> bool {
638 true
639 }
640
641 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 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 fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
685 -> io::Result<()>;
686
687 fn walk_files(
714 &self,
715 root: &Path,
716 skip_dirs: &[&str],
717 cancel: &std::sync::atomic::AtomicBool,
718 on_file: &mut dyn FnMut(&Path, &str) -> bool,
719 ) -> io::Result<()>;
720}
721
722pub trait FileSystemExt: FileSystem {
747 fn read_file_async(
749 &self,
750 path: &Path,
751 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
752 async { self.read_file(path) }
753 }
754
755 fn read_range_async(
757 &self,
758 path: &Path,
759 offset: u64,
760 len: usize,
761 ) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
762 async move { self.read_range(path, offset, len) }
763 }
764
765 fn count_line_feeds_in_range_async(
767 &self,
768 path: &Path,
769 offset: u64,
770 len: usize,
771 ) -> impl std::future::Future<Output = io::Result<usize>> + Send {
772 async move { self.count_line_feeds_in_range(path, offset, len) }
773 }
774
775 fn write_file_async(
777 &self,
778 path: &Path,
779 data: &[u8],
780 ) -> impl std::future::Future<Output = io::Result<()>> + Send {
781 async { self.write_file(path, data) }
782 }
783
784 fn metadata_async(
786 &self,
787 path: &Path,
788 ) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
789 async { self.metadata(path) }
790 }
791
792 fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
794 async { self.exists(path) }
795 }
796
797 fn is_dir_async(
799 &self,
800 path: &Path,
801 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
802 async { self.is_dir(path) }
803 }
804
805 fn is_file_async(
807 &self,
808 path: &Path,
809 ) -> impl std::future::Future<Output = io::Result<bool>> + Send {
810 async { self.is_file(path) }
811 }
812
813 fn read_dir_async(
815 &self,
816 path: &Path,
817 ) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
818 async { self.read_dir(path) }
819 }
820
821 fn canonicalize_async(
823 &self,
824 path: &Path,
825 ) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
826 async { self.canonicalize(path) }
827 }
828}
829
830impl<T: FileSystem> FileSystemExt for T {}
832
833pub fn build_search_regex(
839 pattern: &str,
840 opts: &FileSearchOptions,
841) -> io::Result<regex::bytes::Regex> {
842 let re_pattern = if opts.fixed_string {
843 regex::escape(pattern)
844 } else {
845 pattern.to_string()
846 };
847 let re_pattern = if opts.whole_word {
848 format!(r"\b{}\b", re_pattern)
849 } else {
850 re_pattern
851 };
852 let re_pattern = if opts.case_sensitive {
853 re_pattern
854 } else {
855 format!("(?i){}", re_pattern)
856 };
857 regex::bytes::Regex::new(&re_pattern)
858 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
859}
860
861pub fn default_search_file(
865 fs: &dyn FileSystem,
866 path: &Path,
867 pattern: &str,
868 opts: &FileSearchOptions,
869 cursor: &mut FileSearchCursor,
870) -> io::Result<Vec<SearchMatch>> {
871 if cursor.done {
872 return Ok(vec![]);
873 }
874
875 const CHUNK_SIZE: usize = 1_048_576; let overlap = pattern.len().max(256);
877
878 let file_len = fs.metadata(path)?.size as usize;
879 let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
880
881 if cursor.offset == 0 && cursor.end_offset.is_none() {
883 if file_len == 0 {
884 cursor.done = true;
885 return Ok(vec![]);
886 }
887 let header_len = file_len.min(8192);
888 let header = fs.read_range(path, 0, header_len)?;
889 if header.contains(&0) {
890 cursor.done = true;
891 return Ok(vec![]);
892 }
893 }
894
895 if cursor.offset >= effective_end {
896 cursor.done = true;
897 return Ok(vec![]);
898 }
899
900 let regex = build_search_regex(pattern, opts)?;
901
902 let read_start = cursor.offset.saturating_sub(overlap);
904 let read_end = (read_start + CHUNK_SIZE).min(effective_end);
905 let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
906
907 let overlap_len = cursor.offset - read_start;
908
909 let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
911 let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
912 let mut counted_to = 0usize;
913 let mut matches = Vec::new();
914
915 for m in regex.find_iter(&chunk) {
916 if overlap_len > 0 && m.end() <= overlap_len {
918 continue;
919 }
920 if matches.len() >= opts.max_matches {
921 break;
922 }
923
924 line_at += chunk[counted_to..m.start()]
926 .iter()
927 .filter(|&&b| b == b'\n')
928 .count();
929 counted_to = m.start();
930
931 let line_start = chunk[..m.start()]
933 .iter()
934 .rposition(|&b| b == b'\n')
935 .map(|p| p + 1)
936 .unwrap_or(0);
937 let line_end = chunk[m.start()..]
938 .iter()
939 .position(|&b| b == b'\n')
940 .map(|p| m.start() + p)
941 .unwrap_or(chunk.len());
942
943 let column = m.start() - line_start + 1;
944 let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
945
946 matches.push(SearchMatch {
947 byte_offset: read_start + m.start(),
948 length: m.end() - m.start(),
949 line: line_at,
950 column,
951 context,
952 });
953 }
954
955 let new_data = &chunk[overlap_len..];
957 cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
958 cursor.offset = read_end;
959 if read_end >= effective_end {
960 cursor.done = true;
961 }
962
963 Ok(matches)
964}
965
966#[derive(Debug, Clone, Copy, Default)]
974pub struct StdFileSystem;
975
976impl StdFileSystem {
977 fn is_hidden(path: &Path) -> bool {
979 path.file_name()
980 .and_then(|n| n.to_str())
981 .is_some_and(|n| n.starts_with('.'))
982 }
983
984 #[cfg(unix)]
986 pub fn current_user_groups() -> (u32, Vec<u32>) {
987 let euid = unsafe { libc::geteuid() };
989 let egid = unsafe { libc::getegid() };
990 let mut groups = vec![egid];
991
992 let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
994 if ngroups > 0 {
995 let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
996 let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
997 if n > 0 {
998 sup_groups.truncate(n as usize);
999 for g in sup_groups {
1000 if g != egid {
1001 groups.push(g);
1002 }
1003 }
1004 }
1005 }
1006
1007 (euid, groups)
1008 }
1009
1010 fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
1012 #[cfg(unix)]
1013 {
1014 use std::os::unix::fs::MetadataExt;
1015 let file_uid = meta.uid();
1016 let file_gid = meta.gid();
1017 let permissions = FilePermissions::from_std(meta.permissions());
1018 let (euid, user_groups) = Self::current_user_groups();
1019 let is_readonly =
1020 permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups);
1021 FileMetadata {
1022 size: meta.len(),
1023 modified: meta.modified().ok(),
1024 permissions: Some(permissions),
1025 is_hidden: Self::is_hidden(path),
1026 is_readonly,
1027 uid: Some(file_uid),
1028 gid: Some(file_gid),
1029 }
1030 }
1031 #[cfg(not(unix))]
1032 {
1033 FileMetadata {
1034 size: meta.len(),
1035 modified: meta.modified().ok(),
1036 permissions: Some(FilePermissions::from_std(meta.permissions())),
1037 is_hidden: Self::is_hidden(path),
1038 is_readonly: meta.permissions().readonly(),
1039 }
1040 }
1041 }
1042}
1043
1044impl FileSystem for StdFileSystem {
1045 fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
1047 let data = std::fs::read(path)?;
1048 crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
1049 Ok(data)
1050 }
1051
1052 fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
1053 let mut file = std::fs::File::open(path)?;
1054 file.seek(io::SeekFrom::Start(offset))?;
1055 let mut buffer = vec![0u8; len];
1056 file.read_exact(&mut buffer)?;
1057 crate::services::counters::global().inc_disk_bytes_read(len as u64);
1058 Ok(buffer)
1059 }
1060
1061 fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
1062 let original_metadata = self.metadata_if_exists(path);
1063 let temp_path = self.temp_path_for(path);
1064 {
1065 let mut file = self.create_file(&temp_path)?;
1066 file.write_all(data)?;
1067 file.sync_all()?;
1068 }
1069 if let Some(ref meta) = original_metadata {
1070 if let Some(ref perms) = meta.permissions {
1071 #[allow(clippy::let_underscore_must_use)]
1073 let _ = self.set_permissions(&temp_path, perms);
1074 }
1075 }
1076 self.rename(&temp_path, path)?;
1077 Ok(())
1078 }
1079
1080 fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1081 let file = std::fs::File::create(path)?;
1082 Ok(Box::new(StdFileWriter(file)))
1083 }
1084
1085 fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
1086 let file = std::fs::File::open(path)?;
1087 Ok(Box::new(StdFileReader(file)))
1088 }
1089
1090 fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1091 let file = std::fs::OpenOptions::new()
1092 .write(true)
1093 .truncate(true)
1094 .open(path)?;
1095 Ok(Box::new(StdFileWriter(file)))
1096 }
1097
1098 fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
1099 let file = std::fs::OpenOptions::new()
1100 .create(true)
1101 .append(true)
1102 .open(path)?;
1103 Ok(Box::new(StdFileWriter(file)))
1104 }
1105
1106 fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
1107 let file = std::fs::OpenOptions::new().write(true).open(path)?;
1108 file.set_len(len)
1109 }
1110
1111 fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
1113 std::fs::rename(from, to)
1114 }
1115
1116 fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
1117 std::fs::copy(from, to)
1118 }
1119
1120 fn remove_file(&self, path: &Path) -> io::Result<()> {
1121 std::fs::remove_file(path)
1122 }
1123
1124 fn remove_dir(&self, path: &Path) -> io::Result<()> {
1125 std::fs::remove_dir(path)
1126 }
1127
1128 fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1130 let meta = std::fs::metadata(path)?;
1131 Ok(Self::build_metadata(path, &meta))
1132 }
1133
1134 fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
1135 let meta = std::fs::symlink_metadata(path)?;
1136 Ok(Self::build_metadata(path, &meta))
1137 }
1138
1139 fn is_dir(&self, path: &Path) -> io::Result<bool> {
1140 Ok(std::fs::metadata(path)?.is_dir())
1141 }
1142
1143 fn is_file(&self, path: &Path) -> io::Result<bool> {
1144 Ok(std::fs::metadata(path)?.is_file())
1145 }
1146
1147 fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
1148 std::fs::set_permissions(path, permissions.to_std())
1149 }
1150
1151 fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
1153 let mut entries = Vec::new();
1154 for entry in std::fs::read_dir(path)? {
1155 let entry = entry?;
1156 let path = entry.path();
1157 let name = entry.file_name().to_string_lossy().into_owned();
1158 let file_type = entry.file_type()?;
1159
1160 let entry_type = if file_type.is_dir() {
1161 EntryType::Directory
1162 } else if file_type.is_symlink() {
1163 EntryType::Symlink
1164 } else {
1165 EntryType::File
1166 };
1167
1168 let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
1169
1170 if file_type.is_symlink() {
1172 dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
1173 .map(|m| m.is_dir())
1174 .unwrap_or(false);
1175 }
1176
1177 entries.push(dir_entry);
1178 }
1179 Ok(entries)
1180 }
1181
1182 fn create_dir(&self, path: &Path) -> io::Result<()> {
1183 std::fs::create_dir(path)
1184 }
1185
1186 fn create_dir_all(&self, path: &Path) -> io::Result<()> {
1187 std::fs::create_dir_all(path)
1188 }
1189
1190 fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
1192 std::fs::canonicalize(path)
1193 }
1194
1195 fn current_uid(&self) -> u32 {
1197 #[cfg(all(unix, feature = "runtime"))]
1198 {
1199 unsafe { libc::getuid() }
1201 }
1202 #[cfg(not(all(unix, feature = "runtime")))]
1203 {
1204 0
1205 }
1206 }
1207
1208 fn sudo_write(
1209 &self,
1210 path: &Path,
1211 data: &[u8],
1212 mode: u32,
1213 uid: u32,
1214 gid: u32,
1215 ) -> io::Result<()> {
1216 use std::process::{Command, Stdio};
1217
1218 let mut child = Command::new("sudo")
1220 .args(["tee", &path.to_string_lossy()])
1221 .stdin(Stdio::piped())
1222 .stdout(Stdio::null())
1223 .stderr(Stdio::piped())
1224 .spawn()
1225 .map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
1226
1227 if let Some(mut stdin) = child.stdin.take() {
1228 use std::io::Write;
1229 stdin.write_all(data)?;
1230 }
1231
1232 let output = child.wait_with_output()?;
1233 if !output.status.success() {
1234 let stderr = String::from_utf8_lossy(&output.stderr);
1235 return Err(io::Error::new(
1236 io::ErrorKind::PermissionDenied,
1237 format!("sudo tee failed: {}", stderr.trim()),
1238 ));
1239 }
1240
1241 let status = Command::new("sudo")
1243 .args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
1244 .status()?;
1245 if !status.success() {
1246 return Err(io::Error::other("sudo chmod failed"));
1247 }
1248
1249 let status = Command::new("sudo")
1251 .args([
1252 "chown",
1253 &format!("{}:{}", uid, gid),
1254 &path.to_string_lossy(),
1255 ])
1256 .status()?;
1257 if !status.success() {
1258 return Err(io::Error::other("sudo chown failed"));
1259 }
1260
1261 Ok(())
1262 }
1263
1264 fn search_file(
1265 &self,
1266 path: &Path,
1267 pattern: &str,
1268 opts: &FileSearchOptions,
1269 cursor: &mut FileSearchCursor,
1270 ) -> io::Result<Vec<SearchMatch>> {
1271 default_search_file(self, path, pattern, opts, cursor)
1272 }
1273
1274 fn walk_files(
1275 &self,
1276 root: &Path,
1277 skip_dirs: &[&str],
1278 cancel: &std::sync::atomic::AtomicBool,
1279 on_file: &mut dyn FnMut(&Path, &str) -> bool,
1280 ) -> io::Result<()> {
1281 let mut stack = vec![root.to_path_buf()];
1282 while let Some(dir) = stack.pop() {
1283 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1284 return Ok(());
1285 }
1286
1287 let iter = match std::fs::read_dir(&dir) {
1291 Ok(it) => it,
1292 Err(_) => continue,
1293 };
1294
1295 for entry in iter {
1296 if cancel.load(std::sync::atomic::Ordering::Relaxed) {
1297 return Ok(());
1298 }
1299 let entry = match entry {
1300 Ok(e) => e,
1301 Err(_) => continue,
1302 };
1303 let name = entry.file_name();
1304 let name_str = name.to_string_lossy();
1305
1306 if name_str.starts_with('.') {
1308 continue;
1309 }
1310
1311 let ft = match entry.file_type() {
1312 Ok(ft) => ft,
1313 Err(_) => continue,
1314 };
1315 let path = entry.path();
1316
1317 if ft.is_file() {
1318 if let Ok(rel) = path.strip_prefix(root) {
1319 let rel_str = rel.to_string_lossy().replace('\\', "/");
1320 if !on_file(&path, &rel_str) {
1321 return Ok(());
1322 }
1323 }
1324 } else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
1325 stack.push(path);
1326 }
1327 }
1328 }
1329 Ok(())
1330 }
1331}
1332
1333#[derive(Debug, Clone, Copy, Default)]
1342pub struct NoopFileSystem;
1343
1344impl NoopFileSystem {
1345 fn unsupported<T>() -> io::Result<T> {
1346 Err(io::Error::new(
1347 io::ErrorKind::Unsupported,
1348 "Filesystem not available",
1349 ))
1350 }
1351}
1352
1353impl FileSystem for NoopFileSystem {
1354 fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
1355 Self::unsupported()
1356 }
1357
1358 fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
1359 Self::unsupported()
1360 }
1361
1362 fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
1363 Self::unsupported()
1364 }
1365
1366 fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1367 Self::unsupported()
1368 }
1369
1370 fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
1371 Self::unsupported()
1372 }
1373
1374 fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1375 Self::unsupported()
1376 }
1377
1378 fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
1379 Self::unsupported()
1380 }
1381
1382 fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
1383 Self::unsupported()
1384 }
1385
1386 fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
1387 Self::unsupported()
1388 }
1389
1390 fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
1391 Self::unsupported()
1392 }
1393
1394 fn remove_file(&self, _path: &Path) -> io::Result<()> {
1395 Self::unsupported()
1396 }
1397
1398 fn remove_dir(&self, _path: &Path) -> io::Result<()> {
1399 Self::unsupported()
1400 }
1401
1402 fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1403 Self::unsupported()
1404 }
1405
1406 fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
1407 Self::unsupported()
1408 }
1409
1410 fn is_dir(&self, _path: &Path) -> io::Result<bool> {
1411 Self::unsupported()
1412 }
1413
1414 fn is_file(&self, _path: &Path) -> io::Result<bool> {
1415 Self::unsupported()
1416 }
1417
1418 fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
1419 Self::unsupported()
1420 }
1421
1422 fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
1423 Self::unsupported()
1424 }
1425
1426 fn create_dir(&self, _path: &Path) -> io::Result<()> {
1427 Self::unsupported()
1428 }
1429
1430 fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
1431 Self::unsupported()
1432 }
1433
1434 fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
1435 Self::unsupported()
1436 }
1437
1438 fn current_uid(&self) -> u32 {
1439 0
1440 }
1441
1442 fn search_file(
1443 &self,
1444 _path: &Path,
1445 _pattern: &str,
1446 _opts: &FileSearchOptions,
1447 _cursor: &mut FileSearchCursor,
1448 ) -> io::Result<Vec<SearchMatch>> {
1449 Self::unsupported()
1450 }
1451
1452 fn sudo_write(
1453 &self,
1454 _path: &Path,
1455 _data: &[u8],
1456 _mode: u32,
1457 _uid: u32,
1458 _gid: u32,
1459 ) -> io::Result<()> {
1460 Self::unsupported()
1461 }
1462
1463 fn walk_files(
1464 &self,
1465 _root: &Path,
1466 _skip_dirs: &[&str],
1467 _cancel: &std::sync::atomic::AtomicBool,
1468 _on_file: &mut dyn FnMut(&Path, &str) -> bool,
1469 ) -> io::Result<()> {
1470 Self::unsupported()
1471 }
1472}
1473
1474#[cfg(test)]
1479mod tests {
1480 use super::*;
1481 use tempfile::NamedTempFile;
1482
1483 #[test]
1484 fn test_std_filesystem_read_write() {
1485 let fs = StdFileSystem;
1486 let mut temp = NamedTempFile::new().unwrap();
1487 let path = temp.path().to_path_buf();
1488
1489 std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
1490 std::io::Write::flush(&mut temp).unwrap();
1491
1492 let content = fs.read_file(&path).unwrap();
1493 assert_eq!(content, b"Hello, World!");
1494
1495 let range = fs.read_range(&path, 7, 5).unwrap();
1496 assert_eq!(range, b"World");
1497
1498 let meta = fs.metadata(&path).unwrap();
1499 assert_eq!(meta.size, 13);
1500 }
1501
1502 #[test]
1503 fn test_noop_filesystem() {
1504 let fs = NoopFileSystem;
1505 let path = Path::new("/some/path");
1506
1507 assert!(fs.read_file(path).is_err());
1508 assert!(fs.read_range(path, 0, 10).is_err());
1509 assert!(fs.write_file(path, b"data").is_err());
1510 assert!(fs.metadata(path).is_err());
1511 assert!(fs.read_dir(path).is_err());
1512 }
1513
1514 #[test]
1515 fn test_create_and_write_file() {
1516 let fs = StdFileSystem;
1517 let temp_dir = tempfile::tempdir().unwrap();
1518 let path = temp_dir.path().join("test.txt");
1519
1520 {
1521 let mut writer = fs.create_file(&path).unwrap();
1522 writer.write_all(b"test content").unwrap();
1523 writer.sync_all().unwrap();
1524 }
1525
1526 let content = fs.read_file(&path).unwrap();
1527 assert_eq!(content, b"test content");
1528 }
1529
1530 #[test]
1531 fn test_read_dir() {
1532 let fs = StdFileSystem;
1533 let temp_dir = tempfile::tempdir().unwrap();
1534
1535 fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
1537 fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
1538 .unwrap();
1539 fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
1540 .unwrap();
1541
1542 let entries = fs.read_dir(temp_dir.path()).unwrap();
1543 assert_eq!(entries.len(), 3);
1544
1545 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1546 assert!(names.contains(&"subdir"));
1547 assert!(names.contains(&"file1.txt"));
1548 assert!(names.contains(&"file2.txt"));
1549 }
1550
1551 #[test]
1552 fn test_dir_entry_types() {
1553 let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
1554 assert!(file.is_file());
1555 assert!(!file.is_dir());
1556
1557 let dir = DirEntry::new(
1558 PathBuf::from("/dir"),
1559 "dir".to_string(),
1560 EntryType::Directory,
1561 );
1562 assert!(dir.is_dir());
1563 assert!(!dir.is_file());
1564
1565 let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
1566 assert!(link_to_dir.is_symlink());
1567 assert!(link_to_dir.is_dir());
1568 }
1569
1570 #[test]
1571 fn test_metadata_builder() {
1572 let meta = FileMetadata::default()
1573 .with_hidden(true)
1574 .with_readonly(true);
1575 assert!(meta.is_hidden);
1576 assert!(meta.is_readonly);
1577 }
1578
1579 #[test]
1580 fn test_atomic_write() {
1581 let fs = StdFileSystem;
1582 let temp_dir = tempfile::tempdir().unwrap();
1583 let path = temp_dir.path().join("atomic_test.txt");
1584
1585 fs.write_file(&path, b"initial").unwrap();
1586 assert_eq!(fs.read_file(&path).unwrap(), b"initial");
1587
1588 fs.write_file(&path, b"updated").unwrap();
1589 assert_eq!(fs.read_file(&path).unwrap(), b"updated");
1590 }
1591
1592 #[test]
1593 fn test_write_patched_default_impl() {
1594 let fs = StdFileSystem;
1596 let temp_dir = tempfile::tempdir().unwrap();
1597 let src_path = temp_dir.path().join("source.txt");
1598 let dst_path = temp_dir.path().join("dest.txt");
1599
1600 fs.write_file(&src_path, b"AAABBBCCC").unwrap();
1602
1603 let ops = vec![
1605 WriteOp::Copy { offset: 0, len: 3 }, WriteOp::Insert { data: b"XXX" }, WriteOp::Copy { offset: 6, len: 3 }, ];
1609
1610 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1611
1612 let result = fs.read_file(&dst_path).unwrap();
1613 assert_eq!(result, b"AAAXXXCCC");
1614 }
1615
1616 #[test]
1617 fn test_write_patched_same_file() {
1618 let fs = StdFileSystem;
1620 let temp_dir = tempfile::tempdir().unwrap();
1621 let path = temp_dir.path().join("file.txt");
1622
1623 fs.write_file(&path, b"Hello World").unwrap();
1625
1626 let ops = vec![
1628 WriteOp::Copy { offset: 0, len: 6 }, WriteOp::Insert { data: b"Rust" }, ];
1631
1632 fs.write_patched(&path, &path, &ops).unwrap();
1633
1634 let result = fs.read_file(&path).unwrap();
1635 assert_eq!(result, b"Hello Rust");
1636 }
1637
1638 #[test]
1639 fn test_write_patched_insert_only() {
1640 let fs = StdFileSystem;
1642 let temp_dir = tempfile::tempdir().unwrap();
1643 let src_path = temp_dir.path().join("empty.txt");
1644 let dst_path = temp_dir.path().join("new.txt");
1645
1646 fs.write_file(&src_path, b"").unwrap();
1648
1649 let ops = vec![WriteOp::Insert {
1650 data: b"All new content",
1651 }];
1652
1653 fs.write_patched(&src_path, &dst_path, &ops).unwrap();
1654
1655 let result = fs.read_file(&dst_path).unwrap();
1656 assert_eq!(result, b"All new content");
1657 }
1658
1659 fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
1664 FileSearchOptions {
1665 fixed_string: pattern_is_fixed,
1666 case_sensitive: true,
1667 whole_word: false,
1668 max_matches: 100,
1669 }
1670 }
1671
1672 #[test]
1673 fn test_search_file_basic() {
1674 let fs = StdFileSystem;
1675 let temp_dir = tempfile::tempdir().unwrap();
1676 let path = temp_dir.path().join("test.txt");
1677 fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
1678 .unwrap();
1679
1680 let opts = make_search_opts(true);
1681 let mut cursor = FileSearchCursor::new();
1682 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1683
1684 assert!(cursor.done);
1685 assert_eq!(matches.len(), 2);
1686
1687 assert_eq!(matches[0].line, 1);
1688 assert_eq!(matches[0].column, 1);
1689 assert_eq!(matches[0].context, "hello world");
1690
1691 assert_eq!(matches[1].line, 3);
1692 assert_eq!(matches[1].column, 1);
1693 assert_eq!(matches[1].context, "hello again");
1694 }
1695
1696 #[test]
1697 fn test_search_file_no_matches() {
1698 let fs = StdFileSystem;
1699 let temp_dir = tempfile::tempdir().unwrap();
1700 let path = temp_dir.path().join("test.txt");
1701 fs.write_file(&path, b"hello world\n").unwrap();
1702
1703 let opts = make_search_opts(true);
1704 let mut cursor = FileSearchCursor::new();
1705 let matches = fs
1706 .search_file(&path, "NOTFOUND", &opts, &mut cursor)
1707 .unwrap();
1708
1709 assert!(cursor.done);
1710 assert!(matches.is_empty());
1711 }
1712
1713 #[test]
1714 fn test_search_file_case_insensitive() {
1715 let fs = StdFileSystem;
1716 let temp_dir = tempfile::tempdir().unwrap();
1717 let path = temp_dir.path().join("test.txt");
1718 fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
1719
1720 let opts = FileSearchOptions {
1721 fixed_string: true,
1722 case_sensitive: false,
1723 whole_word: false,
1724 max_matches: 100,
1725 };
1726 let mut cursor = FileSearchCursor::new();
1727 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1728
1729 assert_eq!(matches.len(), 3);
1730 }
1731
1732 #[test]
1733 fn test_search_file_whole_word() {
1734 let fs = StdFileSystem;
1735 let temp_dir = tempfile::tempdir().unwrap();
1736 let path = temp_dir.path().join("test.txt");
1737 fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
1738
1739 let opts = FileSearchOptions {
1740 fixed_string: true,
1741 case_sensitive: true,
1742 whole_word: true,
1743 max_matches: 100,
1744 };
1745 let mut cursor = FileSearchCursor::new();
1746 let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
1747
1748 assert_eq!(matches.len(), 1);
1749 assert_eq!(matches[0].column, 1);
1750 }
1751
1752 #[test]
1753 fn test_search_file_regex() {
1754 let fs = StdFileSystem;
1755 let temp_dir = tempfile::tempdir().unwrap();
1756 let path = temp_dir.path().join("test.txt");
1757 fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
1758
1759 let opts = FileSearchOptions {
1760 fixed_string: false,
1761 case_sensitive: true,
1762 whole_word: false,
1763 max_matches: 100,
1764 };
1765 let mut cursor = FileSearchCursor::new();
1766 let matches = fs
1767 .search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
1768 .unwrap();
1769
1770 assert_eq!(matches.len(), 2);
1771 assert_eq!(matches[0].context, "foo123 bar456 baz");
1772 }
1773
1774 #[test]
1775 fn test_search_file_binary_skipped() {
1776 let fs = StdFileSystem;
1777 let temp_dir = tempfile::tempdir().unwrap();
1778 let path = temp_dir.path().join("binary.dat");
1779 let mut data = b"hello world\n".to_vec();
1780 data.push(0); data.extend_from_slice(b"hello again\n");
1782 fs.write_file(&path, &data).unwrap();
1783
1784 let opts = make_search_opts(true);
1785 let mut cursor = FileSearchCursor::new();
1786 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1787
1788 assert!(cursor.done);
1789 assert!(matches.is_empty());
1790 }
1791
1792 #[test]
1793 fn test_search_file_empty_file() {
1794 let fs = StdFileSystem;
1795 let temp_dir = tempfile::tempdir().unwrap();
1796 let path = temp_dir.path().join("empty.txt");
1797 fs.write_file(&path, b"").unwrap();
1798
1799 let opts = make_search_opts(true);
1800 let mut cursor = FileSearchCursor::new();
1801 let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
1802
1803 assert!(cursor.done);
1804 assert!(matches.is_empty());
1805 }
1806
1807 #[test]
1808 fn test_search_file_max_matches() {
1809 let fs = StdFileSystem;
1810 let temp_dir = tempfile::tempdir().unwrap();
1811 let path = temp_dir.path().join("test.txt");
1812 fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
1813
1814 let opts = FileSearchOptions {
1815 fixed_string: true,
1816 case_sensitive: true,
1817 whole_word: false,
1818 max_matches: 2,
1819 };
1820 let mut cursor = FileSearchCursor::new();
1821 let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
1822
1823 assert_eq!(matches.len(), 2);
1824 }
1825
1826 #[test]
1827 fn test_search_file_cursor_multi_chunk() {
1828 let fs = StdFileSystem;
1829 let temp_dir = tempfile::tempdir().unwrap();
1830 let path = temp_dir.path().join("large.txt");
1831
1832 let mut content = Vec::new();
1834 for i in 0..100_000 {
1835 content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
1836 }
1837 fs.write_file(&path, &content).unwrap();
1838
1839 let opts = FileSearchOptions {
1840 fixed_string: true,
1841 case_sensitive: true,
1842 whole_word: false,
1843 max_matches: 1000,
1844 };
1845 let mut cursor = FileSearchCursor::new();
1846 let mut all_matches = Vec::new();
1847
1848 while !cursor.done {
1849 let batch = fs
1850 .search_file(&path, "line 5000", &opts, &mut cursor)
1851 .unwrap();
1852 all_matches.extend(batch);
1853 }
1854
1855 assert_eq!(all_matches.len(), 11);
1858
1859 let first = &all_matches[0];
1861 assert_eq!(first.line, 5001); assert_eq!(first.column, 1);
1863 assert!(first.context.starts_with("line 5000"));
1864 }
1865
1866 #[test]
1867 fn test_search_file_cursor_no_duplicates() {
1868 let fs = StdFileSystem;
1869 let temp_dir = tempfile::tempdir().unwrap();
1870 let path = temp_dir.path().join("large.txt");
1871
1872 let mut content = Vec::new();
1874 for i in 0..100_000 {
1875 content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
1876 }
1877 fs.write_file(&path, &content).unwrap();
1878
1879 let opts = FileSearchOptions {
1880 fixed_string: true,
1881 case_sensitive: true,
1882 whole_word: false,
1883 max_matches: 200_000,
1884 };
1885 let mut cursor = FileSearchCursor::new();
1886 let mut all_matches = Vec::new();
1887 let mut batches = 0;
1888
1889 while !cursor.done {
1890 let batch = fs
1891 .search_file(&path, "MARKER_", &opts, &mut cursor)
1892 .unwrap();
1893 all_matches.extend(batch);
1894 batches += 1;
1895 }
1896
1897 assert!(batches > 1, "Expected multiple batches, got {}", batches);
1899 assert_eq!(all_matches.len(), 100_000);
1901 let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
1903 offsets.sort();
1904 offsets.dedup();
1905 assert_eq!(offsets.len(), 100_000);
1906 }
1907
1908 #[test]
1909 fn test_search_file_line_numbers_across_chunks() {
1910 let fs = StdFileSystem;
1911 let temp_dir = tempfile::tempdir().unwrap();
1912 let path = temp_dir.path().join("large.txt");
1913
1914 let mut content = Vec::new();
1916 let total_lines = 100_000;
1917 for i in 0..total_lines {
1918 if i == 99_999 {
1919 content.extend_from_slice(b"FINDME at the end\n");
1920 } else {
1921 content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
1922 }
1923 }
1924 fs.write_file(&path, &content).unwrap();
1925
1926 let opts = make_search_opts(true);
1927 let mut cursor = FileSearchCursor::new();
1928 let mut all_matches = Vec::new();
1929
1930 while !cursor.done {
1931 let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
1932 all_matches.extend(batch);
1933 }
1934
1935 assert_eq!(all_matches.len(), 1);
1936 assert_eq!(all_matches[0].line, total_lines); assert_eq!(all_matches[0].context, "FINDME at the end");
1938 }
1939
1940 #[test]
1941 fn test_search_file_end_offset_bounds_search() {
1942 let fs = StdFileSystem;
1943 let temp_dir = tempfile::tempdir().unwrap();
1944 let path = temp_dir.path().join("bounded.txt");
1945
1946 fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
1948
1949 let opts = make_search_opts(true);
1951 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1952 let mut matches = Vec::new();
1953 while !cursor.done {
1954 matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
1955 }
1956 assert_eq!(matches.len(), 1);
1957 assert_eq!(matches[0].context, "AAA");
1958 assert_eq!(matches[0].line, 1);
1959
1960 let mut cursor = FileSearchCursor::for_range(0, 8, 1);
1962 let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
1963 assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
1964
1965 let mut cursor = FileSearchCursor::for_range(8, 16, 3);
1967 let mut matches = Vec::new();
1968 while !cursor.done {
1969 matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
1970 }
1971 assert_eq!(matches.len(), 1);
1972 assert_eq!(matches[0].context, "CCC");
1973 assert_eq!(matches[0].line, 3);
1974 }
1975
1976 fn make_walk_tree() -> tempfile::TempDir {
1983 let fs = StdFileSystem;
1984 let tmp = tempfile::tempdir().unwrap();
1985 let root = tmp.path();
1986
1987 fs.write_file(&root.join("a.txt"), b"a").unwrap();
2002 fs.write_file(&root.join("b.txt"), b"b").unwrap();
2003 fs.create_dir_all(&root.join("sub/deep")).unwrap();
2004 fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
2005 fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
2006 fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
2007 fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
2008 .unwrap();
2009 fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
2010 fs.create_dir_all(&root.join("node_modules")).unwrap();
2011 fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
2012 .unwrap();
2013 fs.create_dir_all(&root.join("target")).unwrap();
2014 fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
2015
2016 tmp
2017 }
2018
2019 #[test]
2020 fn test_walk_files_std_basic() {
2021 let tmp = make_walk_tree();
2022 let fs = StdFileSystem;
2023 let cancel = std::sync::atomic::AtomicBool::new(false);
2024 let mut found: Vec<String> = Vec::new();
2025
2026 fs.walk_files(
2027 tmp.path(),
2028 &["node_modules", "target"],
2029 &cancel,
2030 &mut |_path, rel| {
2031 found.push(rel.to_string());
2032 true
2033 },
2034 )
2035 .unwrap();
2036
2037 found.sort();
2038 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
2039 }
2040
2041 #[test]
2042 fn test_walk_files_std_skips_hidden() {
2043 let tmp = make_walk_tree();
2044 let fs = StdFileSystem;
2045 let cancel = std::sync::atomic::AtomicBool::new(false);
2046 let mut found: Vec<String> = Vec::new();
2047
2048 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2049 found.push(rel.to_string());
2050 true
2051 })
2052 .unwrap();
2053
2054 assert!(!found.iter().any(|f| f.contains(".hidden")));
2057 assert!(found.iter().any(|f| f.contains("node_modules")));
2058 assert!(found.iter().any(|f| f.contains("target")));
2059 }
2060
2061 #[test]
2062 fn test_walk_files_std_skip_dirs() {
2063 let tmp = make_walk_tree();
2064 let fs = StdFileSystem;
2065 let cancel = std::sync::atomic::AtomicBool::new(false);
2066 let mut found: Vec<String> = Vec::new();
2067
2068 fs.walk_files(
2069 tmp.path(),
2070 &["node_modules", "target", "deep"],
2071 &cancel,
2072 &mut |_path, rel| {
2073 found.push(rel.to_string());
2074 true
2075 },
2076 )
2077 .unwrap();
2078
2079 found.sort();
2080 assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
2082 }
2083
2084 #[test]
2085 fn test_walk_files_std_cancel() {
2086 let tmp = make_walk_tree();
2087 let fs = StdFileSystem;
2088 let cancel = std::sync::atomic::AtomicBool::new(false);
2089 let mut found: Vec<String> = Vec::new();
2090
2091 fs.walk_files(
2092 tmp.path(),
2093 &["node_modules", "target"],
2094 &cancel,
2095 &mut |_path, rel| {
2096 found.push(rel.to_string());
2097 cancel.store(true, std::sync::atomic::Ordering::Relaxed);
2099 true
2100 },
2101 )
2102 .unwrap();
2103
2104 assert_eq!(found.len(), 1, "Should stop after cancel is set");
2105 }
2106
2107 #[test]
2108 fn test_walk_files_std_on_file_returns_false() {
2109 let tmp = make_walk_tree();
2110 let fs = StdFileSystem;
2111 let cancel = std::sync::atomic::AtomicBool::new(false);
2112 let mut count = 0usize;
2113
2114 fs.walk_files(
2115 tmp.path(),
2116 &["node_modules", "target"],
2117 &cancel,
2118 &mut |_path, _rel| {
2119 count += 1;
2120 count < 2 },
2122 )
2123 .unwrap();
2124
2125 assert_eq!(count, 2, "Should stop when on_file returns false");
2126 }
2127
2128 #[test]
2129 fn test_walk_files_std_empty_dir() {
2130 let tmp = tempfile::tempdir().unwrap();
2131 let fs = StdFileSystem;
2132 let cancel = std::sync::atomic::AtomicBool::new(false);
2133 let mut found: Vec<String> = Vec::new();
2134
2135 fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
2136 found.push(rel.to_string());
2137 true
2138 })
2139 .unwrap();
2140
2141 assert!(found.is_empty());
2142 }
2143
2144 #[test]
2145 fn test_walk_files_std_nonexistent_root() {
2146 let fs = StdFileSystem;
2147 let cancel = std::sync::atomic::AtomicBool::new(false);
2148 let mut found: Vec<String> = Vec::new();
2149
2150 let result = fs.walk_files(
2152 Path::new("/nonexistent/path/that/does/not/exist"),
2153 &[],
2154 &cancel,
2155 &mut |_path, rel| {
2156 found.push(rel.to_string());
2157 true
2158 },
2159 );
2160
2161 assert!(result.is_ok());
2162 assert!(found.is_empty());
2163 }
2164
2165 #[test]
2166 fn test_walk_files_std_relative_paths_use_forward_slashes() {
2167 let tmp = make_walk_tree();
2168 let fs = StdFileSystem;
2169 let cancel = std::sync::atomic::AtomicBool::new(false);
2170 let mut found: Vec<String> = Vec::new();
2171
2172 fs.walk_files(
2173 tmp.path(),
2174 &["node_modules", "target"],
2175 &cancel,
2176 &mut |_path, rel| {
2177 found.push(rel.to_string());
2178 true
2179 },
2180 )
2181 .unwrap();
2182
2183 for path in &found {
2185 assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
2186 }
2187 }
2188
2189 #[test]
2190 fn test_walk_files_noop_returns_error() {
2191 let fs = NoopFileSystem;
2192 let cancel = std::sync::atomic::AtomicBool::new(false);
2193
2194 let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
2195 true
2196 });
2197
2198 assert!(result.is_err());
2199 assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
2200 }
2201}