sherlock-io 0.1.0

SherlockIO - Lightning-fast language detection CLI tool
Documentation
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();
        
        // Always include the root directory (current directory ".")
        if file_name == "." {
            return true;
        }
        
        // Skip hidden files/directories unless explicitly requested
        if !self.include_hidden && file_name.starts_with('.') && !matches!(file_name.as_ref(), ".gitignore" | ".gitkeep") {
            return false;
        }

        // Skip ignored directories
        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("");

        // Skip ignored files
        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> {
        // Quick check for common binary extensions
        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);
            }
        }

        // For small files, check content
        if let Ok(content) = fs::read(path) {
            if content.len() > 8192 {
                // Only check first 8KB for large files
                return Ok(content[..8192].contains(&0));
            }
            return Ok(content.contains(&0));
        }

        Ok(false)
    }
}