use std::io::{self, Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum EntryType {
File,
Directory,
Symlink,
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub path: PathBuf,
pub name: String,
pub entry_type: EntryType,
pub metadata: Option<FileMetadata>,
pub symlink_target_is_dir: bool,
}
impl DirEntry {
pub fn new(path: PathBuf, name: String, entry_type: EntryType) -> Self {
Self {
path,
name,
entry_type,
metadata: None,
symlink_target_is_dir: false,
}
}
pub fn new_symlink(path: PathBuf, name: String, target_is_dir: bool) -> Self {
Self {
path,
name,
entry_type: EntryType::Symlink,
metadata: None,
symlink_target_is_dir: target_is_dir,
}
}
pub fn with_metadata(mut self, metadata: FileMetadata) -> Self {
self.metadata = Some(metadata);
self
}
pub fn is_dir(&self) -> bool {
self.entry_type == EntryType::Directory
|| (self.entry_type == EntryType::Symlink && self.symlink_target_is_dir)
}
pub fn is_file(&self) -> bool {
self.entry_type == EntryType::File
|| (self.entry_type == EntryType::Symlink && !self.symlink_target_is_dir)
}
pub fn is_symlink(&self) -> bool {
self.entry_type == EntryType::Symlink
}
}
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub size: u64,
pub modified: Option<SystemTime>,
pub permissions: Option<FilePermissions>,
pub is_hidden: bool,
pub is_readonly: bool,
#[cfg(unix)]
pub uid: Option<u32>,
#[cfg(unix)]
pub gid: Option<u32>,
}
impl FileMetadata {
pub fn new(size: u64) -> Self {
Self {
size,
modified: None,
permissions: None,
is_hidden: false,
is_readonly: false,
#[cfg(unix)]
uid: None,
#[cfg(unix)]
gid: None,
}
}
pub fn with_modified(mut self, modified: SystemTime) -> Self {
self.modified = Some(modified);
self
}
pub fn with_hidden(mut self, hidden: bool) -> Self {
self.is_hidden = hidden;
self
}
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.is_readonly = readonly;
self
}
pub fn with_permissions(mut self, permissions: FilePermissions) -> Self {
self.permissions = Some(permissions);
self
}
}
impl Default for FileMetadata {
fn default() -> Self {
Self::new(0)
}
}
#[derive(Debug, Clone)]
pub struct FilePermissions {
#[cfg(unix)]
mode: u32,
#[cfg(not(unix))]
readonly: bool,
}
impl FilePermissions {
#[cfg(unix)]
pub fn from_mode(mode: u32) -> Self {
Self { mode }
}
#[cfg(not(unix))]
pub fn from_mode(mode: u32) -> Self {
Self {
readonly: mode & 0o222 == 0,
}
}
#[cfg(unix)]
pub fn from_std(perms: std::fs::Permissions) -> Self {
use std::os::unix::fs::PermissionsExt;
Self { mode: perms.mode() }
}
#[cfg(not(unix))]
pub fn from_std(perms: std::fs::Permissions) -> Self {
Self {
readonly: perms.readonly(),
}
}
#[cfg(unix)]
pub fn to_std(&self) -> std::fs::Permissions {
use std::os::unix::fs::PermissionsExt;
std::fs::Permissions::from_mode(self.mode)
}
#[cfg(not(unix))]
pub fn to_std(&self) -> std::fs::Permissions {
let mut perms = std::fs::Permissions::from(std::fs::metadata(".").unwrap().permissions());
perms.set_readonly(self.readonly);
perms
}
#[cfg(unix)]
pub fn mode(&self) -> u32 {
self.mode
}
pub fn is_readonly(&self) -> bool {
#[cfg(unix)]
{
self.mode & 0o222 == 0
}
#[cfg(not(unix))]
{
self.readonly
}
}
#[cfg(unix)]
pub fn is_readonly_for_user(
&self,
user_uid: u32,
file_uid: u32,
file_gid: u32,
user_groups: &[u32],
) -> bool {
if user_uid == 0 {
return false;
}
if user_uid == file_uid {
return self.mode & 0o200 == 0;
}
if user_groups.contains(&file_gid) {
return self.mode & 0o020 == 0;
}
self.mode & 0o002 == 0
}
}
pub trait FileWriter: Write + Send {
fn sync_all(&self) -> io::Result<()>;
}
#[derive(Debug, Clone)]
pub enum WriteOp<'a> {
Copy { offset: u64, len: u64 },
Insert { data: &'a [u8] },
}
struct StdFileWriter(std::fs::File);
impl Write for StdFileWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
impl FileWriter for StdFileWriter {
fn sync_all(&self) -> io::Result<()> {
self.0.sync_all()
}
}
pub trait FileReader: Read + Seek + Send {}
struct StdFileReader(std::fs::File);
impl Read for StdFileReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.0.read(buf)
}
}
impl Seek for StdFileReader {
fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
self.0.seek(pos)
}
}
impl FileReader for StdFileReader {}
#[derive(Clone, Debug)]
pub struct FileSearchOptions {
pub fixed_string: bool,
pub case_sensitive: bool,
pub whole_word: bool,
pub max_matches: usize,
}
#[derive(Clone, Debug)]
pub struct FileSearchCursor {
pub offset: usize,
pub running_line: usize,
pub done: bool,
pub end_offset: Option<usize>,
}
impl Default for FileSearchCursor {
fn default() -> Self {
Self {
offset: 0,
running_line: 1,
done: false,
end_offset: None,
}
}
}
impl FileSearchCursor {
pub fn new() -> Self {
Self::default()
}
pub fn for_range(offset: usize, end_offset: usize, running_line: usize) -> Self {
Self {
offset,
running_line,
done: false,
end_offset: Some(end_offset),
}
}
}
#[derive(Clone, Debug)]
pub struct SearchMatch {
pub byte_offset: usize,
pub length: usize,
pub line: usize,
pub column: usize,
pub context: String,
}
pub trait FileSystem: Send + Sync {
fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>>;
fn count_line_feeds_in_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<usize> {
let data = self.read_range(path, offset, len)?;
Ok(data.iter().filter(|&&b| b == b'\n').count())
}
fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()>;
fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>>;
fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>>;
fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()>;
fn write_patched(&self, src_path: &Path, dst_path: &Path, ops: &[WriteOp]) -> io::Result<()> {
let mut buffer = Vec::new();
for op in ops {
match op {
WriteOp::Copy { offset, len } => {
let data = self.read_range(src_path, *offset, *len as usize)?;
buffer.extend_from_slice(&data);
}
WriteOp::Insert { data } => {
buffer.extend_from_slice(data);
}
}
}
self.write_file(dst_path, &buffer)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
fn copy(&self, from: &Path, to: &Path) -> io::Result<u64>;
fn remove_file(&self, path: &Path) -> io::Result<()>;
fn remove_dir(&self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
for entry in self.read_dir(path)? {
if entry.is_dir() {
self.remove_dir_all(&entry.path)?;
} else {
self.remove_file(&entry.path)?;
}
}
self.remove_dir(path)
}
fn copy_dir_all(&self, src: &Path, dst: &Path) -> io::Result<()> {
self.create_dir_all(dst)?;
for entry in self.read_dir(src)? {
let dst_child = dst.join(&entry.name);
if entry.is_dir() {
self.copy_dir_all(&entry.path, &dst_child)?;
} else {
self.copy(&entry.path, &dst_child)?;
}
}
Ok(())
}
fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
fn exists(&self, path: &Path) -> bool {
self.metadata(path).is_ok()
}
fn metadata_if_exists(&self, path: &Path) -> Option<FileMetadata> {
self.metadata(path).ok()
}
fn is_dir(&self, path: &Path) -> io::Result<bool>;
fn is_file(&self, path: &Path) -> io::Result<bool>;
fn is_writable(&self, path: &Path) -> bool {
self.metadata(path).map(|m| !m.is_readonly).unwrap_or(false)
}
fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()>;
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
fn create_dir(&self, path: &Path) -> io::Result<()>;
fn create_dir_all(&self, path: &Path) -> io::Result<()>;
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
fn current_uid(&self) -> u32;
fn is_owner(&self, path: &Path) -> bool {
#[cfg(unix)]
{
if let Ok(meta) = self.metadata(path) {
if let Some(uid) = meta.uid {
return uid == self.current_uid();
}
}
true
}
#[cfg(not(unix))]
{
let _ = path;
true
}
}
fn temp_path_for(&self, path: &Path) -> PathBuf {
path.with_extension("tmp")
}
fn unique_temp_path(&self, dest_path: &Path) -> PathBuf {
let temp_dir = std::env::temp_dir();
let file_name = dest_path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("fresh-save"));
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
temp_dir.join(format!(
"{}-{}-{}.tmp",
file_name.to_string_lossy(),
std::process::id(),
timestamp
))
}
fn remote_connection_info(&self) -> Option<&str> {
None
}
fn is_remote_connected(&self) -> bool {
true
}
fn home_dir(&self) -> io::Result<PathBuf> {
dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))
}
fn search_file(
&self,
path: &Path,
pattern: &str,
opts: &FileSearchOptions,
cursor: &mut FileSearchCursor,
) -> io::Result<Vec<SearchMatch>>;
fn sudo_write(&self, path: &Path, data: &[u8], mode: u32, uid: u32, gid: u32)
-> io::Result<()>;
fn walk_files(
&self,
root: &Path,
skip_dirs: &[&str],
cancel: &std::sync::atomic::AtomicBool,
on_file: &mut dyn FnMut(&Path, &str) -> bool,
) -> io::Result<()>;
}
pub trait FileSystemExt: FileSystem {
fn read_file_async(
&self,
path: &Path,
) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
async { self.read_file(path) }
}
fn read_range_async(
&self,
path: &Path,
offset: u64,
len: usize,
) -> impl std::future::Future<Output = io::Result<Vec<u8>>> + Send {
async move { self.read_range(path, offset, len) }
}
fn count_line_feeds_in_range_async(
&self,
path: &Path,
offset: u64,
len: usize,
) -> impl std::future::Future<Output = io::Result<usize>> + Send {
async move { self.count_line_feeds_in_range(path, offset, len) }
}
fn write_file_async(
&self,
path: &Path,
data: &[u8],
) -> impl std::future::Future<Output = io::Result<()>> + Send {
async { self.write_file(path, data) }
}
fn metadata_async(
&self,
path: &Path,
) -> impl std::future::Future<Output = io::Result<FileMetadata>> + Send {
async { self.metadata(path) }
}
fn exists_async(&self, path: &Path) -> impl std::future::Future<Output = bool> + Send {
async { self.exists(path) }
}
fn is_dir_async(
&self,
path: &Path,
) -> impl std::future::Future<Output = io::Result<bool>> + Send {
async { self.is_dir(path) }
}
fn is_file_async(
&self,
path: &Path,
) -> impl std::future::Future<Output = io::Result<bool>> + Send {
async { self.is_file(path) }
}
fn read_dir_async(
&self,
path: &Path,
) -> impl std::future::Future<Output = io::Result<Vec<DirEntry>>> + Send {
async { self.read_dir(path) }
}
fn canonicalize_async(
&self,
path: &Path,
) -> impl std::future::Future<Output = io::Result<PathBuf>> + Send {
async { self.canonicalize(path) }
}
}
impl<T: FileSystem> FileSystemExt for T {}
pub fn build_search_regex(
pattern: &str,
opts: &FileSearchOptions,
) -> io::Result<regex::bytes::Regex> {
let re_pattern = if opts.fixed_string {
regex::escape(pattern)
} else {
pattern.to_string()
};
let re_pattern = if opts.whole_word {
format!(r"\b{}\b", re_pattern)
} else {
re_pattern
};
let re_pattern = if opts.case_sensitive {
re_pattern
} else {
format!("(?i){}", re_pattern)
};
regex::bytes::Regex::new(&re_pattern)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))
}
pub fn default_search_file(
fs: &dyn FileSystem,
path: &Path,
pattern: &str,
opts: &FileSearchOptions,
cursor: &mut FileSearchCursor,
) -> io::Result<Vec<SearchMatch>> {
if cursor.done {
return Ok(vec![]);
}
const CHUNK_SIZE: usize = 1_048_576; let overlap = pattern.len().max(256);
let file_len = fs.metadata(path)?.size as usize;
let effective_end = cursor.end_offset.unwrap_or(file_len).min(file_len);
if cursor.offset == 0 && cursor.end_offset.is_none() {
if file_len == 0 {
cursor.done = true;
return Ok(vec![]);
}
let header_len = file_len.min(8192);
let header = fs.read_range(path, 0, header_len)?;
if header.contains(&0) {
cursor.done = true;
return Ok(vec![]);
}
}
if cursor.offset >= effective_end {
cursor.done = true;
return Ok(vec![]);
}
let regex = build_search_regex(pattern, opts)?;
let read_start = cursor.offset.saturating_sub(overlap);
let read_end = (read_start + CHUNK_SIZE).min(effective_end);
let chunk = fs.read_range(path, read_start as u64, read_end - read_start)?;
let overlap_len = cursor.offset - read_start;
let newlines_in_overlap = chunk[..overlap_len].iter().filter(|&&b| b == b'\n').count();
let mut line_at = cursor.running_line.saturating_sub(newlines_in_overlap);
let mut counted_to = 0usize;
let mut matches = Vec::new();
for m in regex.find_iter(&chunk) {
if overlap_len > 0 && m.end() <= overlap_len {
continue;
}
if matches.len() >= opts.max_matches {
break;
}
line_at += chunk[counted_to..m.start()]
.iter()
.filter(|&&b| b == b'\n')
.count();
counted_to = m.start();
let line_start = chunk[..m.start()]
.iter()
.rposition(|&b| b == b'\n')
.map(|p| p + 1)
.unwrap_or(0);
let line_end = chunk[m.start()..]
.iter()
.position(|&b| b == b'\n')
.map(|p| m.start() + p)
.unwrap_or(chunk.len());
let column = m.start() - line_start + 1;
let context = String::from_utf8_lossy(&chunk[line_start..line_end]).into_owned();
matches.push(SearchMatch {
byte_offset: read_start + m.start(),
length: m.end() - m.start(),
line: line_at,
column,
context,
});
}
let new_data = &chunk[overlap_len..];
cursor.running_line += new_data.iter().filter(|&&b| b == b'\n').count();
cursor.offset = read_end;
if read_end >= effective_end {
cursor.done = true;
}
Ok(matches)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StdFileSystem;
impl StdFileSystem {
fn is_hidden(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'))
}
#[cfg(unix)]
pub fn current_user_groups() -> (u32, Vec<u32>) {
let euid = unsafe { libc::geteuid() };
let egid = unsafe { libc::getegid() };
let mut groups = vec![egid];
let ngroups = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
if ngroups > 0 {
let mut sup_groups = vec![0 as libc::gid_t; ngroups as usize];
let n = unsafe { libc::getgroups(ngroups, sup_groups.as_mut_ptr()) };
if n > 0 {
sup_groups.truncate(n as usize);
for g in sup_groups {
if g != egid {
groups.push(g);
}
}
}
}
(euid, groups)
}
fn build_metadata(path: &Path, meta: &std::fs::Metadata) -> FileMetadata {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let file_uid = meta.uid();
let file_gid = meta.gid();
let permissions = FilePermissions::from_std(meta.permissions());
let (euid, user_groups) = Self::current_user_groups();
let is_readonly =
permissions.is_readonly_for_user(euid, file_uid, file_gid, &user_groups);
FileMetadata {
size: meta.len(),
modified: meta.modified().ok(),
permissions: Some(permissions),
is_hidden: Self::is_hidden(path),
is_readonly,
uid: Some(file_uid),
gid: Some(file_gid),
}
}
#[cfg(not(unix))]
{
FileMetadata {
size: meta.len(),
modified: meta.modified().ok(),
permissions: Some(FilePermissions::from_std(meta.permissions())),
is_hidden: Self::is_hidden(path),
is_readonly: meta.permissions().readonly(),
}
}
}
}
impl FileSystem for StdFileSystem {
fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
let data = std::fs::read(path)?;
crate::services::counters::global().inc_disk_bytes_read(data.len() as u64);
Ok(data)
}
fn read_range(&self, path: &Path, offset: u64, len: usize) -> io::Result<Vec<u8>> {
let mut file = std::fs::File::open(path)?;
file.seek(io::SeekFrom::Start(offset))?;
let mut buffer = vec![0u8; len];
file.read_exact(&mut buffer)?;
crate::services::counters::global().inc_disk_bytes_read(len as u64);
Ok(buffer)
}
fn write_file(&self, path: &Path, data: &[u8]) -> io::Result<()> {
let original_metadata = self.metadata_if_exists(path);
let temp_path = self.temp_path_for(path);
{
let mut file = self.create_file(&temp_path)?;
file.write_all(data)?;
file.sync_all()?;
}
if let Some(ref meta) = original_metadata {
if let Some(ref perms) = meta.permissions {
#[allow(clippy::let_underscore_must_use)]
let _ = self.set_permissions(&temp_path, perms);
}
}
self.rename(&temp_path, path)?;
Ok(())
}
fn create_file(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
let file = std::fs::File::create(path)?;
Ok(Box::new(StdFileWriter(file)))
}
fn open_file(&self, path: &Path) -> io::Result<Box<dyn FileReader>> {
let file = std::fs::File::open(path)?;
Ok(Box::new(StdFileReader(file)))
}
fn open_file_for_write(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
let file = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path)?;
Ok(Box::new(StdFileWriter(file)))
}
fn open_file_for_append(&self, path: &Path) -> io::Result<Box<dyn FileWriter>> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(Box::new(StdFileWriter(file)))
}
fn set_file_length(&self, path: &Path, len: u64) -> io::Result<()> {
let file = std::fs::OpenOptions::new().write(true).open(path)?;
file.set_len(len)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
fn copy(&self, from: &Path, to: &Path) -> io::Result<u64> {
std::fs::copy(from, to)
}
fn remove_file(&self, path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
fn remove_dir(&self, path: &Path) -> io::Result<()> {
std::fs::remove_dir(path)
}
fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
let meta = std::fs::metadata(path)?;
Ok(Self::build_metadata(path, &meta))
}
fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
let meta = std::fs::symlink_metadata(path)?;
Ok(Self::build_metadata(path, &meta))
}
fn is_dir(&self, path: &Path) -> io::Result<bool> {
Ok(std::fs::metadata(path)?.is_dir())
}
fn is_file(&self, path: &Path) -> io::Result<bool> {
Ok(std::fs::metadata(path)?.is_file())
}
fn set_permissions(&self, path: &Path, permissions: &FilePermissions) -> io::Result<()> {
std::fs::set_permissions(path, permissions.to_std())
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
let mut entries = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let file_type = entry.file_type()?;
let entry_type = if file_type.is_dir() {
EntryType::Directory
} else if file_type.is_symlink() {
EntryType::Symlink
} else {
EntryType::File
};
let mut dir_entry = DirEntry::new(path.clone(), name, entry_type);
if file_type.is_symlink() {
dir_entry.symlink_target_is_dir = std::fs::metadata(&path)
.map(|m| m.is_dir())
.unwrap_or(false);
}
entries.push(dir_entry);
}
Ok(entries)
}
fn create_dir(&self, path: &Path) -> io::Result<()> {
std::fs::create_dir(path)
}
fn create_dir_all(&self, path: &Path) -> io::Result<()> {
std::fs::create_dir_all(path)
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
std::fs::canonicalize(path)
}
fn current_uid(&self) -> u32 {
#[cfg(all(unix, feature = "runtime"))]
{
unsafe { libc::getuid() }
}
#[cfg(not(all(unix, feature = "runtime")))]
{
0
}
}
fn sudo_write(
&self,
path: &Path,
data: &[u8],
mode: u32,
uid: u32,
gid: u32,
) -> io::Result<()> {
use crate::services::process_hidden::HideWindow;
use std::process::{Command, Stdio};
let mut child = Command::new("sudo")
.args(["tee", &path.to_string_lossy()])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.hide_window()
.spawn()
.map_err(|e| io::Error::other(format!("failed to spawn sudo: {}", e)))?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin.write_all(data)?;
}
let output = child.wait_with_output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("sudo tee failed: {}", stderr.trim()),
));
}
let status = Command::new("sudo")
.args(["chmod", &format!("{:o}", mode), &path.to_string_lossy()])
.hide_window()
.status()?;
if !status.success() {
return Err(io::Error::other("sudo chmod failed"));
}
let status = Command::new("sudo")
.args([
"chown",
&format!("{}:{}", uid, gid),
&path.to_string_lossy(),
])
.hide_window()
.status()?;
if !status.success() {
return Err(io::Error::other("sudo chown failed"));
}
Ok(())
}
fn search_file(
&self,
path: &Path,
pattern: &str,
opts: &FileSearchOptions,
cursor: &mut FileSearchCursor,
) -> io::Result<Vec<SearchMatch>> {
default_search_file(self, path, pattern, opts, cursor)
}
fn walk_files(
&self,
root: &Path,
skip_dirs: &[&str],
cancel: &std::sync::atomic::AtomicBool,
on_file: &mut dyn FnMut(&Path, &str) -> bool,
) -> io::Result<()> {
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(());
}
let iter = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => continue,
};
for entry in iter {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
return Ok(());
}
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') {
continue;
}
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
let path = entry.path();
if ft.is_file() {
if let Ok(rel) = path.strip_prefix(root) {
let rel_str = rel.to_string_lossy().replace('\\', "/");
if !on_file(&path, &rel_str) {
return Ok(());
}
}
} else if ft.is_dir() && !skip_dirs.contains(&name_str.as_ref()) {
stack.push(path);
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopFileSystem;
impl NoopFileSystem {
fn unsupported<T>() -> io::Result<T> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Filesystem not available",
))
}
}
impl FileSystem for NoopFileSystem {
fn read_file(&self, _path: &Path) -> io::Result<Vec<u8>> {
Self::unsupported()
}
fn read_range(&self, _path: &Path, _offset: u64, _len: usize) -> io::Result<Vec<u8>> {
Self::unsupported()
}
fn write_file(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
Self::unsupported()
}
fn create_file(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
Self::unsupported()
}
fn open_file(&self, _path: &Path) -> io::Result<Box<dyn FileReader>> {
Self::unsupported()
}
fn open_file_for_write(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
Self::unsupported()
}
fn open_file_for_append(&self, _path: &Path) -> io::Result<Box<dyn FileWriter>> {
Self::unsupported()
}
fn set_file_length(&self, _path: &Path, _len: u64) -> io::Result<()> {
Self::unsupported()
}
fn rename(&self, _from: &Path, _to: &Path) -> io::Result<()> {
Self::unsupported()
}
fn copy(&self, _from: &Path, _to: &Path) -> io::Result<u64> {
Self::unsupported()
}
fn remove_file(&self, _path: &Path) -> io::Result<()> {
Self::unsupported()
}
fn remove_dir(&self, _path: &Path) -> io::Result<()> {
Self::unsupported()
}
fn metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
Self::unsupported()
}
fn symlink_metadata(&self, _path: &Path) -> io::Result<FileMetadata> {
Self::unsupported()
}
fn is_dir(&self, _path: &Path) -> io::Result<bool> {
Self::unsupported()
}
fn is_file(&self, _path: &Path) -> io::Result<bool> {
Self::unsupported()
}
fn set_permissions(&self, _path: &Path, _permissions: &FilePermissions) -> io::Result<()> {
Self::unsupported()
}
fn read_dir(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
Self::unsupported()
}
fn create_dir(&self, _path: &Path) -> io::Result<()> {
Self::unsupported()
}
fn create_dir_all(&self, _path: &Path) -> io::Result<()> {
Self::unsupported()
}
fn canonicalize(&self, _path: &Path) -> io::Result<PathBuf> {
Self::unsupported()
}
fn current_uid(&self) -> u32 {
0
}
fn search_file(
&self,
_path: &Path,
_pattern: &str,
_opts: &FileSearchOptions,
_cursor: &mut FileSearchCursor,
) -> io::Result<Vec<SearchMatch>> {
Self::unsupported()
}
fn sudo_write(
&self,
_path: &Path,
_data: &[u8],
_mode: u32,
_uid: u32,
_gid: u32,
) -> io::Result<()> {
Self::unsupported()
}
fn walk_files(
&self,
_root: &Path,
_skip_dirs: &[&str],
_cancel: &std::sync::atomic::AtomicBool,
_on_file: &mut dyn FnMut(&Path, &str) -> bool,
) -> io::Result<()> {
Self::unsupported()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_std_filesystem_read_write() {
let fs = StdFileSystem;
let mut temp = NamedTempFile::new().unwrap();
let path = temp.path().to_path_buf();
std::io::Write::write_all(&mut temp, b"Hello, World!").unwrap();
std::io::Write::flush(&mut temp).unwrap();
let content = fs.read_file(&path).unwrap();
assert_eq!(content, b"Hello, World!");
let range = fs.read_range(&path, 7, 5).unwrap();
assert_eq!(range, b"World");
let meta = fs.metadata(&path).unwrap();
assert_eq!(meta.size, 13);
}
#[test]
fn test_noop_filesystem() {
let fs = NoopFileSystem;
let path = Path::new("/some/path");
assert!(fs.read_file(path).is_err());
assert!(fs.read_range(path, 0, 10).is_err());
assert!(fs.write_file(path, b"data").is_err());
assert!(fs.metadata(path).is_err());
assert!(fs.read_dir(path).is_err());
}
#[test]
fn test_create_and_write_file() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
{
let mut writer = fs.create_file(&path).unwrap();
writer.write_all(b"test content").unwrap();
writer.sync_all().unwrap();
}
let content = fs.read_file(&path).unwrap();
assert_eq!(content, b"test content");
}
#[test]
fn test_read_dir() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
fs.create_dir(&temp_dir.path().join("subdir")).unwrap();
fs.write_file(&temp_dir.path().join("file1.txt"), b"content1")
.unwrap();
fs.write_file(&temp_dir.path().join("file2.txt"), b"content2")
.unwrap();
let entries = fs.read_dir(temp_dir.path()).unwrap();
assert_eq!(entries.len(), 3);
let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"subdir"));
assert!(names.contains(&"file1.txt"));
assert!(names.contains(&"file2.txt"));
}
#[test]
fn test_dir_entry_types() {
let file = DirEntry::new(PathBuf::from("/file"), "file".to_string(), EntryType::File);
assert!(file.is_file());
assert!(!file.is_dir());
let dir = DirEntry::new(
PathBuf::from("/dir"),
"dir".to_string(),
EntryType::Directory,
);
assert!(dir.is_dir());
assert!(!dir.is_file());
let link_to_dir = DirEntry::new_symlink(PathBuf::from("/link"), "link".to_string(), true);
assert!(link_to_dir.is_symlink());
assert!(link_to_dir.is_dir());
}
#[test]
fn test_metadata_builder() {
let meta = FileMetadata::default()
.with_hidden(true)
.with_readonly(true);
assert!(meta.is_hidden);
assert!(meta.is_readonly);
}
#[test]
fn test_atomic_write() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("atomic_test.txt");
fs.write_file(&path, b"initial").unwrap();
assert_eq!(fs.read_file(&path).unwrap(), b"initial");
fs.write_file(&path, b"updated").unwrap();
assert_eq!(fs.read_file(&path).unwrap(), b"updated");
}
#[test]
fn test_write_patched_default_impl() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let src_path = temp_dir.path().join("source.txt");
let dst_path = temp_dir.path().join("dest.txt");
fs.write_file(&src_path, b"AAABBBCCC").unwrap();
let ops = vec![
WriteOp::Copy { offset: 0, len: 3 }, WriteOp::Insert { data: b"XXX" }, WriteOp::Copy { offset: 6, len: 3 }, ];
fs.write_patched(&src_path, &dst_path, &ops).unwrap();
let result = fs.read_file(&dst_path).unwrap();
assert_eq!(result, b"AAAXXXCCC");
}
#[test]
fn test_write_patched_same_file() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("file.txt");
fs.write_file(&path, b"Hello World").unwrap();
let ops = vec![
WriteOp::Copy { offset: 0, len: 6 }, WriteOp::Insert { data: b"Rust" }, ];
fs.write_patched(&path, &path, &ops).unwrap();
let result = fs.read_file(&path).unwrap();
assert_eq!(result, b"Hello Rust");
}
#[test]
fn test_write_patched_insert_only() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let src_path = temp_dir.path().join("empty.txt");
let dst_path = temp_dir.path().join("new.txt");
fs.write_file(&src_path, b"").unwrap();
let ops = vec![WriteOp::Insert {
data: b"All new content",
}];
fs.write_patched(&src_path, &dst_path, &ops).unwrap();
let result = fs.read_file(&dst_path).unwrap();
assert_eq!(result, b"All new content");
}
fn make_search_opts(pattern_is_fixed: bool) -> FileSearchOptions {
FileSearchOptions {
fixed_string: pattern_is_fixed,
case_sensitive: true,
whole_word: false,
max_matches: 100,
}
}
#[test]
fn test_search_file_basic() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
fs.write_file(&path, b"hello world\nfoo bar\nhello again\n")
.unwrap();
let opts = make_search_opts(true);
let mut cursor = FileSearchCursor::new();
let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
assert!(cursor.done);
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].line, 1);
assert_eq!(matches[0].column, 1);
assert_eq!(matches[0].context, "hello world");
assert_eq!(matches[1].line, 3);
assert_eq!(matches[1].column, 1);
assert_eq!(matches[1].context, "hello again");
}
#[test]
fn test_search_file_no_matches() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
fs.write_file(&path, b"hello world\n").unwrap();
let opts = make_search_opts(true);
let mut cursor = FileSearchCursor::new();
let matches = fs
.search_file(&path, "NOTFOUND", &opts, &mut cursor)
.unwrap();
assert!(cursor.done);
assert!(matches.is_empty());
}
#[test]
fn test_search_file_case_insensitive() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
fs.write_file(&path, b"Hello HELLO hello\n").unwrap();
let opts = FileSearchOptions {
fixed_string: true,
case_sensitive: false,
whole_word: false,
max_matches: 100,
};
let mut cursor = FileSearchCursor::new();
let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
assert_eq!(matches.len(), 3);
}
#[test]
fn test_search_file_whole_word() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
fs.write_file(&path, b"cat concatenate catalog\n").unwrap();
let opts = FileSearchOptions {
fixed_string: true,
case_sensitive: true,
whole_word: true,
max_matches: 100,
};
let mut cursor = FileSearchCursor::new();
let matches = fs.search_file(&path, "cat", &opts, &mut cursor).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].column, 1);
}
#[test]
fn test_search_file_regex() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
fs.write_file(&path, b"foo123 bar456 baz\n").unwrap();
let opts = FileSearchOptions {
fixed_string: false,
case_sensitive: true,
whole_word: false,
max_matches: 100,
};
let mut cursor = FileSearchCursor::new();
let matches = fs
.search_file(&path, r"[a-z]+\d+", &opts, &mut cursor)
.unwrap();
assert_eq!(matches.len(), 2);
assert_eq!(matches[0].context, "foo123 bar456 baz");
}
#[test]
fn test_search_file_binary_skipped() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("binary.dat");
let mut data = b"hello world\n".to_vec();
data.push(0); data.extend_from_slice(b"hello again\n");
fs.write_file(&path, &data).unwrap();
let opts = make_search_opts(true);
let mut cursor = FileSearchCursor::new();
let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
assert!(cursor.done);
assert!(matches.is_empty());
}
#[test]
fn test_search_file_empty_file() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("empty.txt");
fs.write_file(&path, b"").unwrap();
let opts = make_search_opts(true);
let mut cursor = FileSearchCursor::new();
let matches = fs.search_file(&path, "hello", &opts, &mut cursor).unwrap();
assert!(cursor.done);
assert!(matches.is_empty());
}
#[test]
fn test_search_file_max_matches() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.txt");
fs.write_file(&path, b"aa bb aa cc aa dd aa\n").unwrap();
let opts = FileSearchOptions {
fixed_string: true,
case_sensitive: true,
whole_word: false,
max_matches: 2,
};
let mut cursor = FileSearchCursor::new();
let matches = fs.search_file(&path, "aa", &opts, &mut cursor).unwrap();
assert_eq!(matches.len(), 2);
}
#[test]
fn test_search_file_cursor_multi_chunk() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("large.txt");
let mut content = Vec::new();
for i in 0..100_000 {
content.extend_from_slice(format!("line {} content here\n", i).as_bytes());
}
fs.write_file(&path, &content).unwrap();
let opts = FileSearchOptions {
fixed_string: true,
case_sensitive: true,
whole_word: false,
max_matches: 1000,
};
let mut cursor = FileSearchCursor::new();
let mut all_matches = Vec::new();
while !cursor.done {
let batch = fs
.search_file(&path, "line 5000", &opts, &mut cursor)
.unwrap();
all_matches.extend(batch);
}
assert_eq!(all_matches.len(), 11);
let first = &all_matches[0];
assert_eq!(first.line, 5001); assert_eq!(first.column, 1);
assert!(first.context.starts_with("line 5000"));
}
#[test]
fn test_search_file_cursor_no_duplicates() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("large.txt");
let mut content = Vec::new();
for i in 0..100_000 {
content.extend_from_slice(format!("MARKER_{:06}\n", i).as_bytes());
}
fs.write_file(&path, &content).unwrap();
let opts = FileSearchOptions {
fixed_string: true,
case_sensitive: true,
whole_word: false,
max_matches: 200_000,
};
let mut cursor = FileSearchCursor::new();
let mut all_matches = Vec::new();
let mut batches = 0;
while !cursor.done {
let batch = fs
.search_file(&path, "MARKER_", &opts, &mut cursor)
.unwrap();
all_matches.extend(batch);
batches += 1;
}
assert!(batches > 1, "Expected multiple batches, got {}", batches);
assert_eq!(all_matches.len(), 100_000);
let mut offsets: Vec<usize> = all_matches.iter().map(|m| m.byte_offset).collect();
offsets.sort();
offsets.dedup();
assert_eq!(offsets.len(), 100_000);
}
#[test]
fn test_search_file_line_numbers_across_chunks() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("large.txt");
let mut content = Vec::new();
let total_lines = 100_000;
for i in 0..total_lines {
if i == 99_999 {
content.extend_from_slice(b"FINDME at the end\n");
} else {
content.extend_from_slice(format!("padding line {}\n", i).as_bytes());
}
}
fs.write_file(&path, &content).unwrap();
let opts = make_search_opts(true);
let mut cursor = FileSearchCursor::new();
let mut all_matches = Vec::new();
while !cursor.done {
let batch = fs.search_file(&path, "FINDME", &opts, &mut cursor).unwrap();
all_matches.extend(batch);
}
assert_eq!(all_matches.len(), 1);
assert_eq!(all_matches[0].line, total_lines); assert_eq!(all_matches[0].context, "FINDME at the end");
}
#[test]
fn test_search_file_end_offset_bounds_search() {
let fs = StdFileSystem;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("bounded.txt");
fs.write_file(&path, b"AAA\nBBB\nCCC\nDDD\n").unwrap();
let opts = make_search_opts(true);
let mut cursor = FileSearchCursor::for_range(0, 8, 1);
let mut matches = Vec::new();
while !cursor.done {
matches.extend(fs.search_file(&path, "AAA", &opts, &mut cursor).unwrap());
}
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].context, "AAA");
assert_eq!(matches[0].line, 1);
let mut cursor = FileSearchCursor::for_range(0, 8, 1);
let ccc = fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap();
assert!(ccc.is_empty(), "CCC should not be found in first 8 bytes");
let mut cursor = FileSearchCursor::for_range(8, 16, 3);
let mut matches = Vec::new();
while !cursor.done {
matches.extend(fs.search_file(&path, "CCC", &opts, &mut cursor).unwrap());
}
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].context, "CCC");
assert_eq!(matches[0].line, 3);
}
fn make_walk_tree() -> tempfile::TempDir {
let fs = StdFileSystem;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs.write_file(&root.join("a.txt"), b"a").unwrap();
fs.write_file(&root.join("b.txt"), b"b").unwrap();
fs.create_dir_all(&root.join("sub/deep")).unwrap();
fs.write_file(&root.join("sub/c.txt"), b"c").unwrap();
fs.write_file(&root.join("sub/deep/d.txt"), b"d").unwrap();
fs.create_dir_all(&root.join(".hidden_dir")).unwrap();
fs.write_file(&root.join(".hidden_dir/secret.txt"), b"s")
.unwrap();
fs.write_file(&root.join(".hidden_file"), b"h").unwrap();
fs.create_dir_all(&root.join("node_modules")).unwrap();
fs.write_file(&root.join("node_modules/pkg.json"), b"{}")
.unwrap();
fs.create_dir_all(&root.join("target")).unwrap();
fs.write_file(&root.join("target/debug.o"), b"elf").unwrap();
tmp
}
#[test]
fn test_walk_files_std_basic() {
let tmp = make_walk_tree();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
fs.walk_files(
tmp.path(),
&["node_modules", "target"],
&cancel,
&mut |_path, rel| {
found.push(rel.to_string());
true
},
)
.unwrap();
found.sort();
assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt", "sub/deep/d.txt"]);
}
#[test]
fn test_walk_files_std_skips_hidden() {
let tmp = make_walk_tree();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
found.push(rel.to_string());
true
})
.unwrap();
assert!(!found.iter().any(|f| f.contains(".hidden")));
assert!(found.iter().any(|f| f.contains("node_modules")));
assert!(found.iter().any(|f| f.contains("target")));
}
#[test]
fn test_walk_files_std_skip_dirs() {
let tmp = make_walk_tree();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
fs.walk_files(
tmp.path(),
&["node_modules", "target", "deep"],
&cancel,
&mut |_path, rel| {
found.push(rel.to_string());
true
},
)
.unwrap();
found.sort();
assert_eq!(found, vec!["a.txt", "b.txt", "sub/c.txt"]);
}
#[test]
fn test_walk_files_std_cancel() {
let tmp = make_walk_tree();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
fs.walk_files(
tmp.path(),
&["node_modules", "target"],
&cancel,
&mut |_path, rel| {
found.push(rel.to_string());
cancel.store(true, std::sync::atomic::Ordering::Relaxed);
true
},
)
.unwrap();
assert_eq!(found.len(), 1, "Should stop after cancel is set");
}
#[test]
fn test_walk_files_std_on_file_returns_false() {
let tmp = make_walk_tree();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut count = 0usize;
fs.walk_files(
tmp.path(),
&["node_modules", "target"],
&cancel,
&mut |_path, _rel| {
count += 1;
count < 2 },
)
.unwrap();
assert_eq!(count, 2, "Should stop when on_file returns false");
}
#[test]
fn test_walk_files_std_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
fs.walk_files(tmp.path(), &[], &cancel, &mut |_path, rel| {
found.push(rel.to_string());
true
})
.unwrap();
assert!(found.is_empty());
}
#[test]
fn test_walk_files_std_nonexistent_root() {
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
let result = fs.walk_files(
Path::new("/nonexistent/path/that/does/not/exist"),
&[],
&cancel,
&mut |_path, rel| {
found.push(rel.to_string());
true
},
);
assert!(result.is_ok());
assert!(found.is_empty());
}
#[test]
fn test_walk_files_std_relative_paths_use_forward_slashes() {
let tmp = make_walk_tree();
let fs = StdFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let mut found: Vec<String> = Vec::new();
fs.walk_files(
tmp.path(),
&["node_modules", "target"],
&cancel,
&mut |_path, rel| {
found.push(rel.to_string());
true
},
)
.unwrap();
for path in &found {
assert!(!path.contains('\\'), "Path should use / not \\: {}", path);
}
}
#[test]
fn test_walk_files_noop_returns_error() {
let fs = NoopFileSystem;
let cancel = std::sync::atomic::AtomicBool::new(false);
let result = fs.walk_files(Path::new("/noop/path"), &[], &cancel, &mut |_path, _rel| {
true
});
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Unsupported);
}
}