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