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