use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct FileInfo {
pub path: PathBuf,
pub extension: Option<String>,
pub size: u64,
pub is_binary: bool,
}
pub struct Scanner {
ignored_dirs: Vec<String>,
ignored_files: Vec<String>,
include_hidden: bool,
}
impl Scanner {
pub fn new() -> Self {
Self {
ignored_dirs: vec![
".git".to_string(),
"node_modules".to_string(),
"target".to_string(),
"build".to_string(),
"dist".to_string(),
".vscode".to_string(),
".idea".to_string(),
"__pycache__".to_string(),
".pytest_cache".to_string(),
"coverage".to_string(),
".nyc_output".to_string(),
"vendor".to_string(),
"deps".to_string(),
"_build".to_string(),
],
ignored_files: vec![
".DS_Store".to_string(),
"Thumbs.db".to_string(),
".gitignore".to_string(),
".gitkeep".to_string(),
"package-lock.json".to_string(),
"yarn.lock".to_string(),
"Cargo.lock".to_string(),
],
include_hidden: false,
}
}
pub fn with_include_hidden(mut self, include_hidden: bool) -> Self {
self.include_hidden = include_hidden;
self
}
pub fn scan_directory(&self, path: &Path, max_depth: usize) -> Result<Vec<FileInfo>> {
let mut files = Vec::new();
let walker = WalkDir::new(path)
.max_depth(max_depth)
.follow_links(false)
.into_iter()
.filter_entry(|e| self.should_include_entry(e));
for entry in walker {
let entry = entry.context("Failed to read directory entry")?;
if entry.file_type().is_file() {
if let Some(file_info) = self.process_file(entry.path())? {
files.push(file_info);
}
}
}
Ok(files)
}
fn should_include_entry(&self, entry: &walkdir::DirEntry) -> bool {
let file_name = entry.file_name().to_string_lossy();
if file_name == "." {
return true;
}
if !self.include_hidden && file_name.starts_with('.') && !matches!(file_name.as_ref(), ".gitignore" | ".gitkeep") {
return false;
}
if entry.file_type().is_dir() && self.ignored_dirs.contains(&file_name.to_string()) {
return false;
}
true
}
fn process_file(&self, path: &Path) -> Result<Option<FileInfo>> {
let file_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if self.ignored_files.contains(&file_name.to_string()) {
return Ok(None);
}
let metadata = fs::metadata(path)
.context("Failed to read file metadata")?;
let extension = path.extension()
.and_then(|ext| ext.to_str())
.map(|s| s.to_lowercase());
let is_binary = self.is_binary_file(path)?;
Ok(Some(FileInfo {
path: path.to_path_buf(),
extension,
size: metadata.len(),
is_binary,
}))
}
fn is_binary_file(&self, path: &Path) -> Result<bool> {
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let binary_extensions = [
"exe", "dll", "so", "dylib", "a", "lib", "o", "obj",
"bin", "dat", "db", "sqlite", "sqlite3",
"jpg", "jpeg", "png", "gif", "bmp", "ico", "svg", "webp",
"mp3", "mp4", "avi", "mov", "wav", "flac",
"zip", "tar", "gz", "bz2", "xz", "7z", "rar",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"wasm", "jar", "class", "pyc", "pyo",
];
if binary_extensions.contains(&ext.to_lowercase().as_str()) {
return Ok(true);
}
}
if let Ok(content) = fs::read(path) {
if content.len() > 8192 {
return Ok(content[..8192].contains(&0));
}
return Ok(content.contains(&0));
}
Ok(false)
}
}