Skip to main content

shuck/
args.rs

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