shell_cmd_rs/
main.rs

1//! # shell-cmd-rs
2//!
3//! **shell-cmd-rs v1.3** — Recursively find files matching a regex and execute a
4//! shell command for each match.
5//!
6//! This is a drop-in replacement for the C++20 `shell-cmd` utility, rewritten
7//! entirely in Rust. It walks a directory tree, applies metadata filters (size,
8//! modification time, permissions, ownership, type), substitutes placeholders in
9//! a command template, and executes the resulting command for every matched entry.
10//!
11//! ## Features
12//!
13//! - Regex-based file matching (via the `regex` crate)
14//! - Two regex modes:
15//!   - **regex-search** (default): matches if the pattern appears anywhere in the
16//!     full path (substring match via `Regex::is_match`)
17//!   - **regex-match** (`-z`/`--regex-match`): the pattern must match the **entire**
18//!     path (anchored with `^(?:...)$`)
19//! - Glob mode (`-b`/`--glob`): use familiar wildcard patterns (`*`, `?`)
20//!   instead of regex — special characters are auto-escaped
21//! - Expression filter (`-f`/`--expr`): compose `glob()`, `regex()`,
22//!   `regex_search()`, and `regex_match()` predicates with boolean operators
23//!   `and`, `or`, `not`, and parentheses
24//! - Placeholder substitution: `%0` (filename), `%1` (full path), `%b` (stem),
25//!   `%e` (extension), `%2+` (extra args); in `--list-all` mode `%0` expands to
26//!   all matched paths joined by spaces
27//! - Metadata filters: size, modification time, permissions, owner, group, type
28//! - Exclude patterns (regex by default, or glob via `-i`/`--glob-exclude`),
29//!   dry-run, verbose, confirm mode, stop-on-error
30//! - Parallel execution via `fork`/`execv` with proper signal handling
31//! - List-all mode (`-l`/`--list-all`): collect all matches and run the command
32//!   once with `%0` expanded to the full list of matched paths
33//! - Summary statistics (matched/run/failed)
34//!
35//! ## Architecture
36//!
37//! The program flow is:
38//! 1. Parse CLI arguments via `clap` derive macros into [`Cli`]
39//! 2. Convert [`Cli`] into [`Options`] (runtime config)
40//! 3. Compile regex patterns
41//! 4. In list-all mode (`-l`), call [`fill_list()`] to collect all matches into a
42//!    vector, then invoke [`proc_cmd()`] once with `%0` expanded to all paths
43//! 5. Otherwise, call [`add_directory()`] to recursively walk the filesystem
44//!    and call [`proc_cmd()`] per match to substitute placeholders and execute
45//! 6. In parallel mode, manage child PIDs via [`CHILD_PIDS`] and drain with [`wait_all()`]
46//! 7. Print summary to stderr
47//!
48//! ## Signal Handling
49//!
50//! Command execution uses [`system_cmd()`], which mirrors the POSIX `system()`
51//! behavior with proper `SIGCHLD` blocking and `SIGINT`/`SIGQUIT` ignoring in
52//! the parent process. This prevents Ctrl+C from killing the batch runner while
53//! allowing it to reach child processes.
54
55use clap::Parser;
56use regex::Regex;
57use std::ffi::CString;
58use std::fs;
59use std::io::{self, BufRead, Write};
60use std::os::unix::fs::MetadataExt;
61use std::path::{Path, PathBuf};
62use std::process;
63use std::sync::atomic::{AtomicBool, Ordering};
64use std::sync::Mutex;
65use std::time::SystemTime;
66
67/// Check whether to use color output on the given file descriptor.
68/// Respects the NO_COLOR environment variable convention (<https://no-color.org/>).
69fn use_color(fd: i32) -> bool {
70    if std::env::var_os("NO_COLOR").is_some() {
71        return false;
72    }
73    unsafe { libc::isatty(fd) != 0 }
74}
75
76/// Print a colored error message to stderr.
77/// Prefixes the message with "Error: " (bold red when color is enabled).
78macro_rules! error {
79    ($($arg:tt)*) => {{
80        let msg = format!($($arg)*);
81        if use_color(2) {
82            eprintln!("\x1b[1;31mError:\x1b[0m {}", msg);
83        } else {
84            eprintln!("Error: {}", msg);
85        }
86    }};
87}
88
89/// Global flag set to `true` when `--stop-on-error` is active and a command has
90/// failed. Checked at the top of each iteration in [`add_directory()`] and
91/// [`proc_cmd()`] to halt processing early. Uses `SeqCst` ordering since it is
92/// only written once and read from a single thread (parallel children don't read it).
93static STOP_REQUESTED: AtomicBool = AtomicBool::new(false);
94
95/// Global flag set to `true` when SIGINT (Ctrl+C) is received.
96/// Checked alongside `STOP_REQUESTED` to halt processing and exit cleanly.
97static INTERRUPTED: AtomicBool = AtomicBool::new(false);
98
99/// Global pool of outstanding child process PIDs, used only in parallel mode
100/// (`-j N` where N > 1). Protected by a `Mutex` since we access it from the
101/// main thread only (no actual concurrent access, but the Mutex satisfies Rust's
102/// `Send`/`Sync` requirements for statics). Initialized lazily on first access.
103static CHILD_PIDS: std::sync::LazyLock<Mutex<Vec<nix::unistd::Pid>>> =
104    std::sync::LazyLock::new(|| Mutex::new(Vec::new()));
105
106/// Comparison operator for size and time filters.
107///
108/// Used by [`SizeFilter`] and [`TimeFilter`] to determine
109/// the comparison direction when testing metadata values.
110///
111/// - `Eq` — exact match (no prefix in the CLI string)
112/// - `Lt` — less than (CLI prefix `-`)
113/// - `Gt` — greater than (CLI prefix `+`)
114#[derive(Clone, Copy)]
115enum CmpOp {
116    /// Exact equality (e.g., `4096` means exactly 4096 bytes, `3` means exactly 3 days old).
117    Eq,
118    /// Less than (e.g., `-1K` means smaller than 1 KB, `-1` means newer than 1 day).
119    Lt,
120    /// Greater than (e.g., `+10M` means larger than 10 MB, `+7` means older than 7 days).
121    Gt,
122}
123
124/// Parsed size filter with comparison operator and byte threshold.
125///
126/// Created by [`parse_size_filter()`] from CLI strings like `+10M`, `-1K`, or `4096`.
127/// The `active` field indicates whether this filter was specified on the command line.
128///
129/// # Size suffixes
130///
131/// - `K` / `k` — multiply by 1024
132/// - `M` / `m` — multiply by 1024²
133/// - `G` / `g` — multiply by 1024³
134/// - No suffix — raw bytes
135#[derive(Clone)]
136struct SizeFilter {
137    /// Whether this filter is enabled (a `--size` value was provided).
138    active: bool,
139    /// Comparison direction: exact, less-than, or greater-than.
140    op: CmpOp,
141    /// Size threshold in bytes (after applying any K/M/G multiplier).
142    bytes: u64,
143}
144
145/// Parsed modification-time filter with comparison operator and day count.
146///
147/// Created by [`parse_time_filter()`] from CLI strings like `+7`, `-1`, or `3`.
148/// The `active` field indicates whether this filter was specified on the command line.
149///
150/// # Semantics
151///
152/// - `+N` — file is older than N days (age > N)
153/// - `-N` — file is newer than N days (age < N)
154/// - `N`  — file is exactly N days old (age == N)
155///
156/// Age is computed as `(now - mtime) / 86400` (integer division, in hours/24).
157#[derive(Clone)]
158struct TimeFilter {
159    /// Whether this filter is enabled (a `--mtime` value was provided).
160    active: bool,
161    /// Comparison direction: exact, less-than, or greater-than.
162    op: CmpOp,
163    /// Age threshold in days.
164    days: i64,
165}
166
167/// Execution statistics printed in the summary line.
168///
169/// Tracks how many files matched, how many commands were executed (or would have
170/// been in dry-run mode), and how many commands returned a non-zero exit code.
171/// The summary is printed to stderr at the end of execution when verbose, dry-run,
172/// or any command has failed.
173struct Stats {
174    /// Number of directory entries that matched the regex and all metadata filters.
175    files_matched: i32,
176    /// Number of commands executed (or printed in dry-run mode).
177    commands_run: i32,
178    /// Number of commands that returned a non-zero exit status.
179    commands_failed: i32,
180}
181
182/// Command-line interface definition using `clap` derive macros.
183///
184/// This struct defines every flag and option that `shell-cmd-rs` accepts.
185/// Positional arguments (path, command, regex, extras) are collected into
186/// the `args` field via `trailing_var_arg`.
187///
188/// # Compatibility
189///
190/// The short flags, long flags, and their semantics are identical to the
191/// original C++ `shell-cmd` to ensure drop-in compatibility.
192#[derive(Parser)]
193#[command(
194    name = "shell-cmd-rs",
195    version = "1.3.0",
196    about = "Recursively find files matching regex and run command for each.",
197    after_help = "\
198placeholders:
199  %0          filename only (no path)
200  %1          full path to matched file
201  %2+         extra arguments from command line
202  %b          basename without extension
203  %e          file extension (including dot)
204
205regex modes:
206  (default)   regex-search — pattern matches anywhere in the path
207  -z          regex-match  — pattern must match the entire path
208
209glob mode:
210  -b          treat pattern as a glob (*, ?) instead of regex
211              special regex characters are auto-escaped
212
213expr mode:
214  -f EXPR     compose glob(), regex(), regex_match() with and/or/not
215              e.g. --expr '(glob(\"*.cpp\") or glob(\"*.hpp\")) and not regex(\"build\")'"
216)]
217struct Cli {
218    /// Dry-run, print commands without executing
219    #[arg(short = 'n', long = "dry-run")]
220    dry_run: bool,
221
222    /// Verbose, print each command before running
223    #[arg(short = 'v', long = "verbose")]
224    verbose: bool,
225
226    /// Include hidden files/directories
227    #[arg(short = 'a', long = "all")]
228    all: bool,
229
230    /// Max recursion depth (0 = current dir only)
231    #[arg(short = 'd', long = "depth")]
232    depth: Option<i32>,
233
234    /// Filter by size: +10M (>10MB), -1K (<1KB), 4096 (exactly 4096 bytes). Suffixes: K, M, G
235    #[arg(short = 's', long = "size")]
236    size: Option<String>,
237
238    /// Filter by modification time: +7 (older than 7 days), -1 (modified within last day), 3 (exactly 3 days)
239    #[arg(short = 'm', long = "mtime")]
240    mtime: Option<String>,
241
242    /// Filter by permissions (octal), e.g. 755
243    #[arg(short = 'p', long = "perm")]
244    perm: Option<String>,
245
246    /// Filter by owner username
247    #[arg(short = 'u', long = "user")]
248    user: Option<String>,
249
250    /// Filter by group name
251    #[arg(short = 'g', long = "group")]
252    group: Option<String>,
253
254    /// Filter by type: f (file), d (directory), l (symlink)
255    #[arg(short = 't', long = "type")]
256    type_filter: Option<char>,
257
258    /// Exclude files/directories matching REGEX
259    #[arg(short = 'x', long = "exclude")]
260    exclude: Option<String>,
261
262    /// Stop on first command failure
263    #[arg(short = 'e', long = "stop-on-error")]
264    stop_on_error: bool,
265
266    /// Prompt for confirmation before each command
267    #[arg(short = 'c', long = "confirm")]
268    confirm: bool,
269
270    /// Collect all matches and run command once with %0 = all matched paths
271    #[arg(short = 'l', long = "list-all")]
272    list_all: bool,
273
274    /// Run N commands in parallel (default: 1)
275    #[arg(short = 'j', long = "jobs", default_value = "1")]
276    jobs: i32,
277
278    /// Shell to use for execution (default: /bin/bash)
279    #[arg(short = 'w', long = "shell", default_value = "/bin/bash")]
280    shell: String,
281
282    /// Positional args: path "command" regex [extra_args..]
283    args: Vec<String>,
284
285    /// Treat pattern as a glob (*, ?) instead of regex
286    #[arg(short = 'b', long = "glob")]
287    glob: bool,
288
289    /// Use regex-match (entire path must match) instead of regex-search (substring match)
290    #[arg(short = 'z', long = "regex-match")]
291    regex_match: bool,
292
293    /// Treat exclude pattern as a glob (*, ?) instead of regex
294    #[arg(short = 'i', long = "glob-exclude")]
295    glob_exclude: bool,
296
297    /// Expression filter: compose glob(), regex(), regex_match() with and/or/not
298    #[arg(short = 'f', long = "expr")]
299    expr: Option<String>,
300}
301
302/// Aggregated runtime options parsed from CLI arguments.
303///
304/// This is a flattened, validated view of [`Cli`] that the rest of the program
305/// operates on. Filters that weren't specified on the command line have their
306/// `active` flag set to false or their string fields left empty.
307struct Options {
308    /// Print commands without executing them.
309    dry_run: bool,
310    /// Print each command to stdout before executing it.
311    verbose: bool,
312    /// Include hidden (dot-prefixed) files and directories.
313    hidden: bool,
314    /// Maximum recursion depth (-1 = unlimited, 0 = current dir only).
315    max_depth: i32,
316    /// Optional size filter (e.g., `+10M`).
317    size_filter: SizeFilter,
318    /// Optional modification-time filter (e.g., `+7`).
319    mtime_filter: TimeFilter,
320    /// Octal permission string for filtering (e.g., `"755"`). Empty = disabled.
321    perm_filter: String,
322    /// Owner username to filter by. Empty = disabled.
323    user_filter: String,
324    /// Group name to filter by. Empty = disabled.
325    group_filter: String,
326    /// Type filter character: `'f'` (file), `'d'` (dir), `'l'` (symlink), `'\0'` = disabled.
327    type_filter: char,
328    /// Regex pattern string for excluding entries. Empty = disabled.
329    exclude_pattern: String,
330    /// Halt processing on the first command that returns non-zero.
331    stop_on_error: bool,
332    /// Prompt the user for y/N confirmation before each command.
333    confirm: bool,
334    /// Number of parallel child processes (1 = sequential, >1 = fork pool).
335    jobs: i32,
336    /// Shell path to use for command execution (default: /bin/bash).
337    shell: String,
338    shell_name: String,
339    /// If true (via `-l`/`--list-all`), collect all matched file paths and run
340    /// one command with `%0` expanded to the combined space-delimited list.
341    collect_all: bool,
342    /// If true (via `-b`/`--glob`), treat patterns as globs instead of regex.
343    glob: bool,
344    /// If true (via `-z`/`--regex-match`), the regex must match the **entire**
345    /// path (anchored with `^(?:...)$`) rather than just a substring.
346    regex_match: bool,
347    /// If true (via `-i`/`--glob-exclude`), treat the exclude pattern as a glob
348    /// instead of regex.
349    glob_exclude: bool,
350    /// Expression filter string from `--expr`.
351    expr_str: String,
352}
353
354/// Convert a glob pattern to an equivalent regex string.
355///
356/// Escapes regex-special characters and translates glob wildcards:
357/// - `*` becomes `.*`
358/// - `?` becomes `.`
359/// - `[...]` character classes are passed through (with `!` or `^` mapped to `^`)
360/// - All other regex metacharacters are escaped with a backslash.
361/// - The result is anchored with `^...$`.
362///
363/// # Examples
364///
365/// - `"*.cpp"` → `"^.*\.cpp$"`
366/// - `"test?"` → `"^test.$"`
367/// - `"*cmake"` → `"^.*cmake$"`
368/// - `"[!a-z]*"` → `"^[^a-z].*$"`
369///
370/// Used by `--glob` to convert the search pattern and by `--glob-exclude` (`-i`)
371/// to convert the exclude pattern.
372fn glob_to_regex(glob: &str) -> String {
373    let mut result = String::from('^');
374    let chars: Vec<char> = glob.chars().collect();
375    let mut i = 0;
376    let mut in_class = false;
377
378    while i < chars.len() {
379        let c = chars[i];
380
381        if in_class {
382            if c == ']' {
383                in_class = false;
384                result.push(']');
385            } else if c == '\\' {
386                result.push_str("\\\\");
387            } else {
388                result.push(c);
389            }
390            i += 1;
391            continue;
392        }
393
394        match c {
395            '*' => result.push_str(".*"),
396            '?' => result.push('.'),
397            '[' => {
398                in_class = true;
399                result.push('[');
400                if i + 1 < chars.len() && (chars[i + 1] == '!' || chars[i + 1] == '^') {
401                    result.push('^');
402                    i += 1;
403                }
404            }
405            '.' | '\\' | '+' | '^' | '$' | '|' | '(' | ')' | '{' | '}' => {
406                result.push('\\');
407                result.push(c);
408            }
409            _ => result.push(c),
410        }
411        i += 1;
412    }
413
414    if in_class {
415        result.push('\\');
416    }
417
418    result.push('$');
419    result
420}
421
422// --- Expression filter (--expr) ------------------------------------------------
423
424/// Node types for the expression filter AST.
425enum ExprType {
426    Glob,
427    RegexSearch,
428    RegexMatch,
429    And,
430    Or,
431    Not,
432}
433
434/// AST node for expression-based file matching.
435struct ExprNode {
436    node_type: ExprType,
437    /// Pre-compiled regex (leaf nodes only).
438    compiled: Option<Regex>,
439    /// Left child (AND/OR) or sole child (NOT).
440    left: Option<Box<ExprNode>>,
441    /// Right child (AND/OR only).
442    right: Option<Box<ExprNode>>,
443}
444
445impl ExprNode {
446    /// Evaluate this expression node against a file path.
447    fn evaluate(&self, path: &str) -> bool {
448        match self.node_type {
449            ExprType::Glob | ExprType::RegexSearch => {
450                self.compiled.as_ref().map_or(false, |re| re.is_match(path))
451            }
452            ExprType::RegexMatch => self
453                .compiled
454                .as_ref()
455                .map_or(false, |re| re.is_match(path)),
456            ExprType::And => {
457                self.left.as_ref().map_or(false, |l| l.evaluate(path))
458                    && self.right.as_ref().map_or(false, |r| r.evaluate(path))
459            }
460            ExprType::Or => {
461                self.left.as_ref().map_or(false, |l| l.evaluate(path))
462                    || self.right.as_ref().map_or(false, |r| r.evaluate(path))
463            }
464            ExprType::Not => !self.left.as_ref().map_or(false, |l| l.evaluate(path)),
465        }
466    }
467}
468
469/// Token produced by the expression tokenizer.
470#[derive(Debug, Clone, PartialEq)]
471enum ExprTokenType {
472    Ident,
473    StringLit,
474    LParen,
475    RParen,
476    End,
477}
478
479#[derive(Debug, Clone)]
480struct ExprToken {
481    token_type: ExprTokenType,
482    value: String,
483}
484
485/// Tokenizer for expression filter strings.
486struct ExprTokenizer {
487    chars: Vec<char>,
488    pos: usize,
489}
490
491impl ExprTokenizer {
492    fn new(src: &str) -> Self {
493        Self {
494            chars: src.chars().collect(),
495            pos: 0,
496        }
497    }
498
499    fn skip_ws(&mut self) {
500        while self.pos < self.chars.len() && self.chars[self.pos].is_whitespace() {
501            self.pos += 1;
502        }
503    }
504
505    fn next_token(&mut self) -> ExprToken {
506        self.skip_ws();
507        if self.pos >= self.chars.len() {
508            return ExprToken {
509                token_type: ExprTokenType::End,
510                value: String::new(),
511            };
512        }
513        let c = self.chars[self.pos];
514        if c == '(' {
515            self.pos += 1;
516            return ExprToken {
517                token_type: ExprTokenType::LParen,
518                value: "(".to_string(),
519            };
520        }
521        if c == ')' {
522            self.pos += 1;
523            return ExprToken {
524                token_type: ExprTokenType::RParen,
525                value: ")".to_string(),
526            };
527        }
528        if c == '"' || c == '\'' {
529            let q = c;
530            self.pos += 1;
531            let mut val = String::new();
532            while self.pos < self.chars.len() && self.chars[self.pos] != q {
533                if self.chars[self.pos] == '\\' && self.pos + 1 < self.chars.len() {
534                    self.pos += 1;
535                    val.push(self.chars[self.pos]);
536                } else {
537                    val.push(self.chars[self.pos]);
538                }
539                self.pos += 1;
540            }
541            if self.pos < self.chars.len() {
542                self.pos += 1;
543            }
544            return ExprToken {
545                token_type: ExprTokenType::StringLit,
546                value: val,
547            };
548        }
549        if c.is_alphabetic() || c == '_' {
550            let mut val = String::new();
551            while self.pos < self.chars.len()
552                && (self.chars[self.pos].is_alphanumeric() || self.chars[self.pos] == '_')
553            {
554                val.push(self.chars[self.pos]);
555                self.pos += 1;
556            }
557            return ExprToken {
558                token_type: ExprTokenType::Ident,
559                value: val,
560            };
561        }
562        error!(
563            "unexpected character '{}' in expression at position {}",
564            c, self.pos
565        );
566        process::exit(1);
567    }
568}
569
570/// Recursive-descent parser for expression filter strings.
571///
572/// Grammar:
573///   expr     := or_expr
574///   or_expr  := and_expr ("or" and_expr)*
575///   and_expr := not_expr ("and" not_expr)*
576///   not_expr := "not" not_expr | primary
577///   primary  := function "(" STRING ")" | "(" expr ")"
578///   function := "glob" | "regex" | "regex_search" | "regex_match"
579struct ExprParser {
580    tok: ExprTokenizer,
581    cur: ExprToken,
582}
583
584impl ExprParser {
585    fn new(src: &str) -> Self {
586        let mut tok = ExprTokenizer::new(src);
587        let cur = tok.next_token();
588        Self { tok, cur }
589    }
590
591    fn advance(&mut self) {
592        self.cur = self.tok.next_token();
593    }
594
595    fn expect(&mut self, t: ExprTokenType, desc: &str) {
596        if self.cur.token_type != t {
597            let got = if self.cur.value.is_empty() {
598                "end"
599            } else {
600                &self.cur.value
601            };
602            error!("expected {} in expression, got '{}'", desc, got);
603            process::exit(1);
604        }
605        self.advance();
606    }
607
608    fn parse_primary(&mut self) -> Box<ExprNode> {
609        if self.cur.token_type == ExprTokenType::LParen {
610            self.advance();
611            let node = self.parse_or();
612            self.expect(ExprTokenType::RParen, "')'");
613            return node;
614        }
615        if self.cur.token_type != ExprTokenType::Ident {
616            let got = if self.cur.value.is_empty() {
617                "end"
618            } else {
619                &self.cur.value
620            };
621            error!("unexpected token '{}' in expression", got);
622            process::exit(1);
623        }
624        let name = self.cur.value.clone();
625        let ft = match name.as_str() {
626            "glob" => ExprType::Glob,
627            "regex" | "regex_search" => ExprType::RegexSearch,
628            "regex_match" => ExprType::RegexMatch,
629            _ => {
630                error!("unknown function '{}' in expression", name);
631                process::exit(1);
632            }
633        };
634        self.advance();
635        self.expect(ExprTokenType::LParen, "'(' after function name");
636        if self.cur.token_type != ExprTokenType::StringLit {
637            error!("expected quoted string as function argument");
638            process::exit(1);
639        }
640        let pattern = self.cur.value.clone();
641        self.advance();
642        self.expect(ExprTokenType::RParen, "')'");
643
644        let regex_pattern = match ft {
645            ExprType::Glob => glob_to_regex(&pattern),
646            ExprType::RegexMatch => format!("^(?:{})$", pattern),
647            _ => pattern.clone(),
648        };
649        let compiled = Regex::new(&regex_pattern).unwrap_or_else(|e| {
650            error!("invalid regex '{}' in expression: {}", pattern, e);
651            process::exit(1);
652        });
653
654        Box::new(ExprNode {
655            node_type: ft,
656            compiled: Some(compiled),
657            left: None,
658            right: None,
659        })
660    }
661
662    fn parse_not(&mut self) -> Box<ExprNode> {
663        if self.cur.token_type == ExprTokenType::Ident && self.cur.value == "not" {
664            self.advance();
665            let child = self.parse_not();
666            return Box::new(ExprNode {
667                node_type: ExprType::Not,
668                compiled: None,
669                left: Some(child),
670                right: None,
671            });
672        }
673        self.parse_primary()
674    }
675
676    fn parse_and(&mut self) -> Box<ExprNode> {
677        let mut left = self.parse_not();
678        while self.cur.token_type == ExprTokenType::Ident && self.cur.value == "and" {
679            self.advance();
680            let right = self.parse_not();
681            left = Box::new(ExprNode {
682                node_type: ExprType::And,
683                compiled: None,
684                left: Some(left),
685                right: Some(right),
686            });
687        }
688        left
689    }
690
691    fn parse_or(&mut self) -> Box<ExprNode> {
692        let mut left = self.parse_and();
693        while self.cur.token_type == ExprTokenType::Ident && self.cur.value == "or" {
694            self.advance();
695            let right = self.parse_and();
696            left = Box::new(ExprNode {
697                node_type: ExprType::Or,
698                compiled: None,
699                left: Some(left),
700                right: Some(right),
701            });
702        }
703        left
704    }
705
706    fn parse(mut self) -> Box<ExprNode> {
707        let root = self.parse_or();
708        if self.cur.token_type != ExprTokenType::End {
709            error!("unexpected content after expression");
710            process::exit(1);
711        }
712        root
713    }
714}
715
716/// Check whether a path matches the active search pattern or expression.
717fn entry_matches_path(
718    fullpath: &str,
719    regex: &Regex,
720    expr_root: Option<&ExprNode>,
721) -> bool {
722    if let Some(root) = expr_root {
723        return root.evaluate(fullpath);
724    }
725    regex.is_match(fullpath)
726}
727
728/// Parse a size filter string into a [`SizeFilter`].
729///
730/// # Format
731///
732/// The input string has the form `[+|-]<number>[K|M|G]`:
733/// - Prefix `+` → greater-than comparison
734/// - Prefix `-` → less-than comparison
735/// - No prefix  → exact equality
736/// - Suffix `K`/`k` → multiply by 1024
737/// - Suffix `M`/`m` → multiply by 1024²
738/// - Suffix `G`/`g` → multiply by 1024³
739///
740/// # Examples
741///
742/// - `"+10M"` → greater than 10 MiB
743/// - `"-1K"` → less than 1 KiB
744/// - `"4096"` → exactly 4096 bytes
745///
746/// # Panics
747///
748/// Prints an error and calls `process::exit(1)` if the numeric part cannot be parsed.
749fn parse_size_filter(s: &str) -> SizeFilter {
750    // Determine comparison operator from the first character:
751    // '+' means "greater than", '-' means "less than", anything else means "exact".
752    let mut val = s;
753    let op = if val.starts_with('+') {
754        val = &val[1..];
755        CmpOp::Gt
756    } else if val.starts_with('-') {
757        val = &val[1..];
758        CmpOp::Lt
759    } else {
760        CmpOp::Eq
761    };
762
763    // Check for a size suffix (K/M/G) and compute the byte multiplier.
764    // The suffix is case-insensitive; if present, it's stripped from the numeric part.
765    let (num_str, multiplier) = if val.ends_with('K') || val.ends_with('k') {
766        (&val[..val.len() - 1], 1024u64)
767    } else if val.ends_with('M') || val.ends_with('m') {
768        (&val[..val.len() - 1], 1024u64 * 1024)
769    } else if val.ends_with('G') || val.ends_with('g') {
770        (&val[..val.len() - 1], 1024u64 * 1024 * 1024)
771    } else {
772        (val, 1u64)
773    };
774
775    // Parse the numeric portion and multiply by the suffix multiplier.
776    // Exit with an error if the number cannot be parsed.
777    let bytes = num_str.parse::<u64>().unwrap_or_else(|_| {
778        error!("invalid size value '{s}'");
779        process::exit(1);
780    }) * multiplier;
781
782    SizeFilter {
783        active: true,
784        op,
785        bytes,
786    }
787}
788
789/// Parse a time filter string into a [`TimeFilter`].
790///
791/// # Format
792///
793/// The input string has the form `[+|-]<number>`:
794/// - Prefix `+` → older than N days (age > N)
795/// - Prefix `-` → newer than N days (age < N)
796/// - No prefix  → exactly N days old (age == N)
797///
798/// # Examples
799///
800/// - `"+7"` → older than 7 days
801/// - `"-1"` → modified within the last day
802/// - `"3"` → exactly 3 days old
803///
804/// # Panics
805///
806/// Prints an error and calls `process::exit(1)` if the numeric part cannot be parsed.
807fn parse_time_filter(s: &str) -> TimeFilter {
808    // Determine comparison operator from the first character,
809    // same logic as parse_size_filter.
810    let mut val = s;
811    let op = if val.starts_with('+') {
812        val = &val[1..];
813        CmpOp::Gt
814    } else if val.starts_with('-') {
815        val = &val[1..];
816        CmpOp::Lt
817    } else {
818        CmpOp::Eq
819    };
820
821    let days = val.parse::<i64>().unwrap_or_else(|_| {
822        error!("invalid time value '{s}'");
823        process::exit(1);
824    });
825
826    TimeFilter {
827        active: true,
828        op,
829        days,
830    }
831}
832
833/// Test a directory entry's metadata against all active filters.
834///
835/// This function checks the entry's type, size, modification time, permissions,
836/// owner, and group against the corresponding filter in [`Options`]. If any
837/// active filter fails, returns `false`; otherwise returns `true`.
838///
839/// # Arguments
840///
841/// - `_path` — the filesystem path (currently unused but available for future use)
842/// - `metadata` — the `std::fs::Metadata` for the entry (may be symlink or resolved)
843/// - `opts` — the runtime options containing all filter configurations
844///
845/// # Filter evaluation order
846///
847/// 1. Type filter (`-t`)
848/// 2. Size filter (`-s`) — only applies to regular files
849/// 3. Modification time filter (`-m`)
850/// 4. Permission filter (`-p`) — compares `mode & 0o7777` against octal target
851/// 5. User filter (`-u`) — looks up UID → username via `getpwuid()`
852/// 6. Group filter (`-g`) — looks up GID → group name via `getgrgid()`
853fn matches_filters(_path: &Path, metadata: &fs::Metadata, opts: &Options) -> bool {
854    let ft = metadata.file_type();
855
856    // --- Type filter ---
857    // Check if the entry matches the requested type (file/dir/symlink).
858    // If no type filter is set (type_filter == '\0'), this check is skipped.
859    if opts.type_filter != '\0' {
860        match opts.type_filter {
861            'f' => {
862                if !ft.is_file() {
863                    return false;
864                }
865            }
866            'd' => {
867                if !ft.is_dir() {
868                    return false;
869                }
870            }
871            'l' => {
872                if !ft.is_symlink() {
873                    return false;
874                }
875            }
876            _ => {}
877        }
878    }
879
880    // Size filter (only meaningful for regular files)
881    if opts.size_filter.active {
882        if !ft.is_file() {
883            return false;
884        }
885        let sz = metadata.len();
886        match opts.size_filter.op {
887            CmpOp::Gt => {
888                if sz <= opts.size_filter.bytes {
889                    return false;
890                }
891            }
892            CmpOp::Lt => {
893                if sz >= opts.size_filter.bytes {
894                    return false;
895                }
896            }
897            CmpOp::Eq => {
898                if sz != opts.size_filter.bytes {
899                    return false;
900                }
901            }
902        }
903    }
904
905    // Modification time filter
906    if opts.mtime_filter.active {
907        if let Ok(mtime) = metadata.modified() {
908            if let Ok(elapsed) = SystemTime::now().duration_since(mtime) {
909                let age_days = (elapsed.as_secs() / 86400) as i64;
910                match opts.mtime_filter.op {
911                    CmpOp::Gt => {
912                        if age_days <= opts.mtime_filter.days {
913                            return false;
914                        }
915                    }
916                    CmpOp::Lt => {
917                        if age_days >= opts.mtime_filter.days {
918                            return false;
919                        }
920                    }
921                    CmpOp::Eq => {
922                        if age_days != opts.mtime_filter.days {
923                            return false;
924                        }
925                    }
926                }
927            } else {
928                return false;
929            }
930        } else {
931            return false;
932        }
933    }
934
935    // Permission filter (octal comparison)
936    if !opts.perm_filter.is_empty() {
937        let mode = metadata.mode() & 0o7777;
938        let target = u32::from_str_radix(&opts.perm_filter, 8).unwrap_or_else(|_| {
939            error!("invalid permission filter '{}'", opts.perm_filter);
940            process::exit(1);
941        });
942        if mode != target {
943            return false;
944        }
945    }
946
947    // User filter
948    if !opts.user_filter.is_empty() {
949        let uid = metadata.uid();
950        let name = uid_to_name(uid);
951        if name.as_deref() != Some(opts.user_filter.as_str()) {
952            return false;
953        }
954    }
955
956    // Group filter
957    if !opts.group_filter.is_empty() {
958        let gid = metadata.gid();
959        let name = gid_to_name(gid);
960        if name.as_deref() != Some(opts.group_filter.as_str()) {
961            return false;
962        }
963    }
964
965    true
966}
967
968fn uid_to_name(uid: u32) -> Option<String> {
969    // Safety: getpwuid is a standard POSIX call
970    unsafe {
971        let pw = libc::getpwuid(uid);
972        if pw.is_null() {
973            return None;
974        }
975        let cstr = std::ffi::CStr::from_ptr((*pw).pw_name);
976        Some(cstr.to_string_lossy().into_owned())
977    }
978}
979
980fn gid_to_name(gid: u32) -> Option<String> {
981    // Safety: getgrgid is a standard POSIX call
982    unsafe {
983        let gr = libc::getgrgid(gid);
984        if gr.is_null() {
985            return None;
986        }
987        let cstr = std::ffi::CStr::from_ptr((*gr).gr_name);
988        Some(cstr.to_string_lossy().into_owned())
989    }
990}
991
992fn replace_all(orig: &str, from: &str, to: &str) -> String {
993    orig.replace(from, to)
994}
995
996/// Execute a shell command via fork/exec with proper signal handling (mirrors the C++ System()).
997fn system_cmd(command: &str, opts: &Options) -> i32 {
998    if command.is_empty() {
999        return if system_cmd(":", opts) == 0 { 1 } else { 0 };
1000    }
1001
1002    let c_command = CString::new(command).unwrap_or_else(|_| {
1003        error!("command contains null byte");
1004        process::exit(1);
1005    });
1006    let c_sh = CString::new(opts.shell.as_str()).unwrap();
1007    let c_sh_arg = CString::new(opts.shell_name.as_str()).unwrap();
1008    let c_c = CString::new("-c").unwrap();
1009
1010    // Block SIGCHLD
1011    let mut block_set = nix::sys::signal::SigSet::empty();
1012    block_set.add(nix::sys::signal::Signal::SIGCHLD);
1013    let mut old_mask = nix::sys::signal::SigSet::empty();
1014    let has_old_mask = nix::sys::signal::sigprocmask(
1015        nix::sys::signal::SigmaskHow::SIG_BLOCK,
1016        Some(&block_set),
1017        Some(&mut old_mask),
1018    )
1019    .is_ok();
1020
1021    // Use our sigint_handler instead of SIG_IGN so Ctrl+C is recorded
1022    let sa_int_handler = nix::sys::signal::SigAction::new(
1023        nix::sys::signal::SigHandler::Handler(sigint_handler),
1024        nix::sys::signal::SaFlags::empty(),
1025        nix::sys::signal::SigSet::empty(),
1026    );
1027    let sa_ignore = nix::sys::signal::SigAction::new(
1028        nix::sys::signal::SigHandler::SigIgn,
1029        nix::sys::signal::SaFlags::empty(),
1030        nix::sys::signal::SigSet::empty(),
1031    );
1032    let old_sigint =
1033        unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sa_int_handler) }
1034            .ok();
1035    let old_sigquit =
1036        unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGQUIT, &sa_ignore) }.ok();
1037
1038    let status = match unsafe { nix::unistd::fork() } {
1039        Ok(nix::unistd::ForkResult::Child) => {
1040            // Restore default signal handlers in child
1041            let sa_default = nix::sys::signal::SigAction::new(
1042                nix::sys::signal::SigHandler::SigDfl,
1043                nix::sys::signal::SaFlags::empty(),
1044                nix::sys::signal::SigSet::empty(),
1045            );
1046            if let Some(ref old) = old_sigint {
1047                if old.handler() != nix::sys::signal::SigHandler::SigIgn {
1048                    unsafe {
1049                        let _ = nix::sys::signal::sigaction(
1050                            nix::sys::signal::Signal::SIGINT,
1051                            &sa_default,
1052                        );
1053                    }
1054                }
1055            }
1056            if let Some(ref old) = old_sigquit {
1057                if old.handler() != nix::sys::signal::SigHandler::SigIgn {
1058                    unsafe {
1059                        let _ = nix::sys::signal::sigaction(
1060                            nix::sys::signal::Signal::SIGQUIT,
1061                            &sa_default,
1062                        );
1063                    }
1064                }
1065            }
1066            // Restore signal mask in child
1067            if has_old_mask {
1068                let _ = nix::sys::signal::sigprocmask(
1069                    nix::sys::signal::SigmaskHow::SIG_SETMASK,
1070                    Some(&old_mask),
1071                    None,
1072                );
1073            }
1074
1075            nix::unistd::execv(
1076                &c_sh,
1077                &[c_sh_arg.as_c_str(), c_c.as_c_str(), c_command.as_c_str()],
1078            )
1079            .ok();
1080            unsafe { libc::_exit(127) };
1081        }
1082        Ok(nix::unistd::ForkResult::Parent { child }) => loop {
1083            match nix::sys::wait::waitpid(child, None) {
1084                Ok(ws) => match ws {
1085                    nix::sys::wait::WaitStatus::Exited(_, code) => {
1086                        // Shell catches SIGINT and exits with 130 (128+2)
1087                        if code == 130 {
1088                            INTERRUPTED.store(true, Ordering::SeqCst);
1089                        }
1090                        break code;
1091                    }
1092                    nix::sys::wait::WaitStatus::Signaled(_, sig, _) => {
1093                        // If the child was killed directly by SIGINT
1094                        if sig == nix::sys::signal::Signal::SIGINT {
1095                            INTERRUPTED.store(true, Ordering::SeqCst);
1096                        }
1097                        break -1;
1098                    }
1099                    _ => break -1,
1100                },
1101                Err(nix::errno::Errno::EINTR) => continue,
1102                Err(_) => break -1,
1103            }
1104        },
1105        Err(_) => -1,
1106    };
1107
1108    // Restore signal mask and handlers
1109    if has_old_mask {
1110        let _ = nix::sys::signal::sigprocmask(
1111            nix::sys::signal::SigmaskHow::SIG_SETMASK,
1112            Some(&old_mask),
1113            None,
1114        );
1115    }
1116    if let Some(ref old) = old_sigint {
1117        unsafe {
1118            let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, old);
1119        }
1120    }
1121    if let Some(ref old) = old_sigquit {
1122        unsafe {
1123            let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGQUIT, old);
1124        }
1125    }
1126
1127    status
1128}
1129
1130/// Substitute placeholders in a command template and execute the result.
1131///
1132/// # Modes
1133///
1134/// - **Default mode** (one invocation per match): `%0` = basename, `%1` = full
1135///   path, `%2+` = extra args, `%b` = stem, `%e` = extension.
1136/// - **`--list-all` mode** (`-l`): all matching file paths are collected first
1137///   by [`fill_list()`], joined into a single space-delimited string, and passed
1138///   as `file_string`. In this mode `%0` is replaced with the entire list of
1139///   matched paths rather than an individual filename.
1140///
1141/// Supports confirm mode, dry-run, parallel forking, and stop-on-error.
1142///
1143/// # Arguments
1144///
1145/// - `cmd` — the command template string containing `%` placeholders
1146/// - `text` — slice of strings: `text[0]` is the matched file path (unused in
1147///   list-all mode), `text[1+]` are extra CLI arguments
1148/// - `file_string` — when `--list-all` is active, the space-joined list of all
1149///   matched paths; `None` in default per-file mode
1150/// - `opts` — runtime options
1151/// - `stats` — mutable execution statistics
1152///
1153/// # Returns
1154///
1155/// `true` to continue processing, `false` to stop (stop-on-error triggered).
1156fn proc_cmd(
1157    cmd: &str,
1158    text: &[String],
1159    file_string: Option<&str>,
1160    opts: &Options,
1161    stats: &mut Stats,
1162) -> bool {
1163    let mut r = cmd.to_string();
1164    if file_string.is_none() && !text.is_empty() {
1165        let fpath = Path::new(&text[0]);
1166        let fname = fpath
1167            .file_name()
1168            .map(|s| s.to_string_lossy().to_string())
1169            .unwrap_or_default();
1170        let stem = fpath
1171            .file_stem()
1172            .map(|s| s.to_string_lossy().to_string())
1173            .unwrap_or_default();
1174        let ext = fpath
1175            .extension()
1176            .map(|s| format!(".{}", s.to_string_lossy()))
1177            .unwrap_or_default();
1178        r = replace_all(&r, "%0", &fname);
1179        r = replace_all(&r, "%b", &stem);
1180        r = replace_all(&r, "%e", &ext);
1181    }
1182    if let Some(fs) = file_string {
1183        // --list-all mode: %0 expands to the full list of matched paths
1184        r = replace_all(&r, "%0", fs);
1185        for (i, val) in text.iter().enumerate() {
1186            let placeholder = format!("%{}", i + 1);
1187            if val.contains(' ') {
1188                r = replace_all(&r, &placeholder, &format!("\"{}\"", val));
1189            } else {
1190                r = replace_all(&r, &placeholder, val);
1191            }
1192        }
1193    } else {
1194        for (i, val) in text.iter().enumerate() {
1195            let placeholder = format!("%{}", i + 1);
1196            if i == 0 && val.contains(' ') {
1197                r = replace_all(&r, &placeholder, &format!("\"{}\"", val));
1198            } else {
1199                r = replace_all(&r, &placeholder, val);
1200            }
1201        }
1202    }
1203
1204    if opts.confirm {
1205        let co = use_color(1);
1206        print!(
1207            "{}Execute:{} {} {}[y/N]{} ",
1208            if co { "\x1b[1;33m" } else { "" },
1209            if co { "\x1b[0m" } else { "" },
1210            r,
1211            if co { "\x1b[1m" } else { "" },
1212            if co { "\x1b[0m" } else { "" }
1213        );
1214        io::stdout().flush().ok();
1215        let mut answer = String::new();
1216        io::stdin().lock().read_line(&mut answer).ok();
1217        let answer = answer.trim();
1218        if answer != "y" && answer != "Y" {
1219            return true;
1220        }
1221    }
1222
1223    if opts.verbose || opts.dry_run {
1224        if use_color(1) {
1225            println!("\x1b[36m{}\x1b[0m", r);
1226        } else {
1227            println!("{}", r);
1228        }
1229    }
1230
1231    if opts.dry_run {
1232        stats.commands_run += 1;
1233        return true;
1234    }
1235
1236    if opts.jobs > 1 {
1237        wait_for_slot(opts, stats);
1238        if STOP_REQUESTED.load(Ordering::SeqCst) {
1239            return false;
1240        }
1241        match unsafe { nix::unistd::fork() } {
1242            Ok(nix::unistd::ForkResult::Child) => {
1243                let c_sh = CString::new(opts.shell.as_str()).unwrap();
1244                let c_sh_arg = CString::new(opts.shell_name.as_str()).unwrap();
1245                let c_c = CString::new("-c").unwrap();
1246                let c_cmd = CString::new(r.as_str()).unwrap();
1247                nix::unistd::execv(
1248                    &c_sh,
1249                    &[c_sh_arg.as_c_str(), c_c.as_c_str(), c_cmd.as_c_str()],
1250                )
1251                .ok();
1252                unsafe { libc::_exit(127) };
1253            }
1254            Ok(nix::unistd::ForkResult::Parent { child }) => {
1255                CHILD_PIDS.lock().unwrap().push(child);
1256            }
1257            Err(e) => {
1258                eprintln!("fork: {}", e);
1259                stats.commands_failed += 1;
1260                return !opts.stop_on_error;
1261            }
1262        }
1263        return true;
1264    }
1265
1266    let ret = system_cmd(&r, opts);
1267    stats.commands_run += 1;
1268    if INTERRUPTED.load(Ordering::SeqCst) {
1269        return false;
1270    }
1271    if ret != 0 {
1272        stats.commands_failed += 1;
1273        if opts.stop_on_error {
1274            error!("command failed (exit {}), stopping.", ret);
1275            STOP_REQUESTED.store(true, Ordering::SeqCst);
1276            return false;
1277        }
1278    }
1279    true
1280}
1281
1282fn wait_for_slot(opts: &Options, stats: &mut Stats) {
1283    loop {
1284        let len = CHILD_PIDS.lock().unwrap().len() as i32;
1285        if len < opts.jobs {
1286            break;
1287        }
1288        match nix::sys::wait::wait() {
1289            Ok(ws) => {
1290                let pid = match ws {
1291                    nix::sys::wait::WaitStatus::Exited(pid, _) => pid,
1292                    nix::sys::wait::WaitStatus::Signaled(pid, _, _) => pid,
1293                    _ => continue,
1294                };
1295                CHILD_PIDS.lock().unwrap().retain(|&p| p != pid);
1296                stats.commands_run += 1;
1297                let success = matches!(ws, nix::sys::wait::WaitStatus::Exited(_, 0));
1298                if !success {
1299                    stats.commands_failed += 1;
1300                    if opts.stop_on_error {
1301                        STOP_REQUESTED.store(true, Ordering::SeqCst);
1302                    }
1303                }
1304            }
1305            Err(_) => break,
1306        }
1307    }
1308}
1309
1310fn wait_all(stats: &mut Stats) {
1311    loop {
1312        if CHILD_PIDS.lock().unwrap().is_empty() {
1313            break;
1314        }
1315        match nix::sys::wait::wait() {
1316            Ok(ws) => {
1317                let pid = match ws {
1318                    nix::sys::wait::WaitStatus::Exited(pid, _) => pid,
1319                    nix::sys::wait::WaitStatus::Signaled(pid, _, _) => pid,
1320                    _ => continue,
1321                };
1322                CHILD_PIDS.lock().unwrap().retain(|&p| p != pid);
1323                stats.commands_run += 1;
1324                let success = matches!(ws, nix::sys::wait::WaitStatus::Exited(_, 0));
1325                if !success {
1326                    stats.commands_failed += 1;
1327                }
1328            }
1329            Err(_) => break,
1330        }
1331    }
1332}
1333
1334/// Recursively walk a directory and collect all matching file paths into `files`.
1335///
1336/// This is the list-all counterpart to [`add_directory()`]. Instead of executing
1337/// a command for each match, it appends the full path of every matched entry to
1338/// the `files` vector. The caller then joins these paths and invokes the command
1339/// template once via [`proc_cmd()`] with `%0` expanded to the entire list.
1340///
1341/// # Arguments
1342///
1343/// - `path` — the directory to scan
1344/// - `regex` — compiled regex matched against each entry's full path
1345/// - `exclude_regex` — optional compiled exclude pattern
1346/// - `expr_root` — optional parsed expression tree (from `--expr`)
1347/// - `opts` — runtime options (depth, hidden, filters, etc.)
1348/// - `stats` — mutable execution statistics (files_matched is incremented)
1349/// - `files` — accumulator for matched file paths
1350/// - `depth` — current recursion depth (0 at the root call)
1351fn fill_list(
1352    path: &Path,
1353    regex: &Regex,
1354    exclude_regex: Option<&Regex>,
1355    expr_root: Option<&ExprNode>,
1356    opts: &Options,
1357    stats: &mut Stats,
1358    files: &mut Vec<String>,
1359    depth: i32,
1360) {
1361    if opts.max_depth >= 0 && depth > opts.max_depth {
1362        return;
1363    }
1364    if STOP_REQUESTED.load(Ordering::SeqCst) || INTERRUPTED.load(Ordering::SeqCst) {
1365        return;
1366    }
1367
1368    let entries = match fs::read_dir(path) {
1369        Ok(e) => e,
1370        Err(e) => {
1371            error!("could not open directory: {}: {}", path.display(), e);
1372            process::exit(1);
1373        }
1374    };
1375
1376    for entry in entries {
1377        if STOP_REQUESTED.load(Ordering::SeqCst) || INTERRUPTED.load(Ordering::SeqCst) {
1378            return;
1379        }
1380        let entry = match entry {
1381            Ok(e) => e,
1382            Err(_) => continue,
1383        };
1384
1385        let filename = entry.file_name().to_string_lossy().to_string();
1386
1387        // Skip hidden files unless --all
1388        if !opts.hidden && filename.starts_with('.') {
1389            continue;
1390        }
1391
1392        // Exclude pattern check
1393        if let Some(excl) = exclude_regex {
1394            if excl.is_match(&filename) {
1395                continue;
1396            }
1397        }
1398
1399        let symlink_meta = match entry.path().symlink_metadata() {
1400            Ok(m) => m,
1401            Err(_) => continue,
1402        };
1403
1404        let is_symlink = symlink_meta.file_type().is_symlink();
1405        let meta = if is_symlink && opts.type_filter != 'l' {
1406            match entry.path().metadata() {
1407                Ok(m) => m,
1408                Err(_) => continue,
1409            }
1410        } else {
1411            symlink_meta.clone()
1412        };
1413
1414        let is_dir = meta.is_dir();
1415        let is_file = meta.is_file();
1416
1417        if is_dir && !is_symlink {
1418            if opts.type_filter == 'd' {
1419                let fullpath = entry.path().to_string_lossy().to_string();
1420                if entry_matches_path(&fullpath, regex, expr_root) && matches_filters(&entry.path(), &meta, opts) {
1421                    stats.files_matched += 1;
1422                    files.push(fullpath);
1423                }
1424            }
1425            fill_list(
1426                &entry.path(),
1427                regex,
1428                exclude_regex,
1429                expr_root,
1430                opts,
1431                stats,
1432                files,
1433                depth + 1,
1434            );
1435        } else if is_symlink && opts.type_filter == 'l' {
1436            let fullpath = entry.path().to_string_lossy().to_string();
1437            if entry_matches_path(&fullpath, regex, expr_root) && matches_filters(&entry.path(), &symlink_meta, opts) {
1438                stats.files_matched += 1;
1439                files.push(fullpath);
1440            }
1441        } else if is_file || (is_symlink && opts.type_filter == '\0') {
1442            let fullpath = entry.path().to_string_lossy().to_string();
1443            if entry_matches_path(&fullpath, regex, expr_root) && matches_filters(&entry.path(), &meta, opts) {
1444                stats.files_matched += 1;
1445                files.push(fullpath);
1446            }
1447        }
1448    }
1449}
1450
1451fn add_directory(
1452    path: &Path,
1453    cmd: &str,
1454    regex: &Regex,
1455    exclude_regex: Option<&Regex>,
1456    expr_root: Option<&ExprNode>,
1457    args: &mut Vec<String>,
1458    opts: &Options,
1459    stats: &mut Stats,
1460    depth: i32,
1461) {
1462    // Respect max depth: if we've exceeded the limit, stop recursing.
1463    if opts.max_depth >= 0 && depth > opts.max_depth {
1464        return;
1465    }
1466    // If a command previously failed and --stop-on-error was set, bail out.
1467    if STOP_REQUESTED.load(Ordering::SeqCst) || INTERRUPTED.load(Ordering::SeqCst) {
1468        return;
1469    }
1470
1471    // Open the directory for iteration. Exit on failure (matches C++ behavior).
1472    let entries = match fs::read_dir(path) {
1473        Ok(e) => e,
1474        Err(e) => {
1475            error!("could not open directory: {}: {}", path.display(), e);
1476            process::exit(1);
1477        }
1478    };
1479
1480    for entry in entries {
1481        if STOP_REQUESTED.load(Ordering::SeqCst) || INTERRUPTED.load(Ordering::SeqCst) {
1482            return;
1483        }
1484        let entry = match entry {
1485            Ok(e) => e,
1486            Err(_) => continue,
1487        };
1488
1489        let filename = entry.file_name().to_string_lossy().to_string();
1490
1491        // Skip hidden files unless --all
1492        if !opts.hidden && filename.starts_with('.') {
1493            continue;
1494        }
1495
1496        // Exclude pattern check
1497        if let Some(excl) = exclude_regex {
1498            if excl.is_match(&filename) {
1499                continue;
1500            }
1501        }
1502
1503        // Use symlink_metadata first so we can detect symlinks without
1504        // following them. This is critical for the --type l filter.
1505        let symlink_meta = match entry.path().symlink_metadata() {
1506            Ok(m) => m,
1507            Err(_) => continue,
1508        };
1509
1510        let is_symlink = symlink_meta.file_type().is_symlink();
1511        // For symlinks with type filter != 'l', resolve the symlink to get
1512        // the target's real metadata (is it a file or directory?).
1513        // For type filter == 'l', keep the symlink metadata as-is.
1514        let meta = if is_symlink && opts.type_filter != 'l' {
1515            match entry.path().metadata() {
1516                Ok(m) => m,
1517                Err(_) => continue,
1518            }
1519        } else {
1520            symlink_meta.clone()
1521        };
1522
1523        let is_dir = meta.is_dir();
1524        let is_file = meta.is_file();
1525
1526        // Entry classification and processing:
1527        // - Real directories (not symlinks) are recursed into. If --type d is
1528        //   active, they are also tested against the regex for matching.
1529        // - Symlinks with --type l are tested against the regex.
1530        // - Regular files (and unfiltered symlinks) are tested against the regex.
1531        if is_dir && !is_symlink {
1532            // If type filter is 'd', also match directories against regex
1533            if opts.type_filter == 'd' {
1534                let fullpath = entry.path().to_string_lossy().to_string();
1535                if entry_matches_path(&fullpath, regex, expr_root) && matches_filters(&entry.path(), &meta, opts) {
1536                    stats.files_matched += 1;
1537                    args[0] = fullpath;
1538                    if !proc_cmd(cmd, args, None, opts, stats) {
1539                        return;
1540                    }
1541                }
1542            }
1543            add_directory(
1544                &entry.path(),
1545                cmd,
1546                regex,
1547                exclude_regex,
1548                expr_root,
1549                args,
1550                opts,
1551                stats,
1552                depth + 1,
1553            );
1554        } else if is_symlink && opts.type_filter == 'l' {
1555            let fullpath = entry.path().to_string_lossy().to_string();
1556            if entry_matches_path(&fullpath, regex, expr_root) && matches_filters(&entry.path(), &symlink_meta, opts) {
1557                stats.files_matched += 1;
1558                args[0] = fullpath;
1559                if !proc_cmd(cmd, args, None, opts, stats) {
1560                    return;
1561                }
1562            }
1563        } else if is_file || (is_symlink && opts.type_filter == '\0') {
1564            let fullpath = entry.path().to_string_lossy().to_string();
1565            if entry_matches_path(&fullpath, regex, expr_root) && matches_filters(&entry.path(), &meta, opts) {
1566                stats.files_matched += 1;
1567                args[0] = fullpath;
1568                if !proc_cmd(cmd, args, None, opts, stats) {
1569                    return;
1570                }
1571            }
1572        }
1573    }
1574}
1575
1576/// SIGINT handler — sets the INTERRUPTED flag for clean exit.
1577extern "C" fn sigint_handler(_sig: libc::c_int) {
1578    INTERRUPTED.store(true, Ordering::SeqCst);
1579}
1580
1581/// Program entry point.
1582///
1583/// Parses command-line arguments via `clap`, validates positional arguments
1584/// and placeholder consistency, constructs [`Options`], compiles regex patterns
1585/// (anchoring with `^(?:...)$` when `--regex-match` is active), runs the
1586/// recursive directory traversal via [`add_directory()`] (or [`fill_list()`]
1587/// in `--list-all` mode), waits for parallel children if applicable, and prints
1588/// a summary.
1589///
1590/// # Exit codes
1591///
1592/// - `0` — all commands succeeded (or dry-run completed)
1593/// - `1` — at least one command failed, or invalid arguments were provided
1594/// Print usage/help with colored output (matches C++ shell-cmd format).
1595fn print_help() {
1596    let co = use_color(1);
1597    let (b, bw, bc, by, g, r) = if co {
1598        (
1599            "\x1b[1m",
1600            "\x1b[1;37m",
1601            "\x1b[1;36m",
1602            "\x1b[1;33m",
1603            "\x1b[32m",
1604            "\x1b[0m",
1605        )
1606    } else {
1607        ("", "", "", "", "", "")
1608    };
1609    println!(
1610        "\
1611{b}usage:{r} {bw}shell-cmd-rs{r} [options] path \"command %1 [%2 %3..]\" regex [extra_args..]
1612
1613{bw}Recursively find files matching regex and run command for each.{r}
1614{bc}(Rust implementation of shell-cmd){r}
1615
1616{by}placeholders:{r}
1617  {g}%0{r}          filename only (no path, per-match mode)
1618  {g}%1{r}          full path to matched file
1619  {g}%2+{r}         extra arguments from command line
1620  {g}%b{r}          basename without extension
1621  {g}%e{r}          file extension (including dot)
1622
1623  (with -l/--list-all) %0 expands to all matched paths joined by spaces
1624
1625{by}options:{r}
1626  {g}-n, --dry-run{r}       dry-run, print commands without executing
1627  {g}-v, --verbose{r}       verbose, print each command before running
1628  {g}-a, --all{r}           include hidden files/directories
1629  {g}-l, --list-all{r}      collect all matches and invoke command once with %0=all-matches
1630  {g}-d, --depth N{r}       max recursion depth (0 = current dir only)
1631  {g}-s, --size SIZE{r}     filter by size: +10M (>10MB), -1K (<1KB),
1632                      4096 (exactly 4096 bytes). Suffixes: K, M, G
1633  {g}-m, --mtime DAYS{r}    filter by modification time: +7 (older than 7 days),
1634                      -1 (modified within last day), 3 (exactly 3 days)
1635  {g}-p, --perm MODE{r}     filter by permissions (octal), e.g. 755
1636  {g}-u, --user USER{r}     filter by owner username
1637  {g}-g, --group GROUP{r}   filter by group name
1638  {g}-t, --type TYPE{r}     filter by type: f (file), d (directory), l (symlink)
1639  {g}-x, --exclude REGEX{r} exclude files/directories matching REGEX
1640  {g}-i, --glob-exclude{r}  treat exclude pattern as a glob instead of regex
1641  {g}-e, --stop-on-error{r} stop on first command failure
1642  {g}-c, --confirm{r}       prompt for confirmation before each command
1643  {g}-j, --jobs N{r}        run N commands in parallel (default: 1)
1644  {g}-w, --shell SHELL{r}   shell to use for execution (default: /bin/bash)
1645  {g}-b, --glob{r}          treat pattern as a glob (*, ?) instead of regex
1646  {g}-z, --regex-match{r}   use regex-match (full path must match) instead of search
1647  {g}-f, --expr EXPR{r}     expression filter: compose glob(), regex(), regex_match()
1648                      with and/or/not and parentheses
1649  {g}-h, --help{r}          show this help
1650
1651{by}regex modes:{r}
1652  By default, the regex is tested as a {g}substring search{r} (matches anywhere
1653  in the path). With {g}-z{r}/{g}--regex-match{r}, the entire path must match the
1654  pattern (equivalent to anchoring with ^...$).
1655
1656{by}glob mode:{r}
1657  With {g}-b{r}/{g}--glob{r}, write familiar wildcard patterns instead of regex:
1658  {g}*{r} matches anything, {g}?{r} matches a single character, and regex-special
1659  characters ({g}.{r}, {g}+{r}, {g}({r}, etc.) are auto-escaped.
1660
1661{by}expr mode:{r}
1662  With {g}-f{r}/{g}--expr{r}, compose filter functions with boolean operators:
1663  {g}glob(\"pattern\"){r}, {g}regex(\"pattern\"){r}, {g}regex_match(\"pattern\"){r}
1664  combined with {g}and{r}, {g}or{r}, {g}not{r}, and parentheses.
1665  When --expr is used, the regex positional argument is not required.",
1666        b = b,
1667        bw = bw,
1668        bc = bc,
1669        by = by,
1670        g = g,
1671        r = r
1672    );
1673}
1674
1675fn main() {
1676    // Install SIGINT handler for clean Ctrl+C exit
1677    unsafe {
1678        let sa = nix::sys::signal::SigAction::new(
1679            nix::sys::signal::SigHandler::Handler(sigint_handler),
1680            nix::sys::signal::SaFlags::empty(),
1681            nix::sys::signal::SigSet::empty(),
1682        );
1683        let _ = nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGINT, &sa);
1684    }
1685
1686    // If no arguments provided, print colored help and exit (matches C++ behavior)
1687    if std::env::args().len() == 1 {
1688        print_help();
1689        process::exit(0);
1690    }
1691
1692    let cli = Cli::parse();
1693
1694    // Validate type filter
1695    if let Some(t) = cli.type_filter {
1696        if t != 'f' && t != 'd' && t != 'l' {
1697            error!(
1698                "invalid type '{}'. Use f (file), d (directory), or l (symlink).",
1699                t
1700            );
1701            process::exit(1);
1702        }
1703    }
1704
1705    let opts = Options {
1706        dry_run: cli.dry_run,
1707        verbose: cli.verbose,
1708        hidden: cli.all,
1709        max_depth: cli.depth.unwrap_or(-1),
1710        size_filter: cli
1711            .size
1712            .as_ref()
1713            .map(|s| parse_size_filter(s))
1714            .unwrap_or(SizeFilter {
1715                active: false,
1716                op: CmpOp::Eq,
1717                bytes: 0,
1718            }),
1719        mtime_filter: cli
1720            .mtime
1721            .as_ref()
1722            .map(|s| parse_time_filter(s))
1723            .unwrap_or(TimeFilter {
1724                active: false,
1725                op: CmpOp::Eq,
1726                days: 0,
1727            }),
1728        perm_filter: cli.perm.unwrap_or_default(),
1729        user_filter: cli.user.unwrap_or_default(),
1730        group_filter: cli.group.unwrap_or_default(),
1731        type_filter: cli.type_filter.unwrap_or('\0'),
1732        exclude_pattern: cli.exclude.clone().unwrap_or_default(),
1733        stop_on_error: cli.stop_on_error,
1734        confirm: cli.confirm,
1735        jobs: cli.jobs.max(1),
1736        shell_name: cli
1737            .shell
1738            .rsplit('/')
1739            .next()
1740            .unwrap_or(&cli.shell)
1741            .to_string(),
1742        shell: cli.shell,
1743        collect_all: cli.list_all,
1744        glob: cli.glob,
1745        regex_match: cli.regex_match,
1746        glob_exclude: cli.glob_exclude,
1747        expr_str: cli.expr.clone().unwrap_or_default(),
1748    };
1749
1750    // Parse expression filter if --expr was provided
1751    let expr_root: Option<Box<ExprNode>> = if !opts.expr_str.is_empty() {
1752        Some(ExprParser::new(&opts.expr_str).parse())
1753    } else {
1754        None
1755    };
1756
1757    let positional = &cli.args;
1758    let min_args = if expr_root.is_some() { 2 } else { 3 };
1759    if positional.len() < min_args {
1760        if expr_root.is_some() {
1761            error!("at least two positional arguments required when --expr is used.");
1762        } else {
1763            error!("at least three positional arguments required.");
1764        }
1765        print_help();
1766        process::exit(1);
1767    }
1768
1769    let path = PathBuf::from(&positional[0]);
1770    let input = &positional[1];
1771
1772    // When --expr is used, the regex positional is optional.
1773    // Use a dummy "match-nothing" regex as placeholder when no pattern is given.
1774    let regex_str = if positional.len() >= 3 {
1775        if opts.glob {
1776            glob_to_regex(&positional[2])
1777        } else {
1778            positional[2].clone()
1779        }
1780    } else {
1781        // --expr mode without regex arg: use match-everything pattern
1782        ".*".to_string()
1783    };
1784
1785    // In regex-match mode, anchor the pattern so it must match the entire path.
1786    // Wrapping in ^(?:...)$ converts a substring search into a full-string match,
1787    // equivalent to C++ std::regex_match vs std::regex_search.
1788    let regex_str = if opts.regex_match {
1789        format!("^(?:{})$", regex_str)
1790    } else {
1791        regex_str
1792    };
1793
1794    let regex = Regex::new(&regex_str).unwrap_or_else(|e| {
1795        error!("invalid regex '{}': {}", regex_str, e);
1796        process::exit(1);
1797    });
1798
1799    let exclude_pattern = if opts.glob_exclude && !opts.exclude_pattern.is_empty() {
1800        glob_to_regex(&opts.exclude_pattern)
1801    } else {
1802        opts.exclude_pattern.clone()
1803    };
1804
1805    // Anchor the exclude pattern in regex-match mode as well.
1806    let exclude_pattern = if opts.regex_match && !exclude_pattern.is_empty() {
1807        format!("^(?:{})$", exclude_pattern)
1808    } else {
1809        exclude_pattern
1810    };
1811
1812    let exclude_regex = if !exclude_pattern.is_empty() {
1813        Some(Regex::new(&exclude_pattern).unwrap_or_else(|e| {
1814            error!("invalid exclude regex '{}': {}", exclude_pattern, e);
1815            process::exit(1);
1816        }))
1817    } else {
1818        None
1819    };
1820
1821    // In --list-all mode, the placeholder index starts at 1 (no per-file %1)
1822    // and args does not include a "filename" placeholder entry.
1823    let extra_start = if positional.len() >= 3 { 3 } else { 2 };
1824    let mut index: usize = if opts.collect_all { 1 } else { 2 };
1825    let mut args: Vec<String> = if opts.collect_all {
1826        Vec::new()
1827    } else {
1828        vec!["filename".to_string()]
1829    };
1830    for i in extra_start..positional.len() {
1831        let placeholder = format!("%{}", index);
1832        if !input.contains(&placeholder) {
1833            error!(
1834                "command has no placeholder %{} for extra argument \"{}\"",
1835                index, positional[i]
1836            );
1837            process::exit(1);
1838        }
1839        args.push(positional[i].clone());
1840        index += 1;
1841    }
1842
1843    let mut stats = Stats {
1844        files_matched: 0,
1845        commands_run: 0,
1846        commands_failed: 0,
1847    };
1848
1849    if opts.collect_all {
1850        // --list-all mode: collect all matching paths, then run the command once
1851        // with %0 expanded to the space-joined list of all matches.
1852        let mut files: Vec<String> = Vec::new();
1853        fill_list(
1854            &path,
1855            &regex,
1856            exclude_regex.as_ref(),
1857            expr_root.as_deref(),
1858            &opts,
1859            &mut stats,
1860            &mut files,
1861            0,
1862        );
1863        let all_files = files.join(" ");
1864        if proc_cmd(input, &args, Some(&all_files), &opts, &mut stats) {
1865            if opts.verbose {
1866                println!("Success command file list: {} .", all_files);
1867            }
1868            process::exit(0);
1869        } else {
1870            println!("List all command failed.");
1871            process::exit(1);
1872        }
1873    }
1874
1875    add_directory(
1876        &path,
1877        input,
1878        &regex,
1879        exclude_regex.as_ref(),
1880        expr_root.as_deref(),
1881        &mut args,
1882        &opts,
1883        &mut stats,
1884        0,
1885    );
1886
1887    if opts.jobs > 1 {
1888        wait_all(&mut stats);
1889    }
1890
1891    if INTERRUPTED.load(Ordering::SeqCst) {
1892        // Kill outstanding child processes
1893        {
1894            let pids = CHILD_PIDS.lock().unwrap();
1895            for &pid in pids.iter() {
1896                let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
1897            }
1898        }
1899        // Wait for them to finish
1900        loop {
1901            if CHILD_PIDS.lock().unwrap().is_empty() {
1902                break;
1903            }
1904            match nix::sys::wait::wait() {
1905                Ok(ws) => {
1906                    let pid = match ws {
1907                        nix::sys::wait::WaitStatus::Exited(pid, _) => pid,
1908                        nix::sys::wait::WaitStatus::Signaled(pid, _, _) => pid,
1909                        _ => continue,
1910                    };
1911                    CHILD_PIDS.lock().unwrap().retain(|&p| p != pid);
1912                }
1913                Err(_) => break,
1914            }
1915        }
1916        eprintln!("\nInterrupted.");
1917        let co = use_color(2);
1918        if stats.commands_run > 0 || stats.commands_failed > 0 {
1919            if co {
1920                eprintln!(
1921                    "\x1b[1mSummary:\x1b[0m \x1b[1;32m{}\x1b[0m matched, \x1b[1;33m{}\x1b[0m run, {}{}\x1b[0m failed",
1922                    stats.files_matched,
1923                    stats.commands_run,
1924                    if stats.commands_failed > 0 { "\x1b[1;31m" } else { "\x1b[1;32m" },
1925                    stats.commands_failed
1926                );
1927            } else {
1928                eprintln!(
1929                    "Summary: {} matched, {} run, {} failed",
1930                    stats.files_matched, stats.commands_run, stats.commands_failed
1931                );
1932            }
1933        }
1934        process::exit(130);
1935    }
1936
1937    if opts.verbose || opts.dry_run || stats.commands_failed > 0 {
1938        let co = use_color(2);
1939        if co {
1940            eprintln!(
1941                "\n\x1b[1mSummary:\x1b[0m \x1b[1;32m{}\x1b[0m matched, \x1b[1;33m{}\x1b[0m run, {}{}\x1b[0m failed",
1942                stats.files_matched,
1943                stats.commands_run,
1944                if stats.commands_failed > 0 { "\x1b[1;31m" } else { "\x1b[1;32m" },
1945                stats.commands_failed
1946            );
1947        } else {
1948            eprintln!(
1949                "\nSummary: {} matched, {} run, {} failed",
1950                stats.files_matched, stats.commands_run, stats.commands_failed
1951            );
1952        }
1953    }
1954
1955    if stats.commands_failed > 0 {
1956        process::exit(1);
1957    }
1958}