1use 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
67fn 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
76macro_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
89static STOP_REQUESTED: AtomicBool = AtomicBool::new(false);
94
95static INTERRUPTED: AtomicBool = AtomicBool::new(false);
98
99static CHILD_PIDS: std::sync::LazyLock<Mutex<Vec<nix::unistd::Pid>>> =
104 std::sync::LazyLock::new(|| Mutex::new(Vec::new()));
105
106#[derive(Clone, Copy)]
115enum CmpOp {
116 Eq,
118 Lt,
120 Gt,
122}
123
124#[derive(Clone)]
136struct SizeFilter {
137 active: bool,
139 op: CmpOp,
141 bytes: u64,
143}
144
145#[derive(Clone)]
158struct TimeFilter {
159 active: bool,
161 op: CmpOp,
163 days: i64,
165}
166
167struct Stats {
174 files_matched: i32,
176 commands_run: i32,
178 commands_failed: i32,
180}
181
182#[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 #[arg(short = 'n', long = "dry-run")]
220 dry_run: bool,
221
222 #[arg(short = 'v', long = "verbose")]
224 verbose: bool,
225
226 #[arg(short = 'a', long = "all")]
228 all: bool,
229
230 #[arg(short = 'd', long = "depth")]
232 depth: Option<i32>,
233
234 #[arg(short = 's', long = "size")]
236 size: Option<String>,
237
238 #[arg(short = 'm', long = "mtime")]
240 mtime: Option<String>,
241
242 #[arg(short = 'p', long = "perm")]
244 perm: Option<String>,
245
246 #[arg(short = 'u', long = "user")]
248 user: Option<String>,
249
250 #[arg(short = 'g', long = "group")]
252 group: Option<String>,
253
254 #[arg(short = 't', long = "type")]
256 type_filter: Option<char>,
257
258 #[arg(short = 'x', long = "exclude")]
260 exclude: Option<String>,
261
262 #[arg(short = 'e', long = "stop-on-error")]
264 stop_on_error: bool,
265
266 #[arg(short = 'c', long = "confirm")]
268 confirm: bool,
269
270 #[arg(short = 'l', long = "list-all")]
272 list_all: bool,
273
274 #[arg(short = 'j', long = "jobs", default_value = "1")]
276 jobs: i32,
277
278 #[arg(short = 'w', long = "shell", default_value = "/bin/bash")]
280 shell: String,
281
282 args: Vec<String>,
284
285 #[arg(short = 'b', long = "glob")]
287 glob: bool,
288
289 #[arg(short = 'z', long = "regex-match")]
291 regex_match: bool,
292
293 #[arg(short = 'i', long = "glob-exclude")]
295 glob_exclude: bool,
296
297 #[arg(short = 'f', long = "expr")]
299 expr: Option<String>,
300}
301
302struct Options {
308 dry_run: bool,
310 verbose: bool,
312 hidden: bool,
314 max_depth: i32,
316 size_filter: SizeFilter,
318 mtime_filter: TimeFilter,
320 perm_filter: String,
322 user_filter: String,
324 group_filter: String,
326 type_filter: char,
328 exclude_pattern: String,
330 stop_on_error: bool,
332 confirm: bool,
334 jobs: i32,
336 shell: String,
338 shell_name: String,
339 collect_all: bool,
342 glob: bool,
344 regex_match: bool,
347 glob_exclude: bool,
350 expr_str: String,
352}
353
354fn 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
422enum ExprType {
426 Glob,
427 RegexSearch,
428 RegexMatch,
429 And,
430 Or,
431 Not,
432}
433
434struct ExprNode {
436 node_type: ExprType,
437 compiled: Option<Regex>,
439 left: Option<Box<ExprNode>>,
441 right: Option<Box<ExprNode>>,
443}
444
445impl ExprNode {
446 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#[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
485struct 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
570struct 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(®ex_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
716fn 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
728fn parse_size_filter(s: &str) -> SizeFilter {
750 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 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 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
789fn parse_time_filter(s: &str) -> TimeFilter {
808 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
833fn matches_filters(_path: &Path, metadata: &fs::Metadata, opts: &Options) -> bool {
854 let ft = metadata.file_type();
855
856 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 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 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 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 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 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 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 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
996fn 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 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 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 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 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 if code == 130 {
1088 INTERRUPTED.store(true, Ordering::SeqCst);
1089 }
1090 break code;
1091 }
1092 nix::sys::wait::WaitStatus::Signaled(_, sig, _) => {
1093 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 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
1130fn 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 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
1334fn 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 if !opts.hidden && filename.starts_with('.') {
1389 continue;
1390 }
1391
1392 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 if opts.max_depth >= 0 && depth > opts.max_depth {
1464 return;
1465 }
1466 if STOP_REQUESTED.load(Ordering::SeqCst) || INTERRUPTED.load(Ordering::SeqCst) {
1468 return;
1469 }
1470
1471 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 if !opts.hidden && filename.starts_with('.') {
1493 continue;
1494 }
1495
1496 if let Some(excl) = exclude_regex {
1498 if excl.is_match(&filename) {
1499 continue;
1500 }
1501 }
1502
1503 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 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 if is_dir && !is_symlink {
1532 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
1576extern "C" fn sigint_handler(_sig: libc::c_int) {
1578 INTERRUPTED.store(true, Ordering::SeqCst);
1579}
1580
1581fn 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 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 std::env::args().len() == 1 {
1688 print_help();
1689 process::exit(0);
1690 }
1691
1692 let cli = Cli::parse();
1693
1694 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 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 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 ".*".to_string()
1783 };
1784
1785 let regex_str = if opts.regex_match {
1789 format!("^(?:{})$", regex_str)
1790 } else {
1791 regex_str
1792 };
1793
1794 let regex = Regex::new(®ex_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 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 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 let mut files: Vec<String> = Vec::new();
1853 fill_list(
1854 &path,
1855 ®ex,
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 ®ex,
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 {
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 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}