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