| use std::collections::HashMap; |
| use std::path::PathBuf; |
| use std::sync::{Arc, Mutex}; |
|
|
| use anyhow::{Context, Result}; |
| use derive_setters::Setters; |
| use ignore::WalkBuilder; |
| use tokio::task::spawn_blocking; |
|
|
| #[derive(Clone, Debug)] |
| pub struct File { |
| pub path: String, |
| pub file_name: Option<String>, |
| pub size: u64, |
| } |
|
|
| impl File { |
| pub fn is_dir(&self) -> bool { |
| self.path.ends_with('/') |
| } |
| } |
|
|
| #[derive(Debug, Clone, Setters)] |
| pub struct Walker { |
| |
| cwd: PathBuf, |
|
|
| |
| max_depth: usize, |
|
|
| |
| max_breadth: usize, |
|
|
| |
| max_file_size: u64, |
|
|
| |
| max_files: usize, |
|
|
| |
| max_total_size: u64, |
|
|
| |
| skip_binary: bool, |
|
|
| |
| |
| |
| hidden: bool, |
| } |
|
|
| const DEFAULT_MAX_FILE_SIZE: u64 = 1024 * 1024; |
| const DEFAULT_MAX_FILES: usize = 100; |
| const DEFAULT_MAX_TOTAL_SIZE: u64 = 10 * 1024 * 1024; |
| const DEFAULT_MAX_DEPTH: usize = 5; |
| const DEFAULT_MAX_BREADTH: usize = 10; |
|
|
| impl Walker { |
| |
| |
| pub fn min_all() -> Self { |
| Self { |
| cwd: PathBuf::new(), |
| max_depth: DEFAULT_MAX_DEPTH, |
| max_breadth: DEFAULT_MAX_BREADTH, |
| max_file_size: DEFAULT_MAX_FILE_SIZE, |
| max_files: DEFAULT_MAX_FILES, |
| max_total_size: DEFAULT_MAX_TOTAL_SIZE, |
| skip_binary: true, |
| hidden: true, |
| } |
| } |
|
|
| |
| |
| |
| pub fn max_all() -> Self { |
| Self { |
| cwd: PathBuf::new(), |
| max_depth: usize::MAX, |
| max_breadth: usize::MAX, |
| max_file_size: u64::MAX, |
| max_files: usize::MAX, |
| max_total_size: u64::MAX, |
| skip_binary: false, |
| |
| hidden: false, |
| } |
| } |
| } |
|
|
| impl Walker { |
| pub async fn get(&self) -> Result<Vec<File>> { |
| let walker = self.clone(); |
| spawn_blocking(move || walker.get_blocking()) |
| .await |
| .context("Failed to spawn blocking task")? |
| } |
|
|
| fn is_likely_binary(path: &std::path::Path) -> bool { |
| if let Some(extension) = path.extension() { |
| let ext = extension.to_string_lossy().to_lowercase(); |
| |
| let binary_extensions_str = include_str!("binary_extensions.txt"); |
| let binary_extensions: Vec<&str> = binary_extensions_str |
| .lines() |
| .map(|line| line.trim()) |
| .filter(|line| !line.is_empty()) |
| .collect(); |
| binary_extensions.contains(&ext.as_ref()) |
| } else { |
| false |
| } |
| } |
|
|
| |
| |
| pub fn get_blocking(&self) -> Result<Vec<File>> { |
| |
| let collected: Arc<Mutex<Vec<File>>> = Arc::new(Mutex::new(Vec::new())); |
| |
| let dir_entries: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new())); |
| |
| |
| let global: Arc<Mutex<(u64, usize, bool)>> = Arc::new(Mutex::new((0, 0, false))); |
|
|
| let cwd = self.cwd.clone(); |
| let max_depth = self.max_depth; |
| let max_breadth = self.max_breadth; |
| let max_file_size = self.max_file_size; |
| let max_files = self.max_files; |
| let max_total_size = self.max_total_size; |
| let skip_binary = self.skip_binary; |
|
|
| |
| let walk_parallel = WalkBuilder::new(&self.cwd) |
| .standard_filters(true) |
| .hidden(self.hidden) |
| .require_git(false) |
| .max_depth(Some(self.max_depth)) |
| |
| .max_filesize(Some(self.max_file_size)) |
| .filter_entry(|entry| { |
| |
| entry.file_name() != ".git" |
| }) |
| .build_parallel(); |
|
|
| walk_parallel.run(|| { |
| |
| let collected = Arc::clone(&collected); |
| let dir_entries = Arc::clone(&dir_entries); |
| let global = Arc::clone(&global); |
| let cwd = cwd.clone(); |
|
|
| Box::new(move |result| { |
| |
| { |
| let g = global.lock().unwrap(); |
| if g.2 { |
| return ignore::WalkState::Quit; |
| } |
| } |
|
|
| let entry = match result { |
| Ok(e) => e, |
| Err(_) => return ignore::WalkState::Continue, |
| }; |
|
|
| let path = entry.path(); |
|
|
| |
| if entry.path_is_symlink() { |
| return ignore::WalkState::Continue; |
| } |
|
|
| |
| let depth = path |
| .strip_prefix(&cwd) |
| .map(|p| p.components().count()) |
| .unwrap_or(0); |
|
|
| |
| |
| if depth == 0 { |
| return ignore::WalkState::Continue; |
| } |
|
|
| if depth > max_depth { |
| return ignore::WalkState::Continue; |
| } |
|
|
| |
| if let Some(parent) = path.parent() { |
| let parent_path = parent.to_string_lossy().to_string(); |
| let mut de = dir_entries.lock().unwrap(); |
| let entry_count = de.entry(parent_path).or_insert(0); |
| *entry_count += 1; |
| if *entry_count > max_breadth { |
| return ignore::WalkState::Continue; |
| } |
| } |
|
|
| let is_dir = path.is_dir(); |
|
|
| |
| if skip_binary && !is_dir && Walker::is_likely_binary(path) { |
| return ignore::WalkState::Continue; |
| } |
|
|
| let metadata = match path.metadata() { |
| Ok(meta) => meta, |
| Err(_) => return ignore::WalkState::Continue, |
| }; |
|
|
| let file_size = metadata.len(); |
|
|
| |
| { |
| let mut g = global.lock().unwrap(); |
| if g.2 { |
| return ignore::WalkState::Quit; |
| } |
| if g.0 + file_size > max_total_size { |
| g.2 = true; |
| return ignore::WalkState::Quit; |
| } |
| if !is_dir { |
| if g.1 >= max_files { |
| g.2 = true; |
| return ignore::WalkState::Quit; |
| } |
| g.1 += 1; |
| g.0 += file_size; |
| } |
| } |
|
|
| |
| let relative_path = match path.strip_prefix(&cwd) { |
| Ok(p) => p, |
| Err(_) => return ignore::WalkState::Continue, |
| }; |
| let path_string = relative_path.to_string_lossy().to_string(); |
|
|
| let file_name = path |
| .file_name() |
| .map(|name| name.to_string_lossy().to_string()); |
|
|
| |
| let path_string = if is_dir { |
| format!("{path_string}/") |
| } else { |
| path_string |
| }; |
|
|
| |
| |
| if !is_dir && file_size > max_file_size { |
| return ignore::WalkState::Continue; |
| } |
|
|
| collected.lock().unwrap().push(File { |
| path: path_string, |
| file_name, |
| size: file_size, |
| }); |
|
|
| ignore::WalkState::Continue |
| }) |
| }); |
|
|
| let files = Arc::try_unwrap(collected) |
| .expect("all walker threads finished") |
| .into_inner() |
| .unwrap(); |
|
|
| Ok(files) |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::fs::{self}; |
|
|
| use pretty_assertions::assert_eq; |
| use tempfile::{TempDir, tempdir}; |
|
|
| use super::*; |
|
|
| |
| mod fixtures { |
| use std::fs::{File, create_dir_all}; |
| use std::io::Write; |
|
|
| use super::*; |
|
|
| pub struct Fixture(TempDir); |
|
|
| impl Default for Fixture { |
| fn default() -> Self { |
| let dir = tempdir().expect("Failed to create temp directory"); |
| Fixture(dir) |
| } |
| } |
|
|
| impl Fixture { |
| pub fn add_file(&self, name: &str, content: &str) -> Result<()> { |
| let file_path = self.0.path().join(name); |
| if let Some(parent) = file_path.parent() { |
| create_dir_all(parent)?; |
| } |
| File::create(file_path.as_path())?.write_all(content.as_bytes())?; |
| Ok(()) |
| } |
|
|
| pub fn as_path(&self) -> &std::path::Path { |
| self.0.path() |
| } |
| } |
|
|
| |
| |
| pub fn create_sized_files(files: &[(String, u64)]) -> Result<TempDir> { |
| let dir = tempdir()?; |
| for (name, size) in files { |
| let content = vec![b'a'; *size as usize]; |
| File::create(dir.path().join(name))?.write_all(&content)?; |
| } |
| Ok(dir) |
| } |
|
|
| |
| |
| |
| pub fn create_directory_tree(depth: usize, file_name: &str) -> Result<TempDir> { |
| let dir = tempdir()?; |
| let mut current = dir.path().to_path_buf(); |
|
|
| for i in 0..depth { |
| current = current.join(format!("level{i}")); |
| fs::create_dir(¤t)?; |
| File::create(current.join(file_name))?.write_all(b"test")?; |
| } |
| Ok(dir) |
| } |
|
|
| |
| |
| |
| pub fn create_file_collection(count: usize, prefix: &str) -> Result<(TempDir, PathBuf)> { |
| let dir = tempdir()?; |
| let files_dir = dir.path().join("files"); |
| fs::create_dir(&files_dir)?; |
|
|
| for i in 0..count { |
| File::create(files_dir.join(format!("{prefix}{i}.txt")))?.write_all(b"test")?; |
| } |
| Ok((dir, files_dir)) |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_respects_file_size_limit() { |
| let fixture = fixtures::create_sized_files(&[ |
| ("small.txt".into(), 100), |
| ("large.txt".into(), DEFAULT_MAX_FILE_SIZE + 100), |
| ]) |
| .unwrap(); |
|
|
| let actual = Walker::min_all() |
| .cwd(fixture.path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let expected = 1; |
| assert_eq!( |
| actual.iter().filter(|f| !f.is_dir()).count(), |
| expected, |
| "Walker should only include files within size limit" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_filters_binary_files() { |
| let fixture = |
| fixtures::create_sized_files(&[("text.txt".into(), 10), ("binary.exe".into(), 10)]) |
| .unwrap(); |
|
|
| let actual = Walker::min_all() |
| .cwd(fixture.path().to_path_buf()) |
| .skip_binary(true) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let expected = vec!["text.txt"]; |
| let actual_files: Vec<_> = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.as_str()) |
| .collect(); |
|
|
| assert_eq!( |
| actual_files, expected, |
| "Walker should exclude binary files when skip_binary is true" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_enforces_directory_breadth_limit() { |
| let (fixture, _) = |
| fixtures::create_file_collection(DEFAULT_MAX_BREADTH + 5, "file").unwrap(); |
|
|
| let actual = Walker::min_all() |
| .cwd(fixture.path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let expected = DEFAULT_MAX_BREADTH; |
| let actual_file_count = actual |
| .iter() |
| .filter(|f| f.path.starts_with("files/") && !f.is_dir()) |
| .count(); |
|
|
| assert_eq!( |
| actual_file_count, expected, |
| "Walker should respect the configured max_breadth limit" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_enforces_directory_depth_limit() { |
| let fixture = fixtures::create_directory_tree(DEFAULT_MAX_DEPTH + 3, "test.txt").unwrap(); |
|
|
| let actual = Walker::min_all() |
| .cwd(fixture.path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let expected = DEFAULT_MAX_DEPTH; |
| let actual_max_depth = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.split('/').count()) |
| .max() |
| .unwrap(); |
|
|
| assert_eq!( |
| actual_max_depth, expected, |
| "Walker should respect the configured max_depth limit" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_file_name_and_is_dir() { |
| |
| |
| |
| let fixture = fixtures::Fixture::default(); |
| fixture.add_file("subdir/test.txt", "hello").unwrap(); |
|
|
| let actual = Walker::min_all() |
| .cwd(fixture.as_path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let file = actual |
| .iter() |
| .find(|f| !f.is_dir()) |
| .expect("Should find a file"); |
|
|
| assert_eq!(file.file_name.as_deref(), Some("test.txt")); |
| assert!(!file.is_dir()); |
|
|
| let dir = actual |
| .iter() |
| .find(|f| f.is_dir()) |
| .expect("Should find a directory"); |
|
|
| assert!(dir.is_dir()); |
| assert!(dir.path.ends_with('/')); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_respects_ignore_file() { |
| let fixture = fixtures::Fixture::default(); |
| fixture |
| .add_file("included/test.rs", "const test: &str = \"include_test\";") |
| .unwrap(); |
| fixture |
| .add_file("included/main.rs", "const main: &str = \"include_main\";") |
| .unwrap(); |
| fixture |
| .add_file("included/main.log", "included main log content") |
| .unwrap(); |
| fixture |
| .add_file("excluded/test.rs", "const test: &str = \"exclude_test\";") |
| .unwrap(); |
| fixture |
| .add_file("excluded/main.rs", "const main: &str = \"exclude_main\";") |
| .unwrap(); |
| fixture |
| .add_file("excluded/main.log", "excluded main log content") |
| .unwrap(); |
| fixture |
| .add_file("base.rs", "const base: &str = \"base\";") |
| .unwrap(); |
| fixture |
| .add_file("main.log", "base main log content") |
| .unwrap(); |
| fixture.add_file(".ignore", "excluded/**/*\n*.log").unwrap(); |
|
|
| let actual = Walker::max_all() |
| .cwd(fixture.as_path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| |
| |
| let mut expected = vec![".ignore", "included/main.rs", "included/test.rs", "base.rs"]; |
| expected.sort(); |
|
|
| let mut actual_files: Vec<_> = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.as_str()) |
| .collect(); |
| actual_files.sort(); |
|
|
| assert_eq!( |
| actual_files, expected, |
| "Walker should exclude files listed in .ignore file" |
| ); |
| } |
|
|
| #[test] |
| fn test_is_likely_binary_detects_binary_files() { |
| use std::path::Path; |
|
|
| |
| let binary_files = [ |
| "program.exe", |
| "library.dll", |
| "archive.zip", |
| "document.pdf", |
| "music.mp3", |
| "video.mp4", |
| "image.bmp", |
| "database.sqlite", |
| "archive.tar", |
| "compressed.gz", |
| ]; |
|
|
| for file in &binary_files { |
| let path = Path::new(file); |
| let actual = Walker::is_likely_binary(path); |
| assert!(actual, "File {file} should be detected as binary"); |
| } |
| } |
|
|
| #[test] |
| fn test_is_likely_binary_allows_text_files() { |
| use std::path::Path; |
|
|
| |
| let text_files = [ |
| "source.rs", |
| "script.js", |
| "style.css", |
| "markup.html", |
| "data.json", |
| "config.yaml", |
| "readme.md", |
| "code.py", |
| "program.c", |
| "header.h", |
| ]; |
|
|
| for file in &text_files { |
| let path = Path::new(file); |
| let actual = Walker::is_likely_binary(path); |
| assert!(!actual, "File {file} should not be detected as binary"); |
| } |
| } |
|
|
| #[test] |
| fn test_is_likely_binary_handles_edge_cases() { |
| use std::path::Path; |
|
|
| |
| let no_extension_files = ["README", "Makefile", "Dockerfile", "LICENSE"]; |
|
|
| for file in &no_extension_files { |
| let path = Path::new(file); |
| let actual = Walker::is_likely_binary(path); |
| assert!( |
| !actual, |
| "File without extension {file} should not be detected as binary" |
| ); |
| } |
|
|
| |
| let case_test_files = [ |
| ("program.EXE", true), |
| ("DOCUMENT.PDF", true), |
| ("Archive.ZIP", true), |
| ("Source.RS", false), |
| ("Script.JS", false), |
| ]; |
|
|
| for (file, expected) in &case_test_files { |
| let path = Path::new(file); |
| let actual = Walker::is_likely_binary(path); |
| assert_eq!( |
| actual, *expected, |
| "File {} case sensitivity test failed", |
| file |
| ); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_respects_nested_gitignore() { |
| let fixture = fixtures::Fixture::default(); |
|
|
| |
| fixture.add_file(".gitignore", "*.log\n").unwrap(); |
| fixture |
| .add_file("frontend/.gitignore", "node_modules/\n") |
| .unwrap(); |
|
|
| |
| fixture.add_file("debug.log", "").unwrap(); |
| fixture |
| .add_file("frontend/node_modules/lib/index.js", "") |
| .unwrap(); |
|
|
| |
| fixture.add_file("src/main.rs", "").unwrap(); |
| fixture.add_file("frontend/src/main.ts", "").unwrap(); |
|
|
| let actual = Walker::max_all() |
| .cwd(fixture.as_path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let mut actual: Vec<_> = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.as_str()) |
| .collect(); |
| actual.sort(); |
| |
| |
| let expected = vec![ |
| ".gitignore", |
| "frontend/.gitignore", |
| "frontend/src/main.ts", |
| "src/main.rs", |
| ]; |
| assert_eq!(actual, expected, "should respect nested .gitignore files"); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_respects_nested_gitignore_with_git_repo() { |
| let fixture = fixtures::Fixture::default(); |
|
|
| |
| let git_dir = fixture.as_path().join(".git"); |
| std::fs::create_dir(&git_dir).unwrap(); |
| std::fs::write(git_dir.join("config"), "[core]\n").unwrap(); |
| std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").unwrap(); |
|
|
| fixture.add_file(".gitignore", "*.log\n").unwrap(); |
| fixture |
| .add_file("frontend/.gitignore", "node_modules/\n") |
| .unwrap(); |
|
|
| fixture.add_file("debug.log", "").unwrap(); |
| fixture |
| .add_file("frontend/node_modules/lib/index.js", "") |
| .unwrap(); |
| fixture.add_file("src/main.rs", "").unwrap(); |
| fixture.add_file("frontend/src/main.ts", "").unwrap(); |
|
|
| let actual = Walker::max_all() |
| .cwd(fixture.as_path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let mut actual: Vec<_> = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.as_str()) |
| .collect(); |
| actual.sort(); |
| |
| |
| |
| let expected = vec![ |
| ".gitignore", |
| "frontend/.gitignore", |
| "frontend/src/main.ts", |
| "src/main.rs", |
| ]; |
| assert_eq!( |
| actual, expected, |
| "should respect nested .gitignore in git repos" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_excludes_symlinks() { |
| let fixture = fixtures::Fixture::default(); |
|
|
| |
| fixture.add_file("real.txt", "content").unwrap(); |
|
|
| |
| let link_path = fixture.as_path().join("link.txt"); |
| std::os::unix::fs::symlink(fixture.as_path().join("real.txt"), &link_path).unwrap(); |
|
|
| let actual = Walker::max_all() |
| .cwd(fixture.as_path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let actual_files: Vec<_> = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.as_str()) |
| .collect(); |
|
|
| let expected = vec!["real.txt"]; |
| assert_eq!( |
| actual_files, expected, |
| "symlinks should be excluded from walker results" |
| ); |
| } |
|
|
| #[tokio::test] |
| async fn test_walker_excludes_dangling_symlinks() { |
| let fixture = fixtures::Fixture::default(); |
|
|
| |
| fixture.add_file("present.txt", "").unwrap(); |
|
|
| |
| let dangling = fixture.as_path().join("dangling.txt"); |
| std::os::unix::fs::symlink(fixture.as_path().join("ghost.txt"), &dangling).unwrap(); |
|
|
| let actual = Walker::max_all() |
| .cwd(fixture.as_path().to_path_buf()) |
| .get() |
| .await |
| .unwrap(); |
|
|
| let actual_files: Vec<_> = actual |
| .iter() |
| .filter(|f| !f.is_dir()) |
| .map(|f| f.path.as_str()) |
| .collect(); |
|
|
| let expected = vec!["present.txt"]; |
| assert_eq!( |
| actual_files, expected, |
| "dangling symlinks should be excluded from walker results" |
| ); |
| } |
| } |
|
|