Skip to main content

tracexec_core/cli/
args.rs

1use std::{
2  borrow::Cow,
3  fmt,
4  num::ParseFloatError,
5  path::{
6    Path,
7    PathBuf,
8  },
9};
10
11use clap::{
12  Args,
13  ValueEnum,
14};
15use color_eyre::eyre::bail;
16use enumflags2::BitFlags;
17use snafu::{
18  ResultExt,
19  Snafu,
20};
21
22use super::{
23  config::{
24    DebuggerConfig,
25    ExitHandling,
26    LogModeConfig,
27    ModifierConfig,
28    PtraceConfig,
29    TuiModeConfig,
30  },
31  keys::TuiKeyBindingsConfig,
32  options::{
33    ActivePane,
34    AppLayout,
35    SeccompBpf,
36  },
37};
38use crate::{
39  breakpoint::BreakPoint,
40  cli::config::{
41    ColorLevel,
42    EnvDisplay,
43    FileDescriptorDisplay,
44  },
45  event::TracerEventDetailsKind,
46  timestamp::TimestampFormat,
47};
48
49#[derive(Args, Debug, Default, Clone)]
50pub struct PtraceArgs {
51  #[clap(long, help = "Controls whether to enable seccomp-bpf optimization, which greatly improves performance", default_value_t = SeccompBpf::Auto)]
52  pub seccomp_bpf: SeccompBpf,
53  #[clap(
54    long,
55    help = "Polling interval, in microseconds. -1(default) disables polling."
56  )]
57  pub polling_interval: Option<i64>,
58}
59
60#[derive(Args, Debug, Default, Clone)]
61pub struct ModifierArgs {
62  #[clap(long, help = "Only show successful calls", default_value_t = false)]
63  pub successful_only: bool,
64  #[clap(
65    long,
66    help = "[Experimental] Try to reproduce file descriptors in commandline. This might result in an unexecutable cmdline if pipes, sockets, etc. are involved.",
67    default_value_t = false
68  )]
69  pub fd_in_cmdline: bool,
70  #[clap(
71    long,
72    help = "[Experimental] Try to reproduce stdio in commandline. This might result in an unexecutable cmdline if pipes, sockets, etc. are involved.",
73    default_value_t = false
74  )]
75  pub stdio_in_cmdline: bool,
76  #[clap(long, help = "Resolve /proc/self/exe symlink", default_value_t = false)]
77  pub resolve_proc_self_exe: bool,
78  #[clap(
79    long,
80    help = "Do not resolve /proc/self/exe symlink",
81    default_value_t = false,
82    conflicts_with = "resolve_proc_self_exe"
83  )]
84  pub no_resolve_proc_self_exe: bool,
85  #[clap(long, help = "Hide CLOEXEC fds", default_value_t = false)]
86  pub hide_cloexec_fds: bool,
87  #[clap(
88    long,
89    help = "Do not hide CLOEXEC fds",
90    default_value_t = false,
91    conflicts_with = "hide_cloexec_fds"
92  )]
93  pub no_hide_cloexec_fds: bool,
94  #[clap(long, help = "Show timestamp information", default_value_t = false)]
95  pub timestamp: bool,
96  #[clap(
97    long,
98    help = "Do not show timestamp information",
99    default_value_t = false,
100    conflicts_with = "timestamp"
101  )]
102  pub no_timestamp: bool,
103  #[clap(
104    long,
105    help = "Set the format of inline timestamp. See https://docs.rs/chrono/latest/chrono/format/strftime/index.html for available options."
106  )]
107  pub inline_timestamp_format: Option<TimestampFormat>,
108  #[clap(long, help = "Collect cgroup information", default_value_t = false)]
109  pub collect_cgroup: bool,
110  #[clap(
111    long,
112    help = "Do not collect cgroup information",
113    default_value_t = false,
114    conflicts_with = "collect_cgroup"
115  )]
116  pub no_collect_cgroup: bool,
117}
118
119impl PtraceArgs {
120  pub fn merge_config(&mut self, config: PtraceConfig) {
121    // seccomp-bpf
122    if let Some(setting) = config.seccomp_bpf
123      && self.seccomp_bpf == SeccompBpf::Auto
124    {
125      self.seccomp_bpf = setting;
126    }
127  }
128}
129
130impl ModifierArgs {
131  pub fn processed(mut self) -> Self {
132    self.stdio_in_cmdline = self.fd_in_cmdline || self.stdio_in_cmdline;
133    self.resolve_proc_self_exe = match (self.resolve_proc_self_exe, self.no_resolve_proc_self_exe) {
134      (true, false) => true,
135      (false, true) => false,
136      _ => true, // default
137    };
138    self.hide_cloexec_fds = match (self.hide_cloexec_fds, self.no_hide_cloexec_fds) {
139      (true, false) => true,
140      (false, true) => false,
141      _ => true, // default
142    };
143    self.timestamp = match (self.timestamp, self.no_timestamp) {
144      (true, false) => true,
145      (false, true) => false,
146      _ => false, // default
147    };
148    self.collect_cgroup = match (self.collect_cgroup, self.no_collect_cgroup) {
149      (true, false) => true,
150      (false, true) => false,
151      _ => false, // default
152    };
153    self
154      .inline_timestamp_format
155      .get_or_insert_with(TimestampFormat::default);
156    self
157  }
158
159  pub fn merge_config(&mut self, config: ModifierConfig) {
160    // false by default flags
161    self.successful_only = self.successful_only || config.successful_only.unwrap_or_default();
162    self.fd_in_cmdline |= config.fd_in_cmdline.unwrap_or_default();
163    self.stdio_in_cmdline |= config.stdio_in_cmdline.unwrap_or_default();
164    // flags that have negation counterparts
165    if (!self.no_resolve_proc_self_exe) && (!self.resolve_proc_self_exe) {
166      self.resolve_proc_self_exe = config.resolve_proc_self_exe.unwrap_or_default();
167    }
168    if (!self.no_hide_cloexec_fds) && (!self.hide_cloexec_fds) {
169      self.hide_cloexec_fds = config.hide_cloexec_fds.unwrap_or_default();
170    }
171    if let Some(c) = config.timestamp {
172      if (!self.timestamp) && (!self.no_timestamp) {
173        self.timestamp = c.enable;
174      }
175      if self.inline_timestamp_format.is_none() {
176        self.inline_timestamp_format = c.inline_format;
177      }
178    }
179    if (!self.no_collect_cgroup) && (!self.collect_cgroup) {
180      self.collect_cgroup = config.collect_cgroup.unwrap_or_default();
181    }
182  }
183}
184
185#[derive(Args, Debug)]
186pub struct TracerEventArgs {
187  // TODO:
188  //   This isn't really compatible with logging mode
189  #[clap(
190    long,
191    help = "Set the default filter to show all events. This option can be used in combination with --filter-exclude to exclude some unwanted events.",
192    conflicts_with = "filter"
193  )]
194  pub show_all_events: bool,
195  #[clap(
196    long,
197    help = "Set the default filter for events.",
198    value_parser = tracer_event_filter_parser,
199    default_value = "warning,error,exec,tracee-exit"
200  )]
201  pub filter: BitFlags<TracerEventDetailsKind>,
202  #[clap(
203    long,
204    help = "Aside from the default filter, also include the events specified here.",
205    required = false,
206    value_parser = tracer_event_filter_parser,
207    default_value_t = BitFlags::empty()
208  )]
209  pub filter_include: BitFlags<TracerEventDetailsKind>,
210  #[clap(
211    long,
212    help = "Exclude the events specified here from the default filter.",
213    value_parser = tracer_event_filter_parser,
214    default_value_t = BitFlags::empty()
215  )]
216  pub filter_exclude: BitFlags<TracerEventDetailsKind>,
217}
218
219fn tracer_event_filter_parser(filter: &str) -> Result<BitFlags<TracerEventDetailsKind>, String> {
220  let mut result = BitFlags::empty();
221  if filter == "<empty>" {
222    return Ok(result);
223  }
224  for f in filter.split(',') {
225    let kind = TracerEventDetailsKind::from_str(f, false)?;
226    if result.contains(kind) {
227      return Err(format!(
228        "Event kind '{kind}' is already included in the filter"
229      ));
230    }
231    result |= kind;
232  }
233  Ok(result)
234}
235
236impl TracerEventArgs {
237  pub fn all() -> Self {
238    Self {
239      show_all_events: true,
240      filter: Default::default(),
241      filter_include: Default::default(),
242      filter_exclude: Default::default(),
243    }
244  }
245
246  pub fn filter(&self) -> color_eyre::Result<BitFlags<TracerEventDetailsKind>> {
247    let default_filter = if self.show_all_events {
248      BitFlags::all()
249    } else {
250      self.filter
251    };
252    if self.filter_include.intersects(self.filter_exclude) {
253      bail!("filter_include and filter_exclude cannot contain common events");
254    }
255    let mut filter = default_filter | self.filter_include;
256    filter.remove(self.filter_exclude);
257    Ok(filter)
258  }
259}
260
261#[derive(Args, Debug, Default, Clone)]
262pub struct LogModeArgs {
263  #[clap(long, help = "More colors", conflicts_with = "less_colors")]
264  pub more_colors: bool,
265  #[clap(long, help = "Less colors", conflicts_with = "more_colors")]
266  pub less_colors: bool,
267  // BEGIN ugly: https://github.com/clap-rs/clap/issues/815
268  #[clap(
269    long,
270    help = "Print commandline that (hopefully) reproduces what was executed. Note: file descriptors are not handled for now.",
271    conflicts_with_all = ["show_env", "diff_env", "show_argv", "no_show_cmdline"]
272  )]
273  pub show_cmdline: bool,
274  #[clap(
275    long,
276    help = "Don't print commandline that (hopefully) reproduces what was executed."
277  )]
278  pub no_show_cmdline: bool,
279  #[clap(
280    long,
281    help = "Try to show script interpreter indicated by shebang",
282    conflicts_with = "no_show_interpreter"
283  )]
284  pub show_interpreter: bool,
285  #[clap(
286    long,
287    help = "Do not show script interpreter indicated by shebang",
288    conflicts_with = "show_interpreter"
289  )]
290  pub no_show_interpreter: bool,
291  #[clap(
292    long,
293    help = "Set the terminal foreground process group to tracee. This option is useful when tracexec is used interactively. [default]",
294    conflicts_with = "no_foreground"
295  )]
296  pub foreground: bool,
297  #[clap(
298    long,
299    help = "Do not set the terminal foreground process group to tracee",
300    conflicts_with = "foreground"
301  )]
302  pub no_foreground: bool,
303  #[clap(
304    long,
305    help = "Diff file descriptors with the original std{in/out/err}",
306    conflicts_with = "no_diff_fd"
307  )]
308  pub diff_fd: bool,
309  #[clap(
310    long,
311    help = "Do not diff file descriptors",
312    conflicts_with = "diff_fd"
313  )]
314  pub no_diff_fd: bool,
315  #[clap(long, help = "Show file descriptors", conflicts_with = "diff_fd")]
316  pub show_fd: bool,
317  #[clap(
318    long,
319    help = "Do not show file descriptors",
320    conflicts_with = "show_fd"
321  )]
322  pub no_show_fd: bool,
323  #[clap(
324    long,
325    help = "Diff environment variables with the original environment",
326    conflicts_with = "no_diff_env",
327    conflicts_with = "show_env",
328    conflicts_with = "no_show_env"
329  )]
330  pub diff_env: bool,
331  #[clap(
332    long,
333    help = "Do not diff environment variables",
334    conflicts_with = "diff_env"
335  )]
336  pub no_diff_env: bool,
337  #[clap(
338    long,
339    help = "Show environment variables",
340    conflicts_with = "no_show_env",
341    conflicts_with = "diff_env"
342  )]
343  pub show_env: bool,
344  #[clap(
345    long,
346    help = "Do not show environment variables",
347    conflicts_with = "show_env"
348  )]
349  pub no_show_env: bool,
350  #[clap(long, help = "Show comm", conflicts_with = "no_show_comm")]
351  pub show_comm: bool,
352  #[clap(long, help = "Do not show comm", conflicts_with = "show_comm")]
353  pub no_show_comm: bool,
354  #[clap(long, help = "Show argv", conflicts_with = "no_show_argv")]
355  pub show_argv: bool,
356  #[clap(long, help = "Do not show argv", conflicts_with = "show_argv")]
357  pub no_show_argv: bool,
358  #[clap(long, help = "Show filename", conflicts_with = "no_show_filename")]
359  pub show_filename: bool,
360  #[clap(long, help = "Do not show filename", conflicts_with = "show_filename")]
361  pub no_show_filename: bool,
362  #[clap(long, help = "Show cwd", conflicts_with = "no_show_cwd")]
363  pub show_cwd: bool,
364  #[clap(long, help = "Do not show cwd", conflicts_with = "show_cwd")]
365  pub no_show_cwd: bool,
366  #[clap(long, help = "Decode errno values", conflicts_with = "no_decode_errno")]
367  pub decode_errno: bool,
368  #[clap(
369    long,
370    help = "Do not decode errno values",
371    conflicts_with = "decode_errno"
372  )]
373  pub no_decode_errno: bool,
374  // END ugly
375}
376
377impl LogModeArgs {
378  pub fn foreground(&self) -> bool {
379    match (self.foreground, self.no_foreground) {
380      (false, true) => false,
381      (true, false) => true,
382      _ => true,
383    }
384  }
385
386  pub fn merge_config(&mut self, config: LogModeConfig) {
387    /// fallback to config value if both --x and --no-x are not set
388    macro_rules! fallback {
389      ($x:ident) => {
390        ::paste::paste! {
391          if (!self.$x) && (!self.[<no_ $x>]) {
392            if let Some(x) = config.$x {
393              if x {
394                self.$x = true;
395              } else {
396                self.[<no_ $x>] = true;
397              }
398            }
399          }
400        }
401      };
402    }
403    fallback!(show_interpreter);
404    fallback!(foreground);
405    fallback!(show_comm);
406    fallback!(show_filename);
407    fallback!(show_cwd);
408    fallback!(decode_errno);
409    match config.fd_display {
410      Some(FileDescriptorDisplay::Show) => {
411        if (!self.no_show_fd) && (!self.diff_fd) {
412          self.show_fd = true;
413        }
414      }
415      Some(FileDescriptorDisplay::Diff) => {
416        if (!self.show_fd) && (!self.no_diff_fd) {
417          self.diff_fd = true;
418        }
419      }
420      Some(FileDescriptorDisplay::Hide) if (!self.diff_fd) && (!self.show_fd) => {
421        self.no_diff_fd = true;
422        self.no_show_fd = true;
423      }
424      _ => (),
425    }
426    fallback!(show_cmdline);
427    if !self.show_cmdline {
428      fallback!(show_argv);
429      tracing::warn!("{}", self.show_argv);
430      match config.env_display {
431        Some(EnvDisplay::Show) => {
432          if (!self.diff_env) && (!self.no_show_env) {
433            self.show_env = true;
434          }
435        }
436        Some(EnvDisplay::Diff) => {
437          if (!self.show_env) && (!self.no_diff_env) {
438            self.diff_env = true;
439          }
440        }
441        Some(EnvDisplay::Hide) if (!self.show_env) && (!self.diff_env) => {
442          self.no_diff_env = true;
443          self.no_show_env = true;
444        }
445        _ => (),
446      }
447    }
448    match config.color_level {
449      Some(ColorLevel::Less) => {
450        if !self.more_colors {
451          self.less_colors = true;
452        }
453      }
454      Some(ColorLevel::More) if !self.less_colors => {
455        self.more_colors = true;
456      }
457      _ => (),
458    }
459  }
460}
461
462#[derive(Args, Debug, Default, Clone)]
463pub struct TuiModeArgs {
464  #[clap(
465    long,
466    help = "Do not allocate a pseudo terminal; redirect stdin/out/err to /dev/null"
467  )]
468  pub no_tty: bool,
469  #[clap(long, short, help = "Keep the event list scrolled to the bottom")]
470  pub follow: bool,
471  #[clap(
472    long,
473    help = "Instead of waiting for the root child to exit, terminate when the TUI exits",
474    conflicts_with = "kill_on_exit"
475  )]
476  pub terminate_on_exit: bool,
477  #[clap(
478    long,
479    help = "Instead of waiting for the root child to exit, kill when the TUI exits"
480  )]
481  pub kill_on_exit: bool,
482  #[clap(
483    long,
484    short = 'A',
485    help = "Set the default active pane to use when TUI launches",
486    conflicts_with = "no_tty"
487  )]
488  pub active_pane: Option<ActivePane>,
489  #[clap(
490    long,
491    short = 'L',
492    help = "Set the layout of the TUI when it launches",
493    conflicts_with = "no_tty"
494  )]
495  pub layout: Option<AppLayout>,
496  #[clap(
497    long,
498    short = 'F',
499    help = "Set the frame rate of the TUI (60 by default)",
500    value_parser = frame_rate_parser
501  )]
502  pub frame_rate: Option<f64>,
503  #[clap(
504    long,
505    short = 'm',
506    help = "Max number of events to keep in TUI (0=unlimited)"
507  )]
508  pub max_events: Option<u64>,
509  #[clap(
510    long,
511    help = "Number of scrollback lines to keep in the pseudo terminal (1000 by default)",
512    conflicts_with = "no_tty"
513  )]
514  pub scrollback_lines: Option<usize>,
515  #[clap(
516    long = "theme",
517    help = "Path to a theme file to use for the TUI.",
518    value_parser = theme_file_cli_parser,
519  )]
520  /// Tri-state source of theme file path: unset, CLI, or config.
521  pub theme_file: Option<ThemeFileValue>,
522  #[clap(skip)]
523  pub theme: Option<Box<crate::cli::tui_theme::ThemeSpec>>,
524  #[clap(skip)]
525  pub keys: Option<Box<TuiKeyBindingsConfig>>,
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub enum ThemeFileValue {
530  Cli(PathBuf),
531  Config(PathBuf),
532}
533
534impl ThemeFileValue {
535  pub fn as_deref(&self) -> &Path {
536    match self {
537      Self::Cli(path) | Self::Config(path) => path.as_path(),
538    }
539  }
540
541  pub fn is_from_cli(&self) -> bool {
542    matches!(self, Self::Cli(_))
543  }
544}
545
546impl fmt::Display for ThemeFileValue {
547  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548    match self {
549      Self::Cli(path) | Self::Config(path) => write!(f, "{}", path.display()),
550    }
551  }
552}
553
554#[derive(Args, Debug, Default, Clone)]
555pub struct DebuggerArgs {
556  #[clap(
557    long,
558    short = 'D',
559    help = "Set the default external command to run when using \"Detach, Stop and Run Command\" feature in Hit Manager"
560  )]
561  pub default_external_command: Option<String>,
562  #[clap(
563    long = "add-breakpoint",
564    short = 'b',
565    value_parser = breakpoint_parser,
566    help = "Add a new breakpoint to the tracer. This option can be used multiple times. The format is <syscall-stop>:<pattern-type>:<pattern>, where syscall-stop can be sysenter or sysexit, pattern-type can be argv-regex, in-filename or exact-filename. For example, sysexit:in-filename:/bash",
567  )]
568  pub breakpoints: Vec<BreakPoint>,
569}
570
571impl TuiModeArgs {
572  pub const fn tty(&self) -> bool {
573    !self.no_tty
574  }
575
576  /// Reject options that only make sense when a pseudo terminal is allocated.
577  ///
578  /// The explicit `--no-tty` flag is already rejected by clap's
579  /// `conflicts_with`, but the effective mode can lack a pseudo terminal
580  /// without that flag, e.g. in eBPF TUI mode when no command is given and
581  /// the TUI traces all execs on the system. `--active-pane events` and
582  /// `--layout` are not checked here: they are valid (and the former is
583  /// what the TUI falls back to) without a pseudo terminal.
584  pub fn validate_pty_options(&self, pty_allocated: bool) -> color_eyre::Result<()> {
585    if pty_allocated {
586      return Ok(());
587    }
588    if self.active_pane == Some(ActivePane::Terminal) {
589      bail!(
590        "--active-pane terminal requires a pseudo terminal, which is not allocated in this mode"
591      );
592    }
593    if self.scrollback_lines.is_some() {
594      bail!("--scrollback-lines requires a pseudo terminal, which is not allocated in this mode");
595    }
596    Ok(())
597  }
598
599  pub fn merge_config(&mut self, config: TuiModeConfig) {
600    self.active_pane = self.active_pane.or(config.active_pane);
601    self.layout = self.layout.or(config.layout);
602    self.frame_rate = self.frame_rate.or(config.frame_rate);
603    self.max_events = self.max_events.or(config.max_events);
604    self.scrollback_lines = self.scrollback_lines.or(config.scrollback_lines);
605    if self.theme_file.is_none()
606      && let Some(path) = config.theme_file
607    {
608      self.theme_file = Some(ThemeFileValue::Config(path));
609    }
610    if self.theme.is_none() {
611      self.theme = config.theme.map(Box::new);
612    }
613    self.follow |= config.follow.unwrap_or_default();
614    if self.keys.is_none() {
615      self.keys = config.keys.map(Box::new);
616    }
617    if (!self.terminate_on_exit) && (!self.kill_on_exit) {
618      match config.exit_handling {
619        Some(ExitHandling::Kill) => self.kill_on_exit = true,
620        Some(ExitHandling::Terminate) => self.terminate_on_exit = true,
621        _ => (),
622      }
623    }
624  }
625}
626
627fn theme_file_cli_parser(s: &str) -> Result<ThemeFileValue, String> {
628  if s.is_empty() {
629    Err("theme file path cannot be empty".to_string())
630  } else {
631    Ok(ThemeFileValue::Cli(PathBuf::from(s)))
632  }
633}
634
635impl DebuggerArgs {
636  pub fn merge_config(&mut self, config: DebuggerConfig) {
637    if self.default_external_command.is_none() {
638      self.default_external_command = config.default_external_command;
639    }
640  }
641}
642
643fn frame_rate_parser(s: &str) -> Result<f64, ParseFrameRateError> {
644  let v = s.parse::<f64>().with_context(|_| ParseFloatSnafu {
645    value: s.to_string(),
646  })?;
647  if v < 0.0 || v.is_nan() || v.is_infinite() {
648    Err(ParseFrameRateError::Invalid)
649  } else if v < 5.0 {
650    Err(ParseFrameRateError::TooLow)
651  } else {
652    Ok(v)
653  }
654}
655
656fn breakpoint_parser(s: &str) -> Result<BreakPoint, Cow<'static, str>> {
657  BreakPoint::try_from(s)
658}
659
660#[derive(Snafu, Debug)]
661enum ParseFrameRateError {
662  #[snafu(display("Failed to parse frame rate {value} as a floating point number"))]
663  ParseFloat {
664    source: ParseFloatError,
665    value: String,
666  },
667  #[snafu(display("Invalid frame rate"))]
668  Invalid,
669  #[snafu(display("Frame rate too low, must be at least 5.0"))]
670  TooLow,
671}
672
673#[derive(Args, Debug, Default, Clone)]
674pub struct ExporterArgs {
675  #[clap(short, long, help = "prettify the output if supported")]
676  pub pretty: bool,
677}
678
679#[cfg(test)]
680mod tests {
681  use clap::Parser;
682  use test_that::prelude::*;
683
684  use super::*;
685
686  // Helper wrapper so we can test clap parsing easily
687  #[derive(Parser, Debug)]
688  struct TestCli<T: Args + Clone + std::fmt::Debug> {
689    #[clap(flatten)]
690    args: T,
691  }
692
693  /* ----------------------------- PtraceArgs ----------------------------- */
694
695  #[test]
696  fn test_ptrace_args_merge_config() {
697    let mut args = PtraceArgs {
698      seccomp_bpf: SeccompBpf::Auto,
699      polling_interval: None,
700    };
701
702    let cfg = PtraceConfig {
703      seccomp_bpf: Some(SeccompBpf::On),
704    };
705
706    args.merge_config(cfg);
707    assert_eq!(args.seccomp_bpf, SeccompBpf::On);
708  }
709
710  #[test]
711  fn test_ptrace_args_cli_parse() {
712    let cli = TestCli::<PtraceArgs>::parse_from(["test", "--polling-interval", "100"]);
713    assert_eq!(cli.args.polling_interval, Some(100));
714  }
715
716  /* ---------------------------- ModifierArgs ----------------------------- */
717
718  #[test]
719  fn test_modifier_processed_defaults() {
720    let args = ModifierArgs::default().processed();
721    assert!(args.resolve_proc_self_exe);
722    assert!(args.hide_cloexec_fds);
723    assert!(!args.timestamp);
724    assert!(args.inline_timestamp_format.is_some());
725  }
726
727  #[test]
728  fn test_modifier_processed_fd_implies_stdio() {
729    let args = ModifierArgs {
730      fd_in_cmdline: true,
731      ..Default::default()
732    }
733    .processed();
734
735    assert!(args.stdio_in_cmdline);
736  }
737
738  #[test]
739  fn test_modifier_merge_config() {
740    let mut args = ModifierArgs::default();
741
742    let cfg = ModifierConfig {
743      successful_only: Some(true),
744      fd_in_cmdline: Some(true),
745      stdio_in_cmdline: None,
746      resolve_proc_self_exe: Some(false),
747      hide_cloexec_fds: Some(false),
748      timestamp: None,
749      seccomp_bpf: None,
750      collect_cgroup: None,
751    };
752
753    args.merge_config(cfg);
754
755    assert!(args.successful_only);
756    assert!(args.fd_in_cmdline);
757    assert!(!args.resolve_proc_self_exe);
758    assert!(!args.hide_cloexec_fds);
759  }
760
761  #[test]
762  fn test_modifier_args_cli_overrides_config_positive() {
763    let mut args = ModifierArgs {
764      resolve_proc_self_exe: true, // CLI explicitly enables
765      ..Default::default()
766    };
767
768    let cfg = ModifierConfig {
769      resolve_proc_self_exe: Some(false),
770      ..Default::default()
771    };
772
773    args.merge_config(cfg);
774
775    assert!(args.resolve_proc_self_exe);
776  }
777
778  #[test]
779  fn test_modifier_args_cli_no_flag_blocks_config() {
780    let mut args = ModifierArgs {
781      no_hide_cloexec_fds: true, // CLI explicitly disables
782      ..Default::default()
783    };
784
785    let cfg = ModifierConfig {
786      hide_cloexec_fds: Some(true),
787      ..Default::default()
788    };
789
790    args.merge_config(cfg);
791
792    assert!(!args.hide_cloexec_fds);
793  }
794
795  #[test]
796  fn test_modifier_cli_parse_conflicts() {
797    let cli = TestCli::<ModifierArgs>::parse_from(["test", "--no-timestamp"]);
798
799    let processed = cli.args.processed();
800    assert!(!processed.timestamp);
801  }
802
803  #[test]
804  fn test_modifier_args_timestamp_cli_overrides_config() {
805    let mut args = ModifierArgs {
806      timestamp: true,
807      ..Default::default()
808    };
809
810    let cfg = ModifierConfig {
811      timestamp: Some(crate::cli::config::TimestampConfig {
812        enable: false,
813        inline_format: None,
814      }),
815      ..Default::default()
816    };
817
818    args.merge_config(cfg);
819
820    assert!(args.timestamp);
821  }
822
823  /* -------------------------- TracerEventArgs ---------------------------- */
824
825  #[test]
826  fn test_tracer_event_filter_parser_basic() {
827    let f = tracer_event_filter_parser("warning,error").unwrap();
828    assert!(f.contains(TracerEventDetailsKind::Warning));
829    assert!(f.contains(TracerEventDetailsKind::Error));
830  }
831
832  #[test]
833  fn test_tracer_event_filter_duplicate() {
834    let err = tracer_event_filter_parser("warning,warning").unwrap_err();
835    assert_that!(err, contains_substring("already included"));
836  }
837
838  #[test]
839  fn test_tracer_event_args_all() {
840    let args = TracerEventArgs::all();
841    let f = args.filter().unwrap();
842    assert_eq!(f, BitFlags::all());
843  }
844
845  #[test]
846  fn test_tracer_event_include_exclude_conflict() {
847    let args = TracerEventArgs {
848      show_all_events: false,
849      filter: BitFlags::empty(),
850      filter_include: TracerEventDetailsKind::Error.into(),
851      filter_exclude: TracerEventDetailsKind::Error.into(),
852    };
853
854    assert_that!(args.filter(), err(anything()));
855  }
856
857  /* ---------------------------- LogModeArgs ------------------------------ */
858
859  #[test]
860  fn test_logmode_foreground_logic() {
861    let args = LogModeArgs {
862      foreground: false,
863      no_foreground: true,
864      ..Default::default()
865    };
866    assert!(!args.foreground());
867
868    let args = LogModeArgs {
869      foreground: true,
870      no_foreground: false,
871      ..Default::default()
872    };
873    assert!(args.foreground());
874  }
875
876  #[test]
877  fn test_logmode_merge_color_config() {
878    let mut args = LogModeArgs::default();
879
880    let cfg = LogModeConfig {
881      color_level: Some(ColorLevel::Less),
882      ..Default::default()
883    };
884
885    args.merge_config(cfg);
886    assert!(args.less_colors);
887  }
888
889  #[test]
890  fn test_logmode_fd_display_config() {
891    let mut args = LogModeArgs::default();
892
893    let cfg = LogModeConfig {
894      fd_display: Some(FileDescriptorDisplay::Show),
895      ..Default::default()
896    };
897
898    args.merge_config(cfg);
899    assert!(args.show_fd);
900  }
901
902  #[test]
903  fn test_logmode_cli_parse() {
904    let cli = TestCli::<LogModeArgs>::parse_from(["test", "--show-cmdline", "--show-interpreter"]);
905
906    assert!(cli.args.show_cmdline);
907    assert!(cli.args.show_interpreter);
908  }
909
910  #[test]
911  fn test_logmode_cli_no_foreground_overrides_config() {
912    let mut args = LogModeArgs {
913      no_foreground: true,
914      ..Default::default()
915    };
916
917    let cfg = LogModeConfig {
918      foreground: Some(true),
919      ..Default::default()
920    };
921
922    args.merge_config(cfg);
923
924    assert!(!args.foreground());
925  }
926
927  #[test]
928  fn test_logmode_cli_show_fd_overrides_config_hide() {
929    let mut args = LogModeArgs {
930      show_fd: true,
931      ..Default::default()
932    };
933
934    let cfg = LogModeConfig {
935      fd_display: Some(FileDescriptorDisplay::Hide),
936      ..Default::default()
937    };
938
939    args.merge_config(cfg);
940
941    assert!(args.show_fd);
942    assert!(!args.no_show_fd);
943  }
944
945  #[test]
946  fn test_logmode_cli_color_overrides_config() {
947    let mut args = LogModeArgs {
948      more_colors: true,
949      ..Default::default()
950    };
951
952    let cfg = LogModeConfig {
953      color_level: Some(ColorLevel::Less),
954      ..Default::default()
955    };
956
957    args.merge_config(cfg);
958
959    assert!(args.more_colors);
960    assert!(!args.less_colors);
961  }
962
963  /* ----------------------------- TuiModeArgs ----------------------------- */
964
965  #[test]
966  fn test_tui_merge_config_exit_handling() {
967    let mut args = TuiModeArgs::default();
968
969    let cfg = TuiModeConfig {
970      exit_handling: Some(ExitHandling::Kill),
971      follow: Some(true),
972      theme_file: Some(PathBuf::from("high-contrast.toml")),
973      ..Default::default()
974    };
975
976    args.merge_config(cfg);
977
978    assert!(args.kill_on_exit);
979    assert!(args.follow);
980    assert_eq!(
981      args.theme_file,
982      Some(ThemeFileValue::Config(PathBuf::from("high-contrast.toml")))
983    );
984  }
985
986  #[test]
987  fn test_tui_merge_config_theme_file_from_cli() {
988    let mut args = TuiModeArgs {
989      theme_file: Some(ThemeFileValue::Cli(PathBuf::from("cli.toml"))),
990      ..Default::default()
991    };
992    let cfg = TuiModeConfig {
993      theme_file: Some(PathBuf::from("config.toml")),
994      ..Default::default()
995    };
996
997    args.merge_config(cfg);
998
999    // CLI value wins and keeps CLI provenance.
1000    assert_eq!(
1001      args.theme_file,
1002      Some(ThemeFileValue::Cli(PathBuf::from("cli.toml")))
1003    );
1004  }
1005
1006  #[test]
1007  fn test_tui_parse_theme_file_from_cli() {
1008    let args = TestCli::<TuiModeArgs>::parse_from(["test", "--theme", "cli.toml"]).args;
1009    assert_eq!(
1010      args.theme_file,
1011      Some(ThemeFileValue::Cli(PathBuf::from("cli.toml")))
1012    );
1013  }
1014
1015  #[test]
1016  fn test_tui_parse_theme_file_unset_by_default() {
1017    let args = TestCli::<TuiModeArgs>::parse_from(["test"]).args;
1018    assert_eq!(args.theme_file, None);
1019  }
1020
1021  #[test]
1022  fn test_tui_merge_config_inline_theme() {
1023    use crate::cli::tui_theme::{
1024      StyleSpec,
1025      ThemeColor,
1026      ThemeSpec,
1027    };
1028    let mut args = TuiModeArgs::default();
1029    let cfg = TuiModeConfig {
1030      theme: Some(ThemeSpec {
1031        app_title: Some(StyleSpec {
1032          fg: Some(ThemeColor::Named("cyan".into())),
1033          ..Default::default()
1034        }),
1035        ..Default::default()
1036      }),
1037      ..Default::default()
1038    };
1039
1040    args.merge_config(cfg);
1041
1042    assert!(matches!(
1043      args
1044        .theme
1045        .as_ref()
1046        .and_then(|s| s.app_title.as_ref())
1047        .and_then(|a| a.fg.as_ref()),
1048      Some(ThemeColor::Named(s)) if s == "cyan"
1049    ));
1050  }
1051
1052  #[test]
1053  fn test_tui_validate_pty_options() {
1054    // Options that require a pseudo terminal are accepted when one is allocated.
1055    let args = TuiModeArgs {
1056      active_pane: Some(crate::cli::options::ActivePane::Terminal),
1057      layout: Some(crate::cli::options::AppLayout::Vertical),
1058      scrollback_lines: Some(2000),
1059      ..Default::default()
1060    };
1061    assert!(args.validate_pty_options(true).is_ok());
1062
1063    // `--active-pane terminal` is rejected when the effective mode allocates none.
1064    assert_that!(args.validate_pty_options(false), err(anything()));
1065    assert_that!(
1066      TuiModeArgs {
1067        scrollback_lines: Some(1000),
1068        ..Default::default()
1069      }
1070      .validate_pty_options(false),
1071      err(anything())
1072    );
1073    // `--active-pane events`, `--layout`, and defaults stay valid without a
1074    // pseudo terminal.
1075    assert!(
1076      TuiModeArgs {
1077        active_pane: Some(crate::cli::options::ActivePane::Events),
1078        ..Default::default()
1079      }
1080      .validate_pty_options(false)
1081      .is_ok()
1082    );
1083    assert!(
1084      TuiModeArgs {
1085        layout: Some(crate::cli::options::AppLayout::Vertical),
1086        ..Default::default()
1087      }
1088      .validate_pty_options(false)
1089      .is_ok()
1090    );
1091    assert!(TuiModeArgs::default().validate_pty_options(false).is_ok());
1092  }
1093
1094  #[test]
1095  fn test_tui_cli_parse() {
1096    let cli = TestCli::<TuiModeArgs>::parse_from(["test", "--follow", "--frame-rate", "30"]);
1097
1098    assert!(cli.args.tty());
1099    assert!(!cli.args.no_tty);
1100    assert!(cli.args.follow);
1101    assert_eq!(cli.args.frame_rate, Some(30.0));
1102  }
1103
1104  #[test]
1105  fn test_tui_cli_parse_no_tty() {
1106    let cli = TestCli::<TuiModeArgs>::parse_from(["test", "--no-tty"]);
1107
1108    assert!(!cli.args.tty());
1109    assert!(cli.args.no_tty);
1110  }
1111
1112  #[test]
1113  fn test_tui_cli_no_tty_conflicts_with_terminal_options() {
1114    let result =
1115      TestCli::<TuiModeArgs>::try_parse_from(["test", "--no-tty", "--active-pane", "terminal"]);
1116
1117    assert_that!(result, err(anything()));
1118  }
1119
1120  #[test]
1121  fn test_tui_cli_exit_handling_overrides_config() {
1122    let mut args = TuiModeArgs {
1123      terminate_on_exit: true,
1124      ..Default::default()
1125    };
1126
1127    let cfg = TuiModeConfig {
1128      exit_handling: Some(ExitHandling::Kill),
1129      ..Default::default()
1130    };
1131
1132    args.merge_config(cfg);
1133
1134    assert!(args.terminate_on_exit);
1135    assert!(!args.kill_on_exit);
1136  }
1137
1138  /* --------------------------- DebuggerArgs ------------------------------ */
1139
1140  #[test]
1141  fn test_debugger_merge_config() {
1142    let mut args = DebuggerArgs::default();
1143
1144    let cfg = DebuggerConfig {
1145      default_external_command: Some("echo hi".into()),
1146    };
1147
1148    args.merge_config(cfg);
1149    assert_eq!(args.default_external_command.as_deref(), Some("echo hi"));
1150  }
1151
1152  #[test]
1153  fn test_debugger_cli_parse_breakpoint() {
1154    let cli = TestCli::<DebuggerArgs>::parse_from([
1155      "test",
1156      "--add-breakpoint",
1157      "sysenter:exact-filename:/bin/ls",
1158    ]);
1159
1160    assert_eq!(cli.args.breakpoints.len(), 1);
1161  }
1162
1163  #[test]
1164  fn test_debugger_cli_command_overrides_config() {
1165    let mut args = DebuggerArgs {
1166      default_external_command: Some("cli-cmd".into()),
1167      ..Default::default()
1168    };
1169
1170    let cfg = DebuggerConfig {
1171      default_external_command: Some("config-cmd".into()),
1172    };
1173
1174    args.merge_config(cfg);
1175
1176    assert_eq!(args.default_external_command.as_deref(), Some("cli-cmd"));
1177  }
1178
1179  /* ------------------------- frame_rate_parser --------------------------- */
1180
1181  #[test]
1182  fn test_frame_rate_parser_valid() {
1183    assert_eq!(frame_rate_parser("60").unwrap(), 60.0);
1184  }
1185
1186  #[test]
1187  fn test_frame_rate_parser_too_low() {
1188    let err = frame_rate_parser("1").unwrap_err();
1189    let msg = err.to_string();
1190    assert_that!(msg, contains_substring("too low"));
1191  }
1192
1193  #[test]
1194  fn test_frame_rate_parser_invalid() {
1195    let err = frame_rate_parser("-1").unwrap_err();
1196    assert_that!(err.to_string(), contains_substring("Invalid"));
1197  }
1198
1199  /* ----------------------------- ExporterArgs ---------------------------- */
1200
1201  #[test]
1202  fn test_exporter_cli_parse() {
1203    let cli = TestCli::<ExporterArgs>::parse_from(["test", "--pretty"]);
1204
1205    assert!(cli.args.pretty);
1206  }
1207}