Download crates/forge_walker/src/walker.rs from SaylorTwift/forgecode: direct link, hf CLI and curl.
- Browser
- Download file 25.5 kB
-
https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_walker/src/walker.rs
- Command line
-
hf download hf://SaylorTwift/forgecode/crates/forge_walker/src/walker.rs
-
curl -L -o walker.rs https://huggingface.co/SaylorTwift/forgecode/resolve/main/crates/forge_walker/src/walker.rs
25.5 kB
| 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; | |
| 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('/') | |
| } | |
| } | |
| pub struct Walker { | |
| /// Base directory to start walking from | |
| cwd: PathBuf, | |
| /// Maximum depth of directory traversal | |
| max_depth: usize, | |
| /// Maximum number of entries per directory | |
| max_breadth: usize, | |
| /// Maximum size of individual files to process | |
| max_file_size: u64, | |
| /// Maximum number of files to process in total | |
| max_files: usize, | |
| /// Maximum total size of all files combined | |
| max_total_size: u64, | |
| /// Whether to skip binary files | |
| skip_binary: bool, | |
| /// Whether to hide hidden files and directories (those starting with `.`). | |
| /// When `true` (the default), dotfiles are excluded from results. | |
| /// Set to `false` to include them, matching `fd --hidden`. | |
| hidden: bool, | |
| } | |
| const DEFAULT_MAX_FILE_SIZE: u64 = 1024 * 1024; // 1MB | |
| const DEFAULT_MAX_FILES: usize = 100; | |
| const DEFAULT_MAX_TOTAL_SIZE: u64 = 10 * 1024 * 1024; // 10MB | |
| const DEFAULT_MAX_DEPTH: usize = 5; | |
| const DEFAULT_MAX_BREADTH: usize = 10; | |
| impl Walker { | |
| /// Creates a new Walker instance with all settings set to conservative | |
| /// values. | |
| 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, | |
| } | |
| } | |
| /// Creates a new Walker instance with all settings set to maximum values. | |
| /// NOTE: This could produce a large number of files and should be used with | |
| /// carefully. | |
| 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, | |
| // Include hidden files (dotfiles) — matches `fd --hidden`. | |
| 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(); | |
| // List of common binary file extensions loaded from file | |
| 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 | |
| } | |
| } | |
| /// Blocking function to scan filesystem. Use this when you already have | |
| /// a runtime or want to avoid spawning a new one. | |
| pub fn get_blocking(&self) -> Result<Vec<File>> { | |
| // Shared state collected across parallel walker threads. | |
| let collected: Arc<Mutex<Vec<File>>> = Arc::new(Mutex::new(Vec::new())); | |
| // Per-directory entry counters for breadth limiting (shared across threads). | |
| let dir_entries: Arc<Mutex<HashMap<String, usize>>> = Arc::new(Mutex::new(HashMap::new())); | |
| // Global counters protected by a single mutex to enforce total limits. | |
| // Layout: (total_size, file_count, quit) | |
| 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; | |
| // TODO: Convert to async and return a stream | |
| let walk_parallel = WalkBuilder::new(&self.cwd) | |
| .standard_filters(true) // use standard ignore filters. | |
| .hidden(self.hidden) | |
| .require_git(false) | |
| .max_depth(Some(self.max_depth)) | |
| // Skip files that exceed size limit | |
| .max_filesize(Some(self.max_file_size)) | |
| .filter_entry(|entry| { | |
| // Always exclude the `.git` directory, matching `fd --exclude .git`. | |
| entry.file_name() != ".git" | |
| }) | |
| .build_parallel(); | |
| walk_parallel.run(|| { | |
| // Each thread gets its own clone of the shared state. | |
| 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| { | |
| // Check if a previous thread already triggered the quit signal. | |
| { | |
| 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(); | |
| // Skip symlinks — we only process real files and directories. | |
| if entry.path_is_symlink() { | |
| return ignore::WalkState::Continue; | |
| } | |
| // Calculate depth relative to base directory. | |
| let depth = path | |
| .strip_prefix(&cwd) | |
| .map(|p| p.components().count()) | |
| .unwrap_or(0); | |
| // Skip the root directory itself (depth 0 = the cwd), matching | |
| // `fd` behaviour which never emits the starting directory. | |
| if depth == 0 { | |
| return ignore::WalkState::Continue; | |
| } | |
| if depth > max_depth { | |
| return ignore::WalkState::Continue; | |
| } | |
| // Handle breadth limit — uses a shared mutex. | |
| 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(); | |
| // Skip binary files if configured. | |
| 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(); | |
| // Enforce global total-size and file-count limits atomically. | |
| { | |
| 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; | |
| } | |
| } | |
| // Build relative path string. | |
| 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()); | |
| // Ensure directory paths end with '/' for is_dir(). | |
| let path_string = if is_dir { | |
| format!("{path_string}/") | |
| } else { | |
| path_string | |
| }; | |
| // Filter out entries whose file_size exceeds the per-file limit. | |
| // (WalkBuilder::max_filesize only applies to regular files; double-check.) | |
| 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) | |
| } | |
| } | |
| mod tests { | |
| use std::fs::{self}; | |
| use pretty_assertions::assert_eq; | |
| use tempfile::{TempDir, tempdir}; | |
| use super::*; | |
| /// Test Fixtures | |
| 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() | |
| } | |
| } | |
| /// Creates a directory with files of specified sizes | |
| /// Returns a TempDir containing the test files | |
| 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) | |
| } | |
| /// Creates a directory structure with specified depth and a test file | |
| /// in each directory Returns a TempDir with nested directories | |
| /// up to depth | |
| 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) | |
| } | |
| /// Creates a directory containing a specified number of files | |
| /// Returns a tuple of (TempDir, PathBuf) where PathBuf points to the | |
| /// directory containing files | |
| 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)) | |
| } | |
| } | |
| 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; // Only small.txt should be included | |
| assert_eq!( | |
| actual.iter().filter(|f| !f.is_dir()).count(), | |
| expected, | |
| "Walker should only include files within size limit" | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| 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" | |
| ); | |
| } | |
| async fn test_file_name_and_is_dir() { | |
| // Use a file inside a subdirectory so the walker emits both a directory | |
| // entry ("subdir/") and a file entry ("subdir/test.txt"). | |
| // The root directory itself is never emitted (matching `fd` behaviour). | |
| 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('/')); | |
| } | |
| 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(); | |
| // .ignore itself is a dotfile and is visible when hidden: false (matches fd | |
| // --hidden). | |
| 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" | |
| ); | |
| } | |
| fn test_is_likely_binary_detects_binary_files() { | |
| use std::path::Path; | |
| // Test known binary extensions | |
| 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"); | |
| } | |
| } | |
| fn test_is_likely_binary_allows_text_files() { | |
| use std::path::Path; | |
| // Test known text extensions | |
| 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"); | |
| } | |
| } | |
| fn test_is_likely_binary_handles_edge_cases() { | |
| use std::path::Path; | |
| // Test files without extensions | |
| 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" | |
| ); | |
| } | |
| // Test case sensitivity | |
| 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 | |
| ); | |
| } | |
| } | |
| async fn test_walker_respects_nested_gitignore() { | |
| let fixture = fixtures::Fixture::default(); | |
| // Root and nested .gitignore files | |
| fixture.add_file(".gitignore", "*.log\n").unwrap(); | |
| fixture | |
| .add_file("frontend/.gitignore", "node_modules/\n") | |
| .unwrap(); | |
| // Files to exclude | |
| fixture.add_file("debug.log", "").unwrap(); | |
| fixture | |
| .add_file("frontend/node_modules/lib/index.js", "") | |
| .unwrap(); | |
| // Files to include | |
| 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(); | |
| // .gitignore files are dotfiles and visible when hidden: false (matches fd | |
| // --hidden). | |
| let expected = vec![ | |
| ".gitignore", | |
| "frontend/.gitignore", | |
| "frontend/src/main.ts", | |
| "src/main.rs", | |
| ]; | |
| assert_eq!(actual, expected, "should respect nested .gitignore files"); | |
| } | |
| async fn test_walker_respects_nested_gitignore_with_git_repo() { | |
| let fixture = fixtures::Fixture::default(); | |
| // Create a .git directory to simulate a real git repository | |
| 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(); | |
| // .gitignore files are dotfiles and visible when hidden: false (matches fd | |
| // --hidden). .git directory is always excluded (matching fd --exclude | |
| // .git). | |
| let expected = vec![ | |
| ".gitignore", | |
| "frontend/.gitignore", | |
| "frontend/src/main.ts", | |
| "src/main.rs", | |
| ]; | |
| assert_eq!( | |
| actual, expected, | |
| "should respect nested .gitignore in git repos" | |
| ); | |
| } | |
| async fn test_walker_excludes_symlinks() { | |
| let fixture = fixtures::Fixture::default(); | |
| // Real file that should appear in results. | |
| fixture.add_file("real.txt", "content").unwrap(); | |
| // Symlink pointing to the real file — must be excluded. | |
| 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" | |
| ); | |
| } | |
| async fn test_walker_excludes_dangling_symlinks() { | |
| let fixture = fixtures::Fixture::default(); | |
| // Real file that should appear in results. | |
| fixture.add_file("present.txt", "").unwrap(); | |
| // Dangling symlink — target does not exist. | |
| 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" | |
| ); | |
| } | |
| } | |