Skip to main content

shuck/
args.rs

1//! Command-line argument types and parsing helpers for the `shuck` CLI.
2
3use std::ffi::OsString;
4use std::path::PathBuf;
5
6use clap::builder::Styles;
7use clap::builder::styling::{AnsiColor, Effects};
8use clap::error::ErrorKind;
9use clap::{
10    Args as ClapArgs, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
11};
12use shuck_formatter::{IndentStyle, ShellDialect};
13use shuck_linter::RuleSelector;
14
15use crate::config::{ConfigArgumentParser, ConfigArguments, SingleConfigArgument};
16use crate::format_settings::FormatSettingsPatch;
17
18const STYLES: Styles = Styles::styled()
19    .header(AnsiColor::Green.on_default().effects(Effects::BOLD))
20    .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
21    .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
22    .placeholder(AnsiColor::Cyan.on_default());
23const EXPERIMENTAL_ENV_VAR: &str = "SHUCK_EXPERIMENTAL";
24
25/// Shell dialect override accepted by `shuck format`.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27pub enum FormatDialectArg {
28    /// Detect the dialect from the source and file path when possible.
29    Auto,
30    /// Parse and format as Bash.
31    Bash,
32    /// Parse and format as a POSIX-style shell.
33    Posix,
34    /// Parse and format as mksh.
35    Mksh,
36    /// Parse and format as zsh.
37    Zsh,
38}
39
40impl From<FormatDialectArg> for ShellDialect {
41    fn from(value: FormatDialectArg) -> Self {
42        match value {
43            FormatDialectArg::Auto => Self::Auto,
44            FormatDialectArg::Bash => Self::Bash,
45            FormatDialectArg::Posix => Self::Posix,
46            FormatDialectArg::Mksh => Self::Mksh,
47            FormatDialectArg::Zsh => Self::Zsh,
48        }
49    }
50}
51
52/// Indentation styles accepted by `shuck format`.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
54pub enum FormatIndentStyleArg {
55    /// Indent with tab characters.
56    Tab,
57    /// Indent with spaces.
58    Space,
59}
60
61impl From<FormatIndentStyleArg> for IndentStyle {
62    fn from(value: FormatIndentStyleArg) -> Self {
63        match value {
64            FormatIndentStyleArg::Tab => Self::Tab,
65            FormatIndentStyleArg::Space => Self::Space,
66        }
67    }
68}
69
70/// Output formats supported by `shuck check`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
72pub enum CheckOutputFormatArg {
73    /// Emit one diagnostic per line.
74    Concise,
75    /// Emit rich human-readable diagnostics.
76    Full,
77    /// Emit a JSON array of diagnostics.
78    Json,
79    /// Emit one JSON object per line.
80    JsonLines,
81    /// Emit JUnit XML.
82    Junit,
83    /// Emit grouped human-readable diagnostics.
84    Grouped,
85    /// Emit GitHub Actions workflow commands.
86    Github,
87    /// Emit GitLab code quality output.
88    Gitlab,
89    /// Emit Reviewdog RDJSON.
90    Rdjson,
91    /// Emit SARIF.
92    Sarif,
93}
94
95/// Color preference for terminal output.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
97pub enum TerminalColor {
98    /// Display colors if the output goes to an interactive terminal.
99    Auto,
100    /// Always display colors.
101    Always,
102    /// Never display colors.
103    Never,
104}
105
106/// Managed shell names accepted by `shuck run`, `shuck install`, and `shuck shell`.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
108pub enum ManagedShellArg {
109    /// GNU bash.
110    Bash,
111    /// The gbash runtime.
112    Gbash,
113    /// The Bashkit runtime.
114    Bashkit,
115    /// Z shell.
116    Zsh,
117    /// Debian Almquist shell.
118    Dash,
119    /// MirBSD Korn shell.
120    Mksh,
121    /// BusyBox shell wrapper (Linux only).
122    Busybox,
123}
124
125impl From<ManagedShellArg> for shuck_run::Shell {
126    fn from(value: ManagedShellArg) -> Self {
127        match value {
128            ManagedShellArg::Bash => Self::Bash,
129            ManagedShellArg::Gbash => Self::Gbash,
130            ManagedShellArg::Bashkit => Self::Bashkit,
131            ManagedShellArg::Zsh => Self::Zsh,
132            ManagedShellArg::Dash => Self::Dash,
133            ManagedShellArg::Mksh => Self::Mksh,
134            ManagedShellArg::Busybox => Self::Busybox,
135        }
136    }
137}
138
139#[derive(Debug, Parser)]
140#[command(name = "shuck")]
141#[command(about = "Shell checker CLI for shuck")]
142#[command(styles = STYLES)]
143struct StableCli {
144    #[command(flatten)]
145    global: GlobalArgs,
146    #[command(subcommand)]
147    command: StableCommand,
148}
149
150#[derive(Debug, Parser)]
151#[command(name = "shuck")]
152#[command(about = "Shell checker CLI for shuck")]
153#[command(styles = STYLES)]
154struct ExperimentalCli {
155    #[command(flatten)]
156    global: GlobalArgs,
157    #[command(subcommand)]
158    command: ExperimentalCommand,
159}
160
161#[derive(Debug, Clone, ClapArgs)]
162struct GlobalArgs {
163    /// Either a path to a TOML configuration file (`shuck.toml`), or a TOML
164    /// `<KEY> = <VALUE>` pair (such as you might find in a `shuck.toml`
165    /// configuration file) overriding a specific configuration option.
166    /// Overrides of individual settings using this option always take
167    /// precedence over all configuration files, including configuration files
168    /// that were also specified using `--config`.
169    #[arg(
170        long,
171        action = clap::ArgAction::Append,
172        value_name = "CONFIG_OPTION",
173        value_parser = ConfigArgumentParser,
174        global = true,
175        help_heading = "Global options"
176    )]
177    config: Vec<SingleConfigArgument>,
178    /// Ignore all configuration files.
179    #[arg(long, global = true, help_heading = "Global options")]
180    isolated: bool,
181    /// Control when colored output is used.
182    #[arg(
183        long,
184        value_enum,
185        value_name = "WHEN",
186        global = true,
187        help_heading = "Global options"
188    )]
189    color: Option<TerminalColor>,
190    /// Path to the cache directory.
191    #[arg(
192        long,
193        env = "SHUCK_CACHE_DIR",
194        global = true,
195        value_name = "PATH",
196        help_heading = "Miscellaneous"
197    )]
198    cache_dir: Option<PathBuf>,
199}
200
201#[derive(Debug, Subcommand)]
202enum StableCommand {
203    /// Lint shell files and supported embedded shell scripts.
204    Check(Box<CheckCommand>),
205    /// Run a shell script with a managed interpreter.
206    Run(RunCommand),
207    /// Pre-install a managed shell interpreter or list available versions.
208    Install(InstallCommand),
209    /// Spawn a shell session using a managed interpreter.
210    Shell(ShellCommand),
211    #[command(hide = true)]
212    Format(FormatCommand),
213    /// Remove shuck cache entries for the provided paths' projects.
214    Clean(CleanCommand),
215}
216
217#[derive(Debug, Subcommand)]
218enum ExperimentalCommand {
219    /// Lint shell files and supported embedded shell scripts.
220    Check(Box<CheckCommand>),
221    /// Run a shell script with a managed interpreter.
222    Run(RunCommand),
223    /// Pre-install a managed shell interpreter or list available versions.
224    Install(InstallCommand),
225    /// Spawn a shell session using a managed interpreter.
226    Shell(ShellCommand),
227    /// Format shell files.
228    Format(FormatCommand),
229    /// Remove shuck cache entries for the provided paths' projects.
230    Clean(CleanCommand),
231}
232
233/// Parsed top-level arguments for the `shuck` command.
234#[derive(Debug, Clone)]
235pub struct Args {
236    /// Override for the cache root directory.
237    pub cache_dir: Option<PathBuf>,
238    pub(crate) config: ConfigArguments,
239    pub(crate) color: Option<TerminalColor>,
240    /// The subcommand selected by the user.
241    pub command: Command,
242}
243
244impl Args {
245    /// Parse arguments from the current process and exit on invalid input.
246    pub fn parse() -> Self {
247        Self::try_parse().unwrap_or_else(|err| err.exit())
248    }
249
250    /// Parse arguments from the current process without exiting on errors.
251    pub fn try_parse() -> Result<Self, clap::Error> {
252        Self::try_parse_from(std::env::args_os())
253    }
254
255    /// Parse arguments from an arbitrary iterator of command-line values.
256    pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
257    where
258        I: IntoIterator<Item = T>,
259        T: Into<OsString> + Clone,
260    {
261        if experimental_enabled() {
262            let parsed = parse_with_color::<ExperimentalCli, _, _>(itr)?;
263            Self::from_experimental(parsed)
264        } else {
265            let parsed = parse_with_color::<StableCli, _, _>(itr)?;
266            Self::from_stable(parsed)
267        }
268    }
269}
270
271impl Args {
272    fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
273        let StableCli { global, command } = value;
274        let GlobalArgs {
275            cache_dir,
276            config,
277            isolated,
278            color,
279        } = global;
280        let command = match command {
281            StableCommand::Check(command) => Command::Check(command),
282            StableCommand::Run(command) => Command::Run(command),
283            StableCommand::Install(command) => Command::Install(command),
284            StableCommand::Shell(command) => Command::Shell(command),
285            StableCommand::Format(_) => {
286                return Err(clap::Error::raw(
287                    ErrorKind::InvalidSubcommand,
288                    format!(
289                        "the `format` subcommand is experimental; set {EXPERIMENTAL_ENV_VAR}=1 to enable it"
290                    ),
291                ));
292            }
293            StableCommand::Clean(command) => Command::Clean(command),
294        };
295
296        Ok(Self {
297            cache_dir,
298            config: ConfigArguments::from_cli(config, isolated)?,
299            color,
300            command,
301        })
302    }
303
304    fn from_experimental(value: ExperimentalCli) -> Result<Self, clap::Error> {
305        let ExperimentalCli { global, command } = value;
306        let GlobalArgs {
307            cache_dir,
308            config,
309            isolated,
310            color,
311        } = global;
312        let command = match command {
313            ExperimentalCommand::Check(command) => Command::Check(command),
314            ExperimentalCommand::Run(command) => Command::Run(command),
315            ExperimentalCommand::Install(command) => Command::Install(command),
316            ExperimentalCommand::Shell(command) => Command::Shell(command),
317            ExperimentalCommand::Format(command) => Command::Format(command),
318            ExperimentalCommand::Clean(command) => Command::Clean(command),
319        };
320
321        Ok(Self {
322            cache_dir,
323            config: ConfigArguments::from_cli(config, isolated)?,
324            color,
325            command,
326        })
327    }
328}
329
330/// Supported `shuck` subcommands.
331#[derive(Debug, Clone, Subcommand)]
332pub enum Command {
333    /// Lint shell files and supported embedded shell scripts.
334    Check(Box<CheckCommand>),
335    /// Run a shell script with a managed interpreter.
336    Run(RunCommand),
337    /// Pre-install a managed shell interpreter or list available versions.
338    Install(InstallCommand),
339    /// Spawn a shell session using a managed interpreter.
340    Shell(ShellCommand),
341    /// Format shell files.
342    Format(FormatCommand),
343    /// Remove shuck cache entries for the provided paths' projects.
344    Clean(CleanCommand),
345}
346
347fn experimental_enabled() -> bool {
348    std::env::var_os(EXPERIMENTAL_ENV_VAR).is_some_and(|value| {
349        !matches!(
350            value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
351            "" | "0" | "false" | "no" | "off"
352        )
353    })
354}
355
356/// Arguments for `shuck check`.
357#[derive(Debug, Clone, ClapArgs)]
358pub struct CheckCommand {
359    /// Apply safe fixes.
360    #[arg(long)]
361    pub fix: bool,
362    /// Apply unsafe fixes.
363    #[arg(long = "unsafe-fixes")]
364    pub unsafe_fixes: bool,
365    /// Enable automatic additions of shuck ignore directives to failing lines.
366    /// Optionally provide a reason to append after the codes.
367    #[arg(
368        long = "add-ignore",
369        value_name = "REASON",
370        default_missing_value = "",
371        num_args = 0..=1,
372        require_equals = true,
373        conflicts_with = "fix",
374        conflicts_with = "unsafe_fixes",
375    )]
376    pub add_ignore: Option<String>,
377    /// Output serialization format for violations.
378    /// The default serialization format is "full".
379    #[arg(
380        long = "output-format",
381        value_enum,
382        env = "SHUCK_OUTPUT_FORMAT",
383        default_value_t = CheckOutputFormatArg::Full
384    )]
385    pub output_format: CheckOutputFormatArg,
386    /// Run in watch mode by re-running whenever files change.
387    #[arg(short = 'w', long, conflicts_with = "add_ignore")]
388    pub watch: bool,
389    /// Files or directories to check.
390    pub paths: Vec<PathBuf>,
391    /// Rule selection and suppression settings.
392    #[command(flatten)]
393    pub rule_selection: RuleSelectionArgs,
394    /// File discovery and exclusion settings.
395    #[command(flatten)]
396    pub file_selection: FileSelectionArgs,
397    /// Disable cache reads and writes.
398    #[arg(long = "no-cache", help_heading = "Miscellaneous")]
399    pub no_cache: bool,
400    /// Exit with status code "0", even upon detecting lint violations. Parse errors and error-severity diagnostics still fail.
401    #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
402    pub exit_zero: bool,
403    /// Exit with a non-zero status code if any files were modified via fix, even if no lint violations remain.
404    #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
405    pub exit_non_zero_on_fix: bool,
406}
407
408impl CheckCommand {
409    /// Whether standard ignore files such as `.gitignore` should be respected.
410    pub fn respect_gitignore(&self) -> bool {
411        self.file_selection.respect_gitignore()
412    }
413
414    /// Whether excludes should also apply to explicitly passed paths.
415    pub fn force_exclude(&self) -> bool {
416        self.file_selection.force_exclude()
417    }
418}
419
420/// Arguments for `shuck run`.
421#[derive(Debug, Clone, ClapArgs)]
422pub struct RunCommand {
423    /// Shell interpreter name (`bash`, `gbash`, `bashkit`, `zsh`, `dash`, `mksh`, or Linux-only `busybox`).
424    #[arg(short = 's', long, value_enum)]
425    pub shell: Option<ManagedShellArg>,
426    /// Version constraint (for example `5.2`, `>=5.1,<6`, or `latest`).
427    #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
428    pub shell_version: Option<String>,
429    /// Use the system-installed interpreter instead of a managed one.
430    #[arg(long)]
431    pub system: bool,
432    /// Resolve and print the interpreter path without executing.
433    #[arg(long)]
434    pub dry_run: bool,
435    /// Show resolution and download progress.
436    #[arg(short = 'v', long)]
437    pub verbose: bool,
438    /// Evaluate a command string instead of running a script file.
439    #[arg(
440        short = 'c',
441        long = "command",
442        value_name = "COMMAND",
443        conflicts_with = "script"
444    )]
445    pub command: Option<String>,
446    /// Script path to execute, or `-` to read from stdin.
447    pub script: Option<PathBuf>,
448    /// Arguments passed through to the script or command.
449    #[arg(last = true, value_name = "ARGS")]
450    pub script_args: Vec<OsString>,
451}
452
453/// Arguments for `shuck install`.
454#[derive(Debug, Clone, ClapArgs)]
455pub struct InstallCommand {
456    /// Show available shells and versions instead of installing anything.
457    #[arg(long)]
458    pub list: bool,
459    /// Force a fresh registry fetch even if the local registry cache is still fresh.
460    #[arg(long)]
461    pub refresh: bool,
462    /// Shell interpreter name (`bash`, `gbash`, `bashkit`, `zsh`, `dash`, `mksh`, or Linux-only `busybox`).
463    #[arg(required_unless_present = "list", value_enum)]
464    pub shell: Option<ManagedShellArg>,
465    /// Version constraint to install.
466    #[arg(required_unless_present = "list")]
467    pub version: Option<String>,
468}
469
470/// Arguments for `shuck shell`.
471#[derive(Debug, Clone, ClapArgs)]
472pub struct ShellCommand {
473    /// Shell interpreter name (`bash`, `gbash`, `bashkit`, `zsh`, `dash`, `mksh`, or Linux-only `busybox`).
474    #[arg(short = 's', long, value_enum)]
475    pub shell: Option<ManagedShellArg>,
476    /// Version constraint (for example `5.2`, `>=5.1,<6`, or `latest`).
477    #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
478    pub shell_version: Option<String>,
479    /// Use the system-installed interpreter instead of a managed one.
480    #[arg(long)]
481    pub system: bool,
482    /// Show resolution and download progress.
483    #[arg(short = 'v', long)]
484    pub verbose: bool,
485}
486
487/// A `<pattern>:<rule-selector>` mapping from the CLI.
488#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct PatternRuleSelectorPair {
490    /// Glob-style file pattern.
491    pub pattern: String,
492    /// Rule selector applied to matching files.
493    pub selector: RuleSelector,
494}
495
496impl std::str::FromStr for PatternRuleSelectorPair {
497    type Err = String;
498
499    fn from_str(value: &str) -> Result<Self, Self::Err> {
500        let (pattern, selector) = value
501            .rsplit_once(':')
502            .ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
503        let pattern = pattern.trim();
504        let selector = selector.trim();
505
506        if pattern.is_empty() || selector.is_empty() {
507            return Err("expected <FilePattern>:<RuleCode>".to_owned());
508        }
509
510        Ok(Self {
511            pattern: pattern.to_owned(),
512            selector: parse_cli_rule_selector(selector)?,
513        })
514    }
515}
516
517/// A `<pattern>:<shell>` mapping from the CLI.
518#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct PatternShellPair {
520    /// Glob-style file pattern.
521    pub pattern: String,
522    /// Shell dialect applied to matching files.
523    pub shell: shuck_linter::ShellDialect,
524}
525
526impl std::str::FromStr for PatternShellPair {
527    type Err = String;
528
529    fn from_str(value: &str) -> Result<Self, Self::Err> {
530        let (pattern, shell) = value
531            .rsplit_once(':')
532            .ok_or_else(|| "expected <FilePattern>:<Shell>".to_owned())?;
533        let pattern = pattern.trim();
534        let shell = shell.trim();
535
536        if pattern.is_empty() || shell.is_empty() {
537            return Err("expected <FilePattern>:<Shell>".to_owned());
538        }
539
540        let shell = shuck_linter::ShellDialect::from_name(shell);
541        if shell == shuck_linter::ShellDialect::Unknown {
542            return Err(
543                "expected shell dialect to be one of sh, bash, dash, ksh, mksh, zsh".to_owned(),
544            );
545        }
546
547        Ok(Self {
548            pattern: pattern.to_owned(),
549            shell,
550        })
551    }
552}
553
554fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
555    let value = value.trim();
556    if value.is_empty() {
557        return Err("rule selector cannot be empty".to_owned());
558    }
559
560    value.parse::<RuleSelector>().map_err(|err| err.to_string())
561}
562
563/// Rule-selection flags shared by `shuck check`.
564#[derive(Debug, Clone, Default, ClapArgs)]
565pub struct RuleSelectionArgs {
566    /// Comma-separated list of rule selectors to enable (for example `google`, `C`, or `C001`; or ALL to enable all rules).
567    #[arg(
568        long,
569        value_delimiter = ',',
570        value_parser = parse_cli_rule_selector,
571        value_name = "RULE_CODE",
572        help_heading = "Rule selection",
573        hide_possible_values = true
574    )]
575    pub select: Option<Vec<RuleSelector>>,
576    /// Comma-separated list of rule selectors to disable.
577    #[arg(
578        long,
579        value_delimiter = ',',
580        value_parser = parse_cli_rule_selector,
581        value_name = "RULE_CODE",
582        help_heading = "Rule selection",
583        hide_possible_values = true
584    )]
585    pub ignore: Vec<RuleSelector>,
586    /// Like --select, but adds additional rule selectors on top of those already specified.
587    #[arg(
588        long,
589        value_delimiter = ',',
590        value_parser = parse_cli_rule_selector,
591        value_name = "RULE_CODE",
592        help_heading = "Rule selection",
593        hide_possible_values = true
594    )]
595    pub extend_select: Vec<RuleSelector>,
596    /// List of mappings from file pattern to code to exclude.
597    #[arg(
598        long,
599        value_delimiter = ',',
600        value_name = "PER_FILE_IGNORES",
601        help_heading = "Rule selection"
602    )]
603    pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
604    /// Like `--per-file-ignores`, but adds additional ignores on top of those already specified.
605    #[arg(
606        long,
607        value_delimiter = ',',
608        value_name = "EXTEND_PER_FILE_IGNORES",
609        help_heading = "Rule selection"
610    )]
611    pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
612    /// List of mappings from file pattern to shell dialect.
613    #[arg(
614        long,
615        value_delimiter = ',',
616        value_name = "PER_FILE_SHELL",
617        help_heading = "Rule selection"
618    )]
619    pub per_file_shell: Option<Vec<PatternShellPair>>,
620    /// Like `--per-file-shell`, but adds additional shell mappings on top of those already specified.
621    #[arg(
622        long,
623        value_delimiter = ',',
624        value_name = "EXTEND_PER_FILE_SHELL",
625        help_heading = "Rule selection"
626    )]
627    pub extend_per_file_shell: Vec<PatternShellPair>,
628    /// List of rule selectors to treat as eligible for fix. Only applicable when fix itself is enabled (e.g., via `--fix`).
629    #[arg(
630        long,
631        value_delimiter = ',',
632        value_parser = parse_cli_rule_selector,
633        value_name = "RULE_CODE",
634        help_heading = "Rule selection",
635        hide_possible_values = true
636    )]
637    pub fixable: Option<Vec<RuleSelector>>,
638    /// List of rule selectors to treat as ineligible for fix. Only applicable when fix itself is enabled (e.g., via `--fix`).
639    #[arg(
640        long,
641        value_delimiter = ',',
642        value_parser = parse_cli_rule_selector,
643        value_name = "RULE_CODE",
644        help_heading = "Rule selection",
645        hide_possible_values = true
646    )]
647    pub unfixable: Vec<RuleSelector>,
648    /// Like --fixable, but adds additional rule selectors on top of those already specified.
649    #[arg(
650        long,
651        value_delimiter = ',',
652        value_parser = parse_cli_rule_selector,
653        value_name = "RULE_CODE",
654        help_heading = "Rule selection",
655        hide_possible_values = true
656    )]
657    pub extend_fixable: Vec<RuleSelector>,
658}
659
660fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
661where
662    Cli: CommandFactory + FromArgMatches,
663    I: IntoIterator<Item = T>,
664    T: Into<OsString> + Clone,
665{
666    let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
667    let mut command = Cli::command().color(command_color_choice(&args));
668    let matches = command.try_get_matches_from_mut(args)?;
669    Cli::from_arg_matches(&matches)
670}
671
672fn command_color_choice(args: &[OsString]) -> ColorChoice {
673    match preparse_color(args) {
674        Some(ColorChoice::Always) => ColorChoice::Always,
675        Some(ColorChoice::Never) => ColorChoice::Never,
676        Some(ColorChoice::Auto) | None => {
677            if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
678                ColorChoice::Always
679            } else {
680                ColorChoice::Auto
681            }
682        }
683    }
684}
685
686fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
687    let mut expect_value = false;
688    let mut color = None;
689
690    for argument in args.iter().skip(1) {
691        if expect_value {
692            let value = argument.to_string_lossy();
693            color = value.parse().ok();
694            expect_value = false;
695            continue;
696        }
697
698        let argument = argument.to_string_lossy();
699        if argument == "--" {
700            break;
701        }
702        if argument == "--color" {
703            expect_value = true;
704            continue;
705        }
706        if let Some(value) = argument.strip_prefix("--color=") {
707            color = value.parse().ok();
708        }
709    }
710
711    color
712}
713
714/// File-discovery and exclusion flags shared by multiple commands.
715#[derive(Debug, Clone, Default, ClapArgs)]
716pub struct FileSelectionArgs {
717    /// List of paths, used to omit files and/or directories from analysis.
718    #[arg(
719        long,
720        value_delimiter = ',',
721        value_name = "FILE_PATTERN",
722        help_heading = "File selection"
723    )]
724    pub exclude: Vec<String>,
725    /// Like --exclude, but adds additional files and directories on top of those already excluded.
726    #[arg(
727        long,
728        value_delimiter = ',',
729        value_name = "FILE_PATTERN",
730        help_heading = "File selection"
731    )]
732    pub extend_exclude: Vec<String>,
733    /// Respect file exclusions via `.gitignore` and other standard ignore files.
734    /// Use `--no-respect-gitignore` to disable.
735    #[arg(
736        long,
737        overrides_with = "no_respect_gitignore",
738        help_heading = "File selection"
739    )]
740    pub(crate) respect_gitignore: bool,
741    #[arg(long, overrides_with = "respect_gitignore", hide = true)]
742    pub(crate) no_respect_gitignore: bool,
743    /// Enforce exclusions, even for paths passed to shuck directly on the command-line.
744    /// Use `--no-force-exclude` to disable.
745    #[arg(
746        long,
747        overrides_with = "no_force_exclude",
748        help_heading = "File selection"
749    )]
750    pub(crate) force_exclude: bool,
751    #[arg(long, overrides_with = "force_exclude", hide = true)]
752    pub(crate) no_force_exclude: bool,
753}
754
755impl FileSelectionArgs {
756    /// Resolve the effective `respect_gitignore` setting after CLI overrides.
757    pub fn respect_gitignore(&self) -> bool {
758        resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
759    }
760
761    /// Resolve the effective `force_exclude` setting after CLI overrides.
762    pub fn force_exclude(&self) -> bool {
763        resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
764    }
765}
766
767/// Arguments for `shuck format`.
768#[derive(Debug, Clone, ClapArgs)]
769pub struct FormatCommand {
770    /// List of files or directories to format, or `-` to read from stdin.
771    pub files: Vec<PathBuf>,
772    /// Avoid writing any formatted files back; instead, exit non-zero if any files would change.
773    #[arg(long)]
774    pub check: bool,
775    /// Avoid writing any formatted files back; instead, print a diff for each changed file.
776    #[arg(long)]
777    pub diff: bool,
778    /// Disable cache reads and writes.
779    #[arg(long = "no-cache")]
780    pub no_cache: bool,
781    /// The name of the file when reading the source from stdin.
782    #[arg(long)]
783    pub stdin_filename: Option<PathBuf>,
784    /// File discovery and exclusion settings.
785    #[command(flatten)]
786    pub file_selection: FileSelectionArgs,
787    /// Override the auto-discovered shell dialect used for parsing and formatting.
788    #[arg(long, value_enum)]
789    pub dialect: Option<FormatDialectArg>,
790    /// Choose the indentation style.
791    #[arg(long, value_enum)]
792    pub indent_style: Option<FormatIndentStyleArg>,
793    /// Set the indentation width for space indentation.
794    #[arg(long, value_name = "WIDTH")]
795    pub indent_width: Option<u8>,
796    /// Put binary operators on the next line when breaking lists and pipelines.
797    #[arg(long, overrides_with = "no_binary_next_line")]
798    pub(crate) binary_next_line: bool,
799    #[arg(
800        long = "no-binary-next-line",
801        overrides_with = "binary_next_line",
802        hide = true
803    )]
804    pub(crate) no_binary_next_line: bool,
805    /// Indent the bodies of `case` branches.
806    #[arg(long, overrides_with = "no_switch_case_indent")]
807    pub(crate) switch_case_indent: bool,
808    #[arg(
809        long = "no-switch-case-indent",
810        overrides_with = "switch_case_indent",
811        hide = true
812    )]
813    pub(crate) no_switch_case_indent: bool,
814    /// Insert spaces around redirection operators and targets.
815    #[arg(long, overrides_with = "no_space_redirects")]
816    pub(crate) space_redirects: bool,
817    #[arg(
818        long = "no-space-redirects",
819        overrides_with = "space_redirects",
820        hide = true
821    )]
822    pub(crate) no_space_redirects: bool,
823    /// Preserve source padding when it is safe to do so.
824    #[arg(long, overrides_with = "no_keep_padding")]
825    pub(crate) keep_padding: bool,
826    #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
827    pub(crate) no_keep_padding: bool,
828    /// Put function opening braces on the next line.
829    #[arg(long, overrides_with = "no_function_next_line")]
830    pub(crate) function_next_line: bool,
831    #[arg(
832        long = "no-function-next-line",
833        overrides_with = "function_next_line",
834        hide = true
835    )]
836    pub(crate) no_function_next_line: bool,
837    /// Prefer compact layouts and avoid optional splitting.
838    #[arg(long, overrides_with = "no_never_split")]
839    pub(crate) never_split: bool,
840    #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
841    pub(crate) no_never_split: bool,
842    /// Apply safe simplifications before formatting.
843    #[arg(long)]
844    pub simplify: bool,
845    /// Emit a compact minified form and drop comments.
846    #[arg(long)]
847    pub minify: bool,
848}
849
850impl FormatCommand {
851    pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
852        FormatSettingsPatch {
853            dialect: self.dialect.map(Into::into),
854            indent_style: self.indent_style.map(Into::into),
855            indent_width: self.indent_width,
856            binary_next_line: self.binary_next_line(),
857            switch_case_indent: self.switch_case_indent(),
858            space_redirects: self.space_redirects(),
859            keep_padding: self.keep_padding(),
860            function_next_line: self.function_next_line(),
861            never_split: self.never_split(),
862            simplify: self.simplify.then_some(true),
863            minify: self.minify.then_some(true),
864        }
865    }
866
867    /// Resolve the effective `binary-next-line` formatter option.
868    pub fn binary_next_line(&self) -> Option<bool> {
869        tri_state_bool(self.binary_next_line, self.no_binary_next_line)
870    }
871
872    /// Resolve the effective `switch-case-indent` formatter option.
873    pub fn switch_case_indent(&self) -> Option<bool> {
874        tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
875    }
876
877    /// Resolve the effective `space-redirects` formatter option.
878    pub fn space_redirects(&self) -> Option<bool> {
879        tri_state_bool(self.space_redirects, self.no_space_redirects)
880    }
881
882    /// Resolve the effective `keep-padding` formatter option.
883    pub fn keep_padding(&self) -> Option<bool> {
884        tri_state_bool(self.keep_padding, self.no_keep_padding)
885    }
886
887    /// Resolve the effective `function-next-line` formatter option.
888    pub fn function_next_line(&self) -> Option<bool> {
889        tri_state_bool(self.function_next_line, self.no_function_next_line)
890    }
891
892    /// Resolve the effective `never-split` formatter option.
893    pub fn never_split(&self) -> Option<bool> {
894        tri_state_bool(self.never_split, self.no_never_split)
895    }
896
897    /// Whether standard ignore files such as `.gitignore` should be respected.
898    pub fn respect_gitignore(&self) -> bool {
899        self.file_selection.respect_gitignore()
900    }
901
902    /// Whether excludes should also apply to explicitly passed paths.
903    pub fn force_exclude(&self) -> bool {
904        self.file_selection.force_exclude()
905    }
906}
907
908fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
909    match (positive, negative) {
910        (false, false) => None,
911        (true, false) => Some(true),
912        (false, true) => Some(false),
913        // The caller wires every positive/negative flag pair with
914        // `overrides_with`, so clap normalizes repeated input down to at most
915        // one active boolean before we derive the tri-state value.
916        (true, true) => unreachable!("clap should make this impossible"),
917    }
918}
919
920fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
921    match (positive, negative) {
922        (false, false) => default,
923        (true, false) => true,
924        (false, true) => false,
925        // Clap's `overrides_with` on these paired flags keeps only the
926        // last occurrence, so both booleans cannot remain set here.
927        (true, true) => unreachable!("clap should make this impossible"),
928    }
929}
930
931/// Arguments for `shuck clean`.
932#[derive(Debug, Clone, ClapArgs)]
933pub struct CleanCommand {
934    /// Files or directories whose project caches should be removed.
935    pub paths: Vec<PathBuf>,
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941    use clap::builder::TypedValueParser;
942    use shuck_linter::Rule;
943
944    #[test]
945    fn global_config_override_is_available_after_subcommand() {
946        let command = StableCli::command();
947        let override_argument = crate::config::ConfigArgumentParser
948            .parse_ref(
949                &command,
950                None,
951                std::ffi::OsStr::new("format.indent-width = 2"),
952            )
953            .unwrap();
954
955        let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
956            .unwrap();
957
958        assert_eq!(
959            args.config,
960            ConfigArguments::from_cli(vec![override_argument], false).unwrap()
961        );
962    }
963
964    #[test]
965    fn explicit_config_file_and_inline_override_both_parse_globally() {
966        let tempdir = tempfile::tempdir().unwrap();
967        let config_path = tempdir.path().join("shuck.toml");
968        std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
969        let command = StableCli::command();
970        let override_argument = crate::config::ConfigArgumentParser
971            .parse_ref(
972                &command,
973                None,
974                std::ffi::OsStr::new("format.function-next-line = true"),
975            )
976            .unwrap();
977
978        let args = Args::try_parse_from([
979            "shuck",
980            "--config",
981            config_path.to_str().unwrap(),
982            "--config",
983            "format.function-next-line = true",
984            "check",
985        ])
986        .unwrap();
987
988        assert_eq!(
989            args.config,
990            ConfigArguments::from_cli(
991                vec![
992                    SingleConfigArgument::FilePath(config_path),
993                    override_argument
994                ],
995                false,
996            )
997            .unwrap()
998        );
999    }
1000
1001    #[test]
1002    fn global_color_can_be_parsed_before_subcommand() {
1003        let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
1004        assert_eq!(args.color, Some(TerminalColor::Never));
1005    }
1006
1007    #[test]
1008    fn preparse_color_uses_last_value() {
1009        assert_eq!(
1010            preparse_color(&[
1011                OsString::from("shuck"),
1012                OsString::from("--color=always"),
1013                OsString::from("--color"),
1014                OsString::from("never"),
1015            ]),
1016            Some(ColorChoice::Never)
1017        );
1018    }
1019
1020    fn parse_check<I, T>(args: I) -> CheckCommand
1021    where
1022        I: IntoIterator<Item = T>,
1023        T: Into<OsString> + Clone,
1024    {
1025        let parsed = StableCli::try_parse_from(args).unwrap();
1026        match Args::from_stable(parsed).unwrap().command {
1027            Command::Check(command) => *command,
1028            command => panic!("expected check command, got {command:?}"),
1029        }
1030    }
1031
1032    fn parse_run<I, T>(args: I) -> RunCommand
1033    where
1034        I: IntoIterator<Item = T>,
1035        T: Into<OsString> + Clone,
1036    {
1037        let parsed = StableCli::try_parse_from(args).unwrap();
1038        match Args::from_stable(parsed).unwrap().command {
1039            Command::Run(command) => command,
1040            command => panic!("expected run command, got {command:?}"),
1041        }
1042    }
1043
1044    fn parse_install<I, T>(args: I) -> InstallCommand
1045    where
1046        I: IntoIterator<Item = T>,
1047        T: Into<OsString> + Clone,
1048    {
1049        let parsed = StableCli::try_parse_from(args).unwrap();
1050        match Args::from_stable(parsed).unwrap().command {
1051            Command::Install(command) => command,
1052            command => panic!("expected install command, got {command:?}"),
1053        }
1054    }
1055
1056    fn parse_shell<I, T>(args: I) -> ShellCommand
1057    where
1058        I: IntoIterator<Item = T>,
1059        T: Into<OsString> + Clone,
1060    {
1061        let parsed = StableCli::try_parse_from(args).unwrap();
1062        match Args::from_stable(parsed).unwrap().command {
1063            Command::Shell(command) => command,
1064            command => panic!("expected shell command, got {command:?}"),
1065        }
1066    }
1067
1068    #[test]
1069    fn parses_add_ignore_without_reason() {
1070        let command = parse_check(["shuck", "check", "--add-ignore"]);
1071
1072        assert_eq!(command.add_ignore, Some(String::new()));
1073    }
1074
1075    #[test]
1076    fn parses_add_ignore_with_reason() {
1077        let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
1078
1079        assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
1080    }
1081
1082    #[test]
1083    fn parses_short_watch_flag() {
1084        let command = parse_check(["shuck", "check", "-w"]);
1085
1086        assert!(command.watch);
1087    }
1088
1089    #[test]
1090    fn parses_long_watch_flag() {
1091        let command = parse_check(["shuck", "check", "--watch"]);
1092
1093        assert!(command.watch);
1094    }
1095
1096    #[test]
1097    fn parses_all_check_output_formats() {
1098        for (raw, expected) in [
1099            ("concise", CheckOutputFormatArg::Concise),
1100            ("full", CheckOutputFormatArg::Full),
1101            ("json", CheckOutputFormatArg::Json),
1102            ("json-lines", CheckOutputFormatArg::JsonLines),
1103            ("junit", CheckOutputFormatArg::Junit),
1104            ("grouped", CheckOutputFormatArg::Grouped),
1105            ("github", CheckOutputFormatArg::Github),
1106            ("gitlab", CheckOutputFormatArg::Gitlab),
1107            ("rdjson", CheckOutputFormatArg::Rdjson),
1108            ("sarif", CheckOutputFormatArg::Sarif),
1109        ] {
1110            let command = parse_check(["shuck", "check", "--output-format", raw]);
1111            assert_eq!(command.output_format, expected, "failed to parse {raw}");
1112        }
1113    }
1114
1115    #[test]
1116    fn parses_run_command_flags_and_passthrough_args() {
1117        let command = parse_run([
1118            "shuck",
1119            "run",
1120            "--shell",
1121            "bash",
1122            "--shell-version",
1123            "5.2",
1124            "--system",
1125            "--dry-run",
1126            "--verbose",
1127            "deploy.sh",
1128            "--",
1129            "--env",
1130            "staging",
1131        ]);
1132
1133        assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1134        assert_eq!(command.shell_version.as_deref(), Some("5.2"));
1135        assert!(command.system);
1136        assert!(command.dry_run);
1137        assert!(command.verbose);
1138        assert_eq!(
1139            command.script.as_deref(),
1140            Some(PathBuf::from("deploy.sh").as_path())
1141        );
1142        assert_eq!(
1143            command.script_args,
1144            vec![OsString::from("--env"), OsString::from("staging")]
1145        );
1146    }
1147
1148    #[test]
1149    fn parses_run_command_string_mode() {
1150        let command = parse_run([
1151            "shuck", "run", "-s", "bash", "-c", "echo hi", "--", "one", "two",
1152        ]);
1153
1154        assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1155        assert_eq!(command.command.as_deref(), Some("echo hi"));
1156        assert!(command.script.is_none());
1157        assert_eq!(
1158            command.script_args,
1159            vec![OsString::from("one"), OsString::from("two")]
1160        );
1161    }
1162
1163    #[test]
1164    fn parses_busybox_shell_variants() {
1165        let run = parse_run(["shuck", "run", "--shell", "busybox", "deploy.sh"]);
1166        assert_eq!(run.shell, Some(ManagedShellArg::Busybox));
1167
1168        let install = parse_install(["shuck", "install", "busybox", "1.36"]);
1169        assert_eq!(install.shell, Some(ManagedShellArg::Busybox));
1170
1171        let shell = parse_shell(["shuck", "shell", "--shell", "busybox"]);
1172        assert_eq!(shell.shell, Some(ManagedShellArg::Busybox));
1173    }
1174
1175    #[test]
1176    fn parses_install_list_without_version() {
1177        let command = parse_install(["shuck", "install", "--list", "bash"]);
1178        assert!(command.list);
1179        assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1180        assert!(command.version.is_none());
1181    }
1182
1183    #[test]
1184    fn parses_shell_command_flags() {
1185        let command = parse_shell([
1186            "shuck",
1187            "shell",
1188            "--shell",
1189            "zsh",
1190            "--shell-version",
1191            "5.9",
1192            "--system",
1193            "--verbose",
1194        ]);
1195
1196        assert_eq!(command.shell, Some(ManagedShellArg::Zsh));
1197        assert_eq!(command.shell_version.as_deref(), Some("5.9"));
1198        assert!(command.system);
1199        assert!(command.verbose);
1200    }
1201
1202    #[test]
1203    fn parses_extended_managed_shell_names() {
1204        let run_command = parse_run(["shuck", "run", "--shell", "gbash", "-c", "echo hi"]);
1205        assert_eq!(run_command.shell, Some(ManagedShellArg::Gbash));
1206
1207        let install_command = parse_install(["shuck", "install", "--list", "bashkit"]);
1208        assert_eq!(install_command.shell, Some(ManagedShellArg::Bashkit));
1209    }
1210
1211    #[test]
1212    fn parses_rule_selection_flags() {
1213        let command = parse_check([
1214            "shuck",
1215            "check",
1216            "--select",
1217            "C001",
1218            "--select",
1219            "S,C002",
1220            "--ignore",
1221            "C003,C004",
1222            "--extend-select",
1223            "X",
1224            "--fixable",
1225            "ALL",
1226            "--unfixable",
1227            "C001",
1228            "--extend-fixable",
1229            "S074",
1230        ]);
1231
1232        assert_eq!(
1233            command.rule_selection.select,
1234            Some(vec![
1235                RuleSelector::Rule(Rule::UnusedAssignment),
1236                RuleSelector::Category(shuck_linter::Category::Style),
1237                RuleSelector::Rule(Rule::DynamicSourcePath),
1238            ])
1239        );
1240        assert_eq!(
1241            command.rule_selection.ignore,
1242            vec![
1243                RuleSelector::Rule(Rule::UntrackedSourceFile),
1244                RuleSelector::Rule(Rule::UncheckedDirectoryChange),
1245            ]
1246        );
1247        assert_eq!(
1248            command.rule_selection.extend_select,
1249            vec![RuleSelector::Category(shuck_linter::Category::Portability)]
1250        );
1251        assert_eq!(
1252            command.rule_selection.fixable,
1253            Some(vec![RuleSelector::All])
1254        );
1255        assert_eq!(
1256            command.rule_selection.unfixable,
1257            vec![RuleSelector::Rule(Rule::UnusedAssignment)]
1258        );
1259        assert_eq!(
1260            command.rule_selection.extend_fixable,
1261            vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
1262        );
1263    }
1264
1265    #[test]
1266    fn parses_named_rule_selection_flags() {
1267        let command = parse_check([
1268            "shuck",
1269            "check",
1270            "--select",
1271            "google",
1272            "--extend-select",
1273            "google",
1274            "--fixable",
1275            "google",
1276        ]);
1277
1278        assert_eq!(
1279            command.rule_selection.select,
1280            Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1281        );
1282        assert_eq!(
1283            command.rule_selection.extend_select,
1284            vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)]
1285        );
1286        assert_eq!(
1287            command.rule_selection.fixable,
1288            Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1289        );
1290    }
1291
1292    #[test]
1293    fn parses_per_file_ignore_pairs() {
1294        let command = parse_check([
1295            "shuck",
1296            "check",
1297            "--per-file-ignores",
1298            "tests/*.sh:C001",
1299            "--extend-per-file-ignores",
1300            "!src/*.sh:S",
1301        ]);
1302
1303        assert_eq!(
1304            command.rule_selection.per_file_ignores,
1305            Some(vec![PatternRuleSelectorPair {
1306                pattern: "tests/*.sh".to_owned(),
1307                selector: RuleSelector::Rule(Rule::UnusedAssignment),
1308            }])
1309        );
1310        assert_eq!(
1311            command.rule_selection.extend_per_file_ignores,
1312            vec![PatternRuleSelectorPair {
1313                pattern: "!src/*.sh".to_owned(),
1314                selector: RuleSelector::Category(shuck_linter::Category::Style),
1315            }]
1316        );
1317    }
1318
1319    #[test]
1320    fn parses_named_per_file_ignore_pairs() {
1321        let command = parse_check(["shuck", "check", "--per-file-ignores", "tests/*.sh:google"]);
1322
1323        assert_eq!(
1324            command.rule_selection.per_file_ignores,
1325            Some(vec![PatternRuleSelectorPair {
1326                pattern: "tests/*.sh".to_owned(),
1327                selector: RuleSelector::Named(shuck_linter::NamedGroup::Google),
1328            }])
1329        );
1330    }
1331
1332    #[test]
1333    fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
1334        let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
1335
1336        assert_eq!(
1337            command.rule_selection.per_file_ignores,
1338            Some(vec![PatternRuleSelectorPair {
1339                pattern: r"C:\repo\*.sh".to_owned(),
1340                selector: RuleSelector::Rule(Rule::UnusedAssignment),
1341            }])
1342        );
1343    }
1344
1345    #[test]
1346    fn parses_per_file_shell_pairs() {
1347        let command = parse_check([
1348            "shuck",
1349            "check",
1350            "--per-file-shell",
1351            "tests/*.sh:bash",
1352            "--extend-per-file-shell",
1353            "!src/*.sh:zsh",
1354        ]);
1355
1356        assert_eq!(
1357            command.rule_selection.per_file_shell,
1358            Some(vec![PatternShellPair {
1359                pattern: "tests/*.sh".to_owned(),
1360                shell: shuck_linter::ShellDialect::Bash,
1361            }])
1362        );
1363        assert_eq!(
1364            command.rule_selection.extend_per_file_shell,
1365            vec![PatternShellPair {
1366                pattern: "!src/*.sh".to_owned(),
1367                shell: shuck_linter::ShellDialect::Zsh,
1368            }]
1369        );
1370    }
1371
1372    #[test]
1373    fn rejects_empty_cli_rule_selectors() {
1374        let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
1375
1376        assert_eq!(error.kind(), ErrorKind::ValueValidation);
1377    }
1378
1379    #[test]
1380    fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1381        let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1382
1383        assert_eq!(error.kind(), ErrorKind::ValueValidation);
1384    }
1385
1386    #[test]
1387    fn rejects_add_noqa_alias() {
1388        let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1389
1390        assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1391    }
1392
1393    #[test]
1394    fn rejects_add_ignore_with_fix_flags() {
1395        let error =
1396            StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1397
1398        assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1399    }
1400
1401    #[test]
1402    fn rejects_watch_with_add_ignore() {
1403        let error =
1404            StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1405
1406        assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1407    }
1408
1409    #[test]
1410    fn check_file_selection_negative_flags_override_positive_flags() {
1411        let args = Args::try_parse_from([
1412            "shuck",
1413            "check",
1414            "--respect-gitignore",
1415            "--no-respect-gitignore",
1416            "--force-exclude",
1417            "--no-force-exclude",
1418        ])
1419        .unwrap();
1420
1421        let Command::Check(command) = args.command else {
1422            panic!("expected check command");
1423        };
1424
1425        assert!(!command.respect_gitignore());
1426        assert!(!command.force_exclude());
1427    }
1428
1429    #[test]
1430    fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1431        let args = Args::try_parse_from([
1432            "shuck",
1433            "check",
1434            "--exclude",
1435            "base.sh",
1436            "--extend-exclude",
1437            "extra.sh",
1438        ])
1439        .unwrap();
1440
1441        let Command::Check(command) = args.command else {
1442            panic!("expected check command");
1443        };
1444
1445        assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1446        assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1447    }
1448}