diff --git a/crates/bashkit/src/builtins/grep.rs b/crates/bashkit/src/builtins/grep.rs index 3949e1b4..8ec817d3 100644 --- a/crates/bashkit/src/builtins/grep.rs +++ b/crates/bashkit/src/builtins/grep.rs @@ -850,7 +850,7 @@ fn try_indexed_search( #[cfg(test)] mod tests { use super::*; - use crate::fs::{FileSystem, InMemoryFs}; + use crate::fs::{FileSystem, InMemoryFs, OverlayFs}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -1063,6 +1063,91 @@ mod tests { assert!(!result.stdout.contains("b.log")); } + #[tokio::test] + async fn test_grep_recursive_single_file() { + let grep = Grep; + let fs = Arc::new(InMemoryFs::new()); + fs.mkdir(&PathBuf::from("/data"), true).await.unwrap(); + fs.write_file(&PathBuf::from("/data/test.md"), b"hello world\n") + .await + .unwrap(); + + let mut vars = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = ["-r", "hello", "/data/test.md"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let ctx = Context { + args: &args, + env: &HashMap::new(), + variables: &mut vars, + cwd: &mut cwd, + fs, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = grep.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0, "grep -r on a single file should match"); + assert!( + result.stdout.contains("hello world"), + "expected 'hello world' in stdout, got: {:?}", + result.stdout + ); + } + + /// Regression: grep -r on a single file with OverlayFs returned empty + /// because OverlayFs::read_dir returned Ok(vec![]) for files instead of Err. + #[tokio::test] + async fn test_grep_recursive_single_file_overlay() { + let grep = Grep; + let base = Arc::new(InMemoryFs::new()); + let fs: Arc = Arc::new(OverlayFs::new(base)); + fs.mkdir(&PathBuf::from("/data"), true).await.unwrap(); + fs.write_file(&PathBuf::from("/data/test.md"), b"hello world\n") + .await + .unwrap(); + + let mut vars = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = ["-r", "hello", "/data/test.md"] + .iter() + .map(|s| s.to_string()) + .collect(); + + let ctx = Context { + args: &args, + env: &HashMap::new(), + variables: &mut vars, + cwd: &mut cwd, + fs, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = grep.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0, "grep -r on single file via OverlayFs"); + assert!( + result.stdout.contains("hello world"), + "expected 'hello world' in stdout, got: {:?}", + result.stdout + ); + } + #[tokio::test] async fn test_grep_recursive_exclude() { let grep = Grep; diff --git a/crates/bashkit/src/fs/overlay.rs b/crates/bashkit/src/fs/overlay.rs index 218d1ffe..56485c1e 100644 --- a/crates/bashkit/src/fs/overlay.rs +++ b/crates/bashkit/src/fs/overlay.rs @@ -619,13 +619,33 @@ impl FileSystem for OverlayFs { return Err(IoError::new(ErrorKind::NotFound, "not found").into()); } + // Check if the path is a non-directory (file/symlink) — read_dir must fail + let is_dir_lower = if let Ok(meta) = self.lower.stat(&path).await { + if !meta.file_type.is_dir() { + return Err(IoError::other("not a directory").into()); + } + true + } else { + false + }; + let is_dir_upper = if let Ok(meta) = self.upper.stat(&path).await { + if !meta.file_type.is_dir() { + return Err(IoError::other("not a directory").into()); + } + true + } else { + false + }; + + if !is_dir_lower && !is_dir_upper { + return Err(IoError::new(ErrorKind::NotFound, "not found").into()); + } + let mut entries: std::collections::HashMap = std::collections::HashMap::new(); // Get entries from lower (if not whited out) - if self.lower.exists(&path).await.unwrap_or(false) - && let Ok(lower_entries) = self.lower.read_dir(&path).await - { + if is_dir_lower && let Ok(lower_entries) = self.lower.read_dir(&path).await { for entry in lower_entries { // Skip whited out entries let entry_path = path.join(&entry.name); @@ -636,9 +656,7 @@ impl FileSystem for OverlayFs { } // Overlay with entries from upper (overriding lower) - if self.upper.exists(&path).await.unwrap_or(false) - && let Ok(upper_entries) = self.upper.read_dir(&path).await - { + if is_dir_upper && let Ok(upper_entries) = self.upper.read_dir(&path).await { for entry in upper_entries { entries.insert(entry.name.clone(), entry); } @@ -1136,6 +1154,20 @@ mod tests { assert!(names.contains(&&"upper.txt".to_string())); } + /// Regression: read_dir on a file must return Err, not Ok(vec![]) + #[tokio::test] + async fn test_read_dir_on_file_returns_error() { + let lower = Arc::new(InMemoryFs::new()); + lower + .write_file(Path::new("/tmp/file.txt"), b"data") + .await + .unwrap(); + + let overlay = OverlayFs::new(lower); + let result = overlay.read_dir(Path::new("/tmp/file.txt")).await; + assert!(result.is_err(), "read_dir on a file should return Err"); + } + // Issue #418: usage should deduct whited-out files #[tokio::test] async fn test_usage_deducts_whiteouts() {