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