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