From 606a70f2c62b5d91fc29449da5f320974cea8396 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 01:22:49 +0000 Subject: [PATCH] feat(find): add -mindepth option to find builtin find -mindepth N was unrecognized, causing `find . -mindepth 1 -type f | wc -l` to output 0 (find errored on unknown predicate, producing no stdout). Add min_depth field to FindOptions, parse -mindepth argument, and suppress output for entries below the minimum depth threshold. https://claude.ai/code/session_016LNVVjp5EuQXhaQRpoS6jd --- crates/bashkit/src/builtins/ls.rs | 228 +++++++++++++++++- .../tests/spec_cases/bash/find.test.sh | 25 ++ specs/005-builtins.md | 2 +- 3 files changed, 252 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/src/builtins/ls.rs b/crates/bashkit/src/builtins/ls.rs index 030f0b68..f28e99f6 100644 --- a/crates/bashkit/src/builtins/ls.rs +++ b/crates/bashkit/src/builtins/ls.rs @@ -295,16 +295,18 @@ struct FindOptions { name_pattern: Option, type_filter: Option, max_depth: Option, + min_depth: Option, } /// The find builtin - search for files. /// -/// Usage: find [PATH...] [-name PATTERN] [-type TYPE] [-maxdepth N] [-exec CMD {} \;] +/// Usage: find [PATH...] [-name PATTERN] [-type TYPE] [-maxdepth N] [-mindepth N] [-exec CMD {} \;] /// /// Options: /// -name PATTERN Match filename against PATTERN (supports * and ?) /// -type TYPE Match file type: f (file), d (directory), l (link) /// -maxdepth N Descend at most N levels +/// -mindepth N Do not apply tests at levels less than N /// -print Print matching paths (default) /// -exec CMD {} \; Execute CMD for each match ({} = path) /// -exec CMD {} + Execute CMD once with all matches @@ -318,6 +320,7 @@ impl Builtin for Find { name_pattern: None, type_filter: None, max_depth: None, + min_depth: None, }; // Parse arguments @@ -369,6 +372,24 @@ impl Builtin for Find { } } } + "-mindepth" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "find: missing argument to '-mindepth'\n".to_string(), + 1, + )); + } + match ctx.args[i].parse::() { + Ok(n) => opts.min_depth = Some(n), + Err(_) => { + return Ok(ExecResult::err( + format!("find: invalid mindepth value '{}'\n", ctx.args[i]), + 1, + )); + } + } + } "-print" | "-print0" => { // Default action, ignore } @@ -454,8 +475,14 @@ fn find_recursive<'a>( None => true, }; + // Check min depth before outputting + let above_min_depth = match opts.min_depth { + Some(min) => current_depth >= min, + None => true, + }; + // Output if matches (or if no filters, show everything) - if type_matches && name_matches { + if type_matches && name_matches && above_min_depth { output.push_str(display_path); output.push('\n'); } @@ -1486,6 +1513,203 @@ mod tests { ); } + #[tokio::test] + async fn test_find_mindepth() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("a/file1.txt"), b"f1") + .await + .unwrap(); + fs.write_file(&cwd.join("a/b/file2.txt"), b"f2") + .await + .unwrap(); + + // mindepth 1 should exclude the starting directory "." + let args = vec!["-mindepth".to_string(), "1".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // Should NOT contain "." as the starting point (depth 0) + let lines: Vec<&str> = result.stdout.lines().collect(); + assert!(!lines.contains(&"."), "mindepth 1 should exclude '.'"); + // Should contain everything at depth >= 1 + assert!(result.stdout.contains("./a")); + assert!(result.stdout.contains("file1.txt")); + assert!(result.stdout.contains("file2.txt")); + } + + #[tokio::test] + async fn test_find_mindepth_with_type() { + // Reproduces the reported issue: find . -mindepth 1 -type f | wc -l + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("a/file1.txt"), b"f1") + .await + .unwrap(); + fs.write_file(&cwd.join("a/b/file2.txt"), b"f2") + .await + .unwrap(); + + // mindepth 1 + type f + let args = vec![ + "-mindepth".to_string(), + "1".to_string(), + "-type".to_string(), + "f".to_string(), + ]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + let lines: Vec<&str> = result.stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 2, "Should find 2 files: {:?}", lines); + + // mindepth 1 + type d + let args2 = vec![ + "-mindepth".to_string(), + "1".to_string(), + "-type".to_string(), + "d".to_string(), + ]; + let ctx2 = Context { + args: &args2, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result2 = Find.execute(ctx2).await.unwrap(); + assert_eq!(result2.exit_code, 0); + let lines2: Vec<&str> = result2.stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines2.len(), 2, "Should find 2 dirs: {:?}", lines2); + } + + #[tokio::test] + async fn test_find_mindepth_2() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("top.txt"), b"top").await.unwrap(); + fs.write_file(&cwd.join("a/mid.txt"), b"mid").await.unwrap(); + fs.write_file(&cwd.join("a/b/deep.txt"), b"deep") + .await + .unwrap(); + + // mindepth 2 should exclude depth 0 and depth 1 + let args = vec!["-mindepth".to_string(), "2".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + let lines: Vec<&str> = result.stdout.lines().collect(); + // depth 0: "." - excluded + assert!(!lines.contains(&".")); + // depth 1: "./a", "./top.txt" - excluded + assert!(!lines.contains(&"./a")); + assert!(!lines.contains(&"./top.txt")); + // depth 2: "./a/b", "./a/mid.txt" - included + assert!(lines.contains(&"./a/b")); + assert!(lines.contains(&"./a/mid.txt")); + // depth 3: "./a/b/deep.txt" - included + assert!(lines.contains(&"./a/b/deep.txt")); + } + + #[tokio::test] + async fn test_find_mindepth_missing_arg() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-mindepth".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing argument")); + } + + #[tokio::test] + async fn test_find_mindepth_invalid_value() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-mindepth".to_string(), "abc".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid mindepth")); + } + // ==================== rmdir tests ==================== #[tokio::test] diff --git a/crates/bashkit/tests/spec_cases/bash/find.test.sh b/crates/bashkit/tests/spec_cases/bash/find.test.sh index 57964433..be2779e0 100644 --- a/crates/bashkit/tests/spec_cases/bash/find.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/find.test.sh @@ -75,6 +75,31 @@ find /tmp/glob -name "test.*" | sort /tmp/glob/test.txt ### end +### find_mindepth +# Find with mindepth should skip entries below minimum depth +mkdir -p /tmp/mdtest/a/b +touch /tmp/mdtest/top.txt +touch /tmp/mdtest/a/mid.txt +touch /tmp/mdtest/a/b/deep.txt +find /tmp/mdtest -mindepth 1 -type f | sort +### expect +/tmp/mdtest/a/b/deep.txt +/tmp/mdtest/a/mid.txt +/tmp/mdtest/top.txt +### end + +### find_mindepth_2 +# Find with mindepth 2 should skip depth 0 and 1 +mkdir -p /tmp/md2test/a/b +touch /tmp/md2test/top.txt +touch /tmp/md2test/a/mid.txt +touch /tmp/md2test/a/b/deep.txt +find /tmp/md2test -mindepth 2 -type f | sort +### expect +/tmp/md2test/a/b/deep.txt +/tmp/md2test/a/mid.txt +### end + ### ls_recursive # ls -R should list nested directories mkdir -p /tmp/lsrec/a/b diff --git a/specs/005-builtins.md b/specs/005-builtins.md index f9486916..4ebcd345 100644 --- a/specs/005-builtins.md +++ b/specs/005-builtins.md @@ -127,7 +127,7 @@ Bash::builder() #### Directory Listing and Search - `ls` - List directory contents (`-l`, `-a`, `-h`, `-1`, `-R`, `-t`) -- `find` - Search for files (`-name PATTERN`, `-type f|d|l`, `-maxdepth N`, `-print`) +- `find` - Search for files (`-name PATTERN`, `-type f|d|l`, `-maxdepth N`, `-mindepth N`, `-print`) - `rmdir` - Remove empty directories (`-p` for parents) #### File Inspection