From 7a4b64c7ed1f9d024475a9f5520c2ef16c150d1b Mon Sep 17 00:00:00 2001 From: Sahil Gandhi Date: Wed, 18 Feb 2026 15:27:20 -0800 Subject: [PATCH] fix(find): accept native find flags (-name, -type, etc.) --- src/find_cmd.rs | 329 ++++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 25 +--- 2 files changed, 324 insertions(+), 30 deletions(-) diff --git a/src/find_cmd.rs b/src/find_cmd.rs index 679288e..25da54e 100644 --- a/src/find_cmd.rs +++ b/src/find_cmd.rs @@ -1,5 +1,5 @@ use crate::tracking; -use anyhow::Result; +use anyhow::{Context, Result}; use ignore::WalkBuilder; use std::collections::HashMap; use std::path::Path; @@ -23,11 +23,178 @@ fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool { } } +/// Parsed arguments from either native find or RTK find syntax. +#[derive(Debug)] +struct FindArgs { + pattern: String, + path: String, + max_results: usize, + max_depth: Option, + file_type: String, + case_insensitive: bool, +} + +impl Default for FindArgs { + fn default() -> Self { + Self { + pattern: "*".to_string(), + path: ".".to_string(), + max_results: 50, + max_depth: None, + file_type: "f".to_string(), + case_insensitive: false, + } + } +} + +/// Consume the next argument from `args` at position `i`, advancing the index. +/// Returns `None` if `i` is past the end of `args`. +fn next_arg(args: &[String], i: &mut usize) -> Option { + *i += 1; + args.get(*i).cloned() +} + +/// Check if args contain native find flags (-name, -type, -maxdepth, etc.) +fn has_native_find_flags(args: &[String]) -> bool { + args.iter() + .any(|a| a == "-name" || a == "-type" || a == "-maxdepth" || a == "-iname") +} + +/// Native find flags that RTK cannot handle correctly. +/// These involve compound predicates, actions, or semantics we don't support. +const UNSUPPORTED_FIND_FLAGS: &[&str] = &[ + "-not", "!", "-or", "-o", "-and", "-a", "-exec", "-execdir", "-delete", "-print0", "-newer", + "-perm", "-size", "-mtime", "-mmin", "-atime", "-amin", "-ctime", "-cmin", "-empty", "-link", + "-regex", "-iregex", +]; + +fn has_unsupported_find_flags(args: &[String]) -> bool { + args.iter() + .any(|a| UNSUPPORTED_FIND_FLAGS.contains(&a.as_str())) +} + +/// Parse arguments from raw args vec, supporting both native find and RTK syntax. +/// +/// Native find syntax: `find . -name "*.rs" -type f -maxdepth 3` +/// RTK syntax: `find *.rs [path] [-m max] [-t type]` +fn parse_find_args(args: &[String]) -> Result { + if args.is_empty() { + return Ok(FindArgs::default()); + } + + if has_unsupported_find_flags(args) { + anyhow::bail!( + "rtk find does not support compound predicates or actions (e.g. -not, -exec). Use `find` directly." + ); + } + + if has_native_find_flags(args) { + parse_native_find_args(args) + } else { + parse_rtk_find_args(args) + } +} + +/// Parse native find syntax: `find [path] -name "*.rs" -type f -maxdepth 3` +fn parse_native_find_args(args: &[String]) -> Result { + let mut parsed = FindArgs::default(); + let mut i = 0; + + // First non-flag argument is the path (standard find behavior) + if !args[0].starts_with('-') { + parsed.path = args[0].clone(); + i = 1; + } + + while i < args.len() { + match args[i].as_str() { + "-name" => { + if let Some(val) = next_arg(args, &mut i) { + parsed.pattern = val; + } + } + "-iname" => { + if let Some(val) = next_arg(args, &mut i) { + parsed.pattern = val; + parsed.case_insensitive = true; + } + } + "-type" => { + if let Some(val) = next_arg(args, &mut i) { + parsed.file_type = val; + } + } + "-maxdepth" => { + if let Some(val) = next_arg(args, &mut i) { + parsed.max_depth = Some(val.parse().context("invalid -maxdepth value")?); + } + } + flag if flag.starts_with('-') => { + eprintln!("rtk find: unknown flag '{}', ignored", flag); + } + _ => {} + } + i += 1; + } + + Ok(parsed) +} + +/// Parse RTK syntax: `find [path] [-m max] [-t type]` +fn parse_rtk_find_args(args: &[String]) -> Result { + let mut parsed = FindArgs { + pattern: args[0].clone(), + ..FindArgs::default() + }; + let mut i = 1; + + // Second positional arg (if not a flag) is the path + if i < args.len() && !args[i].starts_with('-') { + parsed.path = args[i].clone(); + i += 1; + } + + while i < args.len() { + match args[i].as_str() { + "-m" | "--max" => { + if let Some(val) = next_arg(args, &mut i) { + parsed.max_results = val.parse().context("invalid --max value")?; + } + } + "-t" | "--file-type" => { + if let Some(val) = next_arg(args, &mut i) { + parsed.file_type = val; + } + } + _ => {} + } + i += 1; + } + + Ok(parsed) +} + +/// Entry point from main.rs — parses raw args then delegates to run(). +pub fn run_from_args(args: &[String], verbose: u8) -> Result<()> { + let parsed = parse_find_args(args)?; + run( + &parsed.pattern, + &parsed.path, + parsed.max_results, + parsed.max_depth, + &parsed.file_type, + parsed.case_insensitive, + verbose, + ) +} + pub fn run( pattern: &str, path: &str, max_results: usize, + max_depth: Option, file_type: &str, + case_insensitive: bool, verbose: u8, ) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -41,12 +208,16 @@ pub fn run( let want_dirs = file_type == "d"; - let walker = WalkBuilder::new(path) + let mut builder = WalkBuilder::new(path); + builder .hidden(true) // skip hidden files/dirs .git_ignore(true) // respect .gitignore .git_global(true) - .git_exclude(true) - .build(); + .git_exclude(true); + if let Some(depth) = max_depth { + builder.max_depth(Some(depth)); + } + let walker = builder.build(); let mut files: Vec = Vec::new(); @@ -57,7 +228,7 @@ pub fn run( }; let ft = entry.file_type(); - let is_dir = ft.as_ref().map_or(false, |t| t.is_dir()); + let is_dir = ft.as_ref().is_some_and(|t| t.is_dir()); // Filter by type if want_dirs && !is_dir { @@ -75,7 +246,12 @@ pub fn run( None => continue, }; - if !glob_match(effective_pattern, &name) { + let matches = if case_insensitive { + glob_match(&effective_pattern.to_lowercase(), &name.to_lowercase()) + } else { + glob_match(effective_pattern, &name) + }; + if !matches { continue; } @@ -206,6 +382,11 @@ pub fn run( mod tests { use super::*; + /// Convert string slices to Vec for test convenience. + fn args(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() + } + // --- glob_match unit tests --- #[test] @@ -251,39 +432,165 @@ mod tests { assert_eq!(effective, "*"); } + // --- parse_find_args: native find syntax --- + + #[test] + fn parse_native_find_name() { + let parsed = parse_find_args(&args(&[".", "-name", "*.rs"])).unwrap(); + assert_eq!(parsed.pattern, "*.rs"); + assert_eq!(parsed.path, "."); + assert_eq!(parsed.file_type, "f"); + assert_eq!(parsed.max_results, 50); + } + + #[test] + fn parse_native_find_name_and_type() { + let parsed = parse_find_args(&args(&["src", "-name", "*.rs", "-type", "f"])).unwrap(); + assert_eq!(parsed.pattern, "*.rs"); + assert_eq!(parsed.path, "src"); + assert_eq!(parsed.file_type, "f"); + } + + #[test] + fn parse_native_find_type_d() { + let parsed = parse_find_args(&args(&[".", "-type", "d"])).unwrap(); + assert_eq!(parsed.pattern, "*"); + assert_eq!(parsed.file_type, "d"); + } + + #[test] + fn parse_native_find_maxdepth() { + let parsed = parse_find_args(&args(&[".", "-name", "*.toml", "-maxdepth", "2"])).unwrap(); + assert_eq!(parsed.pattern, "*.toml"); + assert_eq!(parsed.max_depth, Some(2)); + assert_eq!(parsed.max_results, 50); // max_results unchanged by -maxdepth + } + + #[test] + fn parse_native_find_iname() { + let parsed = parse_find_args(&args(&[".", "-iname", "Makefile"])).unwrap(); + assert_eq!(parsed.pattern, "Makefile"); + assert!(parsed.case_insensitive); + } + + #[test] + fn parse_native_find_name_is_case_sensitive() { + let parsed = parse_find_args(&args(&[".", "-name", "*.rs"])).unwrap(); + assert!(!parsed.case_insensitive); + } + + #[test] + fn parse_native_find_no_path() { + // `find -name "*.rs"` without explicit path defaults to "." + let parsed = parse_find_args(&args(&["-name", "*.rs"])).unwrap(); + assert_eq!(parsed.pattern, "*.rs"); + assert_eq!(parsed.path, "."); + } + + // --- parse_find_args: unsupported flags --- + + #[test] + fn parse_native_find_rejects_not() { + let result = parse_find_args(&args(&[".", "-name", "*.rs", "-not", "-name", "*_test.rs"])); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("compound predicates")); + } + + #[test] + fn parse_native_find_rejects_exec() { + let result = parse_find_args(&args(&[".", "-name", "*.tmp", "-exec", "rm", "{}", ";"])); + assert!(result.is_err()); + } + + // --- parse_find_args: RTK syntax --- + + #[test] + fn parse_rtk_syntax_pattern_only() { + let parsed = parse_find_args(&args(&["*.rs"])).unwrap(); + assert_eq!(parsed.pattern, "*.rs"); + assert_eq!(parsed.path, "."); + } + + #[test] + fn parse_rtk_syntax_pattern_and_path() { + let parsed = parse_find_args(&args(&["*.rs", "src"])).unwrap(); + assert_eq!(parsed.pattern, "*.rs"); + assert_eq!(parsed.path, "src"); + } + + #[test] + fn parse_rtk_syntax_with_flags() { + let parsed = parse_find_args(&args(&["*.rs", "src", "-m", "10", "-t", "d"])).unwrap(); + assert_eq!(parsed.pattern, "*.rs"); + assert_eq!(parsed.path, "src"); + assert_eq!(parsed.max_results, 10); + assert_eq!(parsed.file_type, "d"); + } + + #[test] + fn parse_empty_args() { + let parsed = parse_find_args(&args(&[])).unwrap(); + assert_eq!(parsed.pattern, "*"); + assert_eq!(parsed.path, "."); + } + + // --- run_from_args integration tests --- + + #[test] + fn run_from_args_native_find_syntax() { + // Simulates: find . -name "*.rs" -type f + let result = run_from_args(&args(&[".", "-name", "*.rs", "-type", "f"]), 0); + assert!(result.is_ok()); + } + + #[test] + fn run_from_args_rtk_syntax() { + // Simulates: rtk find *.rs src + let result = run_from_args(&args(&["*.rs", "src"]), 0); + assert!(result.is_ok()); + } + + #[test] + fn run_from_args_iname_case_insensitive() { + // -iname should match case-insensitively + let result = run_from_args(&args(&[".", "-iname", "cargo.toml"]), 0); + assert!(result.is_ok()); + } + // --- integration: run on this repo --- #[test] fn find_rs_files_in_src() { // Should find .rs files without error - let result = run("*.rs", "src", 100, "f", 0); + let result = run("*.rs", "src", 100, None, "f", false, 0); assert!(result.is_ok()); } #[test] fn find_dot_pattern_works() { // "." pattern should not error (was broken before) - let result = run(".", "src", 10, "f", 0); + let result = run(".", "src", 10, None, "f", false, 0); assert!(result.is_ok()); } #[test] fn find_no_matches() { - let result = run("*.xyz_nonexistent", "src", 50, "f", 0); + let result = run("*.xyz_nonexistent", "src", 50, None, "f", false, 0); assert!(result.is_ok()); } #[test] fn find_respects_max() { // With max=2, should not error - let result = run("*.rs", "src", 2, "f", 0); + let result = run("*.rs", "src", 2, None, "f", false, 0); assert!(result.is_ok()); } #[test] fn find_gitignored_excluded() { // target/ is in .gitignore — files inside should not appear - let result = run("*", ".", 1000, "f", 0); + let result = run("*", ".", 1000, None, "f", false, 0); assert!(result.is_ok()); // We can't easily capture stdout in unit tests, but at least // verify it runs without error. The smoke tests verify content. diff --git a/src/main.rs b/src/main.rs index d9de230..4c30d21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -181,19 +181,11 @@ enum Commands { show_all: bool, }, - /// Find files with compact tree output + /// Find files with compact tree output (accepts native find flags like -name, -type) Find { - /// Pattern to search (glob) - pattern: String, - /// Path to search in - #[arg(default_value = ".")] - path: String, - /// Maximum results to show - #[arg(short, long, default_value = "50")] - max: usize, - /// Filter by type: f (file), d (directory) - #[arg(short = 't', long, default_value = "f")] - file_type: String, + /// All find arguments (supports both RTK and native find syntax) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, }, /// Ultra-condensed diff (only changed lines) @@ -984,13 +976,8 @@ fn main() -> Result<()> { env_cmd::run(filter.as_deref(), show_all, cli.verbose)?; } - Commands::Find { - pattern, - path, - max, - file_type, - } => { - find_cmd::run(&pattern, &path, max, &file_type, cli.verbose)?; + Commands::Find { args } => { + find_cmd::run_from_args(&args, cli.verbose)?; } Commands::Diff { file1, file2 } => {