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 crate::config::{ConfigArgumentParser, ConfigArguments, SingleConfigArgument};
16use crate::format_settings::FormatSettingsPatch;
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#[derive(Debug, Parser)]
107#[command(name = "shuck")]
108#[command(about = "Shell checker CLI for shuck")]
109#[command(styles = STYLES)]
110struct StableCli {
111    #[command(flatten)]
112    global: GlobalArgs,
113    #[command(subcommand)]
114    command: StableCommand,
115}
116
117#[derive(Debug, Parser)]
118#[command(name = "shuck")]
119#[command(about = "Shell checker CLI for shuck")]
120#[command(styles = STYLES)]
121struct ExperimentalCli {
122    #[command(flatten)]
123    global: GlobalArgs,
124    #[command(subcommand)]
125    command: ExperimentalCommand,
126}
127
128#[derive(Debug, Clone, ClapArgs)]
129struct GlobalArgs {
130    /// Either a path to a TOML configuration file (`shuck.toml`), or a TOML
131    /// `<KEY> = <VALUE>` pair (such as you might find in a `shuck.toml`
132    /// configuration file) overriding a specific configuration option.
133    /// Overrides of individual settings using this option always take
134    /// precedence over all configuration files, including configuration files
135    /// that were also specified using `--config`.
136    #[arg(
137        long,
138        action = clap::ArgAction::Append,
139        value_name = "CONFIG_OPTION",
140        value_parser = ConfigArgumentParser,
141        global = true,
142        help_heading = "Global options"
143    )]
144    config: Vec<SingleConfigArgument>,
145    /// Ignore all configuration files.
146    #[arg(long, global = true, help_heading = "Global options")]
147    isolated: bool,
148    /// Control when colored output is used.
149    #[arg(
150        long,
151        value_enum,
152        value_name = "WHEN",
153        global = true,
154        help_heading = "Global options"
155    )]
156    color: Option<TerminalColor>,
157    /// Path to the cache directory.
158    #[arg(
159        long,
160        env = "SHUCK_CACHE_DIR",
161        global = true,
162        value_name = "PATH",
163        help_heading = "Miscellaneous"
164    )]
165    cache_dir: Option<PathBuf>,
166}
167
168#[derive(Debug, Subcommand)]
169enum StableCommand {
170    /// Parse shell files and report syntax failures.
171    Check(CheckCommand),
172    #[command(hide = true)]
173    Format(FormatCommand),
174    /// Remove shuck cache entries for the provided paths' projects.
175    Clean(CleanCommand),
176}
177
178#[derive(Debug, Subcommand)]
179enum ExperimentalCommand {
180    /// Parse shell files and report syntax failures.
181    Check(CheckCommand),
182    /// Format shell files.
183    Format(FormatCommand),
184    /// Remove shuck cache entries for the provided paths' projects.
185    Clean(CleanCommand),
186}
187
188/// Parsed top-level arguments for the `shuck` command.
189#[derive(Debug, Clone)]
190pub struct Args {
191    /// Override for the cache root directory.
192    pub cache_dir: Option<PathBuf>,
193    pub(crate) config: ConfigArguments,
194    pub(crate) color: Option<TerminalColor>,
195    /// The subcommand selected by the user.
196    pub command: Command,
197}
198
199impl Args {
200    /// Parse arguments from the current process and exit on invalid input.
201    pub fn parse() -> Self {
202        Self::try_parse().unwrap_or_else(|err| err.exit())
203    }
204
205    /// Parse arguments from the current process without exiting on errors.
206    pub fn try_parse() -> Result<Self, clap::Error> {
207        Self::try_parse_from(std::env::args_os())
208    }
209
210    /// Parse arguments from an arbitrary iterator of command-line values.
211    pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
212    where
213        I: IntoIterator<Item = T>,
214        T: Into<OsString> + Clone,
215    {
216        if experimental_enabled() {
217            let parsed = parse_with_color::<ExperimentalCli, _, _>(itr)?;
218            Self::from_experimental(parsed)
219        } else {
220            let parsed = parse_with_color::<StableCli, _, _>(itr)?;
221            Self::from_stable(parsed)
222        }
223    }
224}
225
226impl Args {
227    fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
228        let StableCli { global, command } = value;
229        let GlobalArgs {
230            cache_dir,
231            config,
232            isolated,
233            color,
234        } = global;
235        let command = match command {
236            StableCommand::Check(command) => Command::Check(command),
237            StableCommand::Format(_) => {
238                return Err(clap::Error::raw(
239                    ErrorKind::InvalidSubcommand,
240                    format!(
241                        "the `format` subcommand is experimental; set {EXPERIMENTAL_ENV_VAR}=1 to enable it"
242                    ),
243                ));
244            }
245            StableCommand::Clean(command) => Command::Clean(command),
246        };
247
248        Ok(Self {
249            cache_dir,
250            config: ConfigArguments::from_cli(config, isolated)?,
251            color,
252            command,
253        })
254    }
255
256    fn from_experimental(value: ExperimentalCli) -> Result<Self, clap::Error> {
257        let ExperimentalCli { global, command } = value;
258        let GlobalArgs {
259            cache_dir,
260            config,
261            isolated,
262            color,
263        } = global;
264        let command = match command {
265            ExperimentalCommand::Check(command) => Command::Check(command),
266            ExperimentalCommand::Format(command) => Command::Format(command),
267            ExperimentalCommand::Clean(command) => Command::Clean(command),
268        };
269
270        Ok(Self {
271            cache_dir,
272            config: ConfigArguments::from_cli(config, isolated)?,
273            color,
274            command,
275        })
276    }
277}
278
279/// Supported `shuck` subcommands.
280#[derive(Debug, Clone, Subcommand)]
281pub enum Command {
282    /// Parse shell files and report syntax failures.
283    Check(CheckCommand),
284    /// Format shell files.
285    Format(FormatCommand),
286    /// Remove shuck cache entries for the provided paths' projects.
287    Clean(CleanCommand),
288}
289
290fn experimental_enabled() -> bool {
291    std::env::var_os(EXPERIMENTAL_ENV_VAR).is_some_and(|value| {
292        !matches!(
293            value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
294            "" | "0" | "false" | "no" | "off"
295        )
296    })
297}
298
299/// Arguments for `shuck check`.
300#[derive(Debug, Clone, ClapArgs)]
301pub struct CheckCommand {
302    /// Apply safe fixes.
303    #[arg(long)]
304    pub fix: bool,
305    /// Apply unsafe fixes.
306    #[arg(long = "unsafe-fixes")]
307    pub unsafe_fixes: bool,
308    /// Enable automatic additions of shuck ignore directives to failing lines.
309    /// Optionally provide a reason to append after the codes.
310    #[arg(
311        long = "add-ignore",
312        value_name = "REASON",
313        default_missing_value = "",
314        num_args = 0..=1,
315        require_equals = true,
316        conflicts_with = "fix",
317        conflicts_with = "unsafe_fixes",
318    )]
319    pub add_ignore: Option<String>,
320    /// Output serialization format for violations.
321    /// The default serialization format is "full".
322    #[arg(
323        long = "output-format",
324        value_enum,
325        env = "SHUCK_OUTPUT_FORMAT",
326        default_value_t = CheckOutputFormatArg::Full
327    )]
328    pub output_format: CheckOutputFormatArg,
329    /// Run in watch mode by re-running whenever files change.
330    #[arg(short = 'w', long, conflicts_with = "add_ignore")]
331    pub watch: bool,
332    /// Files or directories to check.
333    pub paths: Vec<PathBuf>,
334    /// Rule selection and suppression settings.
335    #[command(flatten)]
336    pub rule_selection: RuleSelectionArgs,
337    /// File discovery and exclusion settings.
338    #[command(flatten)]
339    pub file_selection: FileSelectionArgs,
340    /// Disable cache reads and writes.
341    #[arg(long = "no-cache", help_heading = "Miscellaneous")]
342    pub no_cache: bool,
343    /// Exit with status code "0", even upon detecting lint violations. Parse errors and error-severity diagnostics still fail.
344    #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
345    pub exit_zero: bool,
346    /// Exit with a non-zero status code if any files were modified via fix, even if no lint violations remain.
347    #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
348    pub exit_non_zero_on_fix: bool,
349}
350
351impl CheckCommand {
352    /// Whether standard ignore files such as `.gitignore` should be respected.
353    pub fn respect_gitignore(&self) -> bool {
354        self.file_selection.respect_gitignore()
355    }
356
357    /// Whether excludes should also apply to explicitly passed paths.
358    pub fn force_exclude(&self) -> bool {
359        self.file_selection.force_exclude()
360    }
361}
362
363/// A `<pattern>:<rule-selector>` mapping from the CLI.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct PatternRuleSelectorPair {
366    /// Glob-style file pattern.
367    pub pattern: String,
368    /// Rule selector applied to matching files.
369    pub selector: RuleSelector,
370}
371
372impl std::str::FromStr for PatternRuleSelectorPair {
373    type Err = String;
374
375    fn from_str(value: &str) -> Result<Self, Self::Err> {
376        let (pattern, selector) = value
377            .rsplit_once(':')
378            .ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
379        let pattern = pattern.trim();
380        let selector = selector.trim();
381
382        if pattern.is_empty() || selector.is_empty() {
383            return Err("expected <FilePattern>:<RuleCode>".to_owned());
384        }
385
386        Ok(Self {
387            pattern: pattern.to_owned(),
388            selector: parse_cli_rule_selector(selector)?,
389        })
390    }
391}
392
393fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
394    let value = value.trim();
395    if value.is_empty() {
396        return Err("rule selector cannot be empty".to_owned());
397    }
398
399    value.parse::<RuleSelector>().map_err(|err| err.to_string())
400}
401
402/// Rule-selection flags shared by `shuck check`.
403#[derive(Debug, Clone, Default, ClapArgs)]
404pub struct RuleSelectionArgs {
405    /// Comma-separated list of rule codes to enable (or ALL, to enable all rules).
406    #[arg(
407        long,
408        value_delimiter = ',',
409        value_parser = parse_cli_rule_selector,
410        value_name = "RULE_CODE",
411        help_heading = "Rule selection",
412        hide_possible_values = true
413    )]
414    pub select: Option<Vec<RuleSelector>>,
415    /// Comma-separated list of rule codes to disable.
416    #[arg(
417        long,
418        value_delimiter = ',',
419        value_parser = parse_cli_rule_selector,
420        value_name = "RULE_CODE",
421        help_heading = "Rule selection",
422        hide_possible_values = true
423    )]
424    pub ignore: Vec<RuleSelector>,
425    /// Like --select, but adds additional rule codes on top of those already specified.
426    #[arg(
427        long,
428        value_delimiter = ',',
429        value_parser = parse_cli_rule_selector,
430        value_name = "RULE_CODE",
431        help_heading = "Rule selection",
432        hide_possible_values = true
433    )]
434    pub extend_select: Vec<RuleSelector>,
435    /// List of mappings from file pattern to code to exclude.
436    #[arg(
437        long,
438        value_delimiter = ',',
439        value_name = "PER_FILE_IGNORES",
440        help_heading = "Rule selection"
441    )]
442    pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
443    /// Like `--per-file-ignores`, but adds additional ignores on top of those already specified.
444    #[arg(
445        long,
446        value_delimiter = ',',
447        value_name = "EXTEND_PER_FILE_IGNORES",
448        help_heading = "Rule selection"
449    )]
450    pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
451    /// List of rule codes to treat as eligible for fix. Only applicable when fix itself is enabled (e.g., via `--fix`).
452    #[arg(
453        long,
454        value_delimiter = ',',
455        value_parser = parse_cli_rule_selector,
456        value_name = "RULE_CODE",
457        help_heading = "Rule selection",
458        hide_possible_values = true
459    )]
460    pub fixable: Option<Vec<RuleSelector>>,
461    /// List of rule codes to treat as ineligible for fix. Only applicable when fix itself is enabled (e.g., via `--fix`).
462    #[arg(
463        long,
464        value_delimiter = ',',
465        value_parser = parse_cli_rule_selector,
466        value_name = "RULE_CODE",
467        help_heading = "Rule selection",
468        hide_possible_values = true
469    )]
470    pub unfixable: Vec<RuleSelector>,
471    /// Like --fixable, but adds additional rule codes on top of those already specified.
472    #[arg(
473        long,
474        value_delimiter = ',',
475        value_parser = parse_cli_rule_selector,
476        value_name = "RULE_CODE",
477        help_heading = "Rule selection",
478        hide_possible_values = true
479    )]
480    pub extend_fixable: Vec<RuleSelector>,
481}
482
483fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
484where
485    Cli: CommandFactory + FromArgMatches,
486    I: IntoIterator<Item = T>,
487    T: Into<OsString> + Clone,
488{
489    let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
490    let mut command = Cli::command().color(command_color_choice(&args));
491    let matches = command.try_get_matches_from_mut(args)?;
492    Cli::from_arg_matches(&matches)
493}
494
495fn command_color_choice(args: &[OsString]) -> ColorChoice {
496    match preparse_color(args) {
497        Some(ColorChoice::Always) => ColorChoice::Always,
498        Some(ColorChoice::Never) => ColorChoice::Never,
499        Some(ColorChoice::Auto) | None => {
500            if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
501                ColorChoice::Always
502            } else {
503                ColorChoice::Auto
504            }
505        }
506    }
507}
508
509fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
510    let mut expect_value = false;
511    let mut color = None;
512
513    for argument in args.iter().skip(1) {
514        if expect_value {
515            let value = argument.to_string_lossy();
516            color = value.parse().ok();
517            expect_value = false;
518            continue;
519        }
520
521        let argument = argument.to_string_lossy();
522        if argument == "--" {
523            break;
524        }
525        if argument == "--color" {
526            expect_value = true;
527            continue;
528        }
529        if let Some(value) = argument.strip_prefix("--color=") {
530            color = value.parse().ok();
531        }
532    }
533
534    color
535}
536
537/// File-discovery and exclusion flags shared by multiple commands.
538#[derive(Debug, Clone, Default, ClapArgs)]
539pub struct FileSelectionArgs {
540    /// List of paths, used to omit files and/or directories from analysis.
541    #[arg(
542        long,
543        value_delimiter = ',',
544        value_name = "FILE_PATTERN",
545        help_heading = "File selection"
546    )]
547    pub exclude: Vec<String>,
548    /// Like --exclude, but adds additional files and directories on top of those already excluded.
549    #[arg(
550        long,
551        value_delimiter = ',',
552        value_name = "FILE_PATTERN",
553        help_heading = "File selection"
554    )]
555    pub extend_exclude: Vec<String>,
556    /// Respect file exclusions via `.gitignore` and other standard ignore files.
557    /// Use `--no-respect-gitignore` to disable.
558    #[arg(
559        long,
560        overrides_with = "no_respect_gitignore",
561        help_heading = "File selection"
562    )]
563    pub(crate) respect_gitignore: bool,
564    #[arg(long, overrides_with = "respect_gitignore", hide = true)]
565    pub(crate) no_respect_gitignore: bool,
566    /// Enforce exclusions, even for paths passed to shuck directly on the command-line.
567    /// Use `--no-force-exclude` to disable.
568    #[arg(
569        long,
570        overrides_with = "no_force_exclude",
571        help_heading = "File selection"
572    )]
573    pub(crate) force_exclude: bool,
574    #[arg(long, overrides_with = "force_exclude", hide = true)]
575    pub(crate) no_force_exclude: bool,
576}
577
578impl FileSelectionArgs {
579    /// Resolve the effective `respect_gitignore` setting after CLI overrides.
580    pub fn respect_gitignore(&self) -> bool {
581        resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
582    }
583
584    /// Resolve the effective `force_exclude` setting after CLI overrides.
585    pub fn force_exclude(&self) -> bool {
586        resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
587    }
588}
589
590/// Arguments for `shuck format`.
591#[derive(Debug, Clone, ClapArgs)]
592pub struct FormatCommand {
593    /// List of files or directories to format, or `-` to read from stdin.
594    pub files: Vec<PathBuf>,
595    /// Avoid writing any formatted files back; instead, exit non-zero if any files would change.
596    #[arg(long)]
597    pub check: bool,
598    /// Avoid writing any formatted files back; instead, print a diff for each changed file.
599    #[arg(long)]
600    pub diff: bool,
601    /// Disable cache reads and writes.
602    #[arg(long = "no-cache")]
603    pub no_cache: bool,
604    /// The name of the file when reading the source from stdin.
605    #[arg(long)]
606    pub stdin_filename: Option<PathBuf>,
607    /// File discovery and exclusion settings.
608    #[command(flatten)]
609    pub file_selection: FileSelectionArgs,
610    /// Override the auto-discovered shell dialect used for parsing and formatting.
611    #[arg(long, value_enum)]
612    pub dialect: Option<FormatDialectArg>,
613    /// Choose the indentation style.
614    #[arg(long, value_enum)]
615    pub indent_style: Option<FormatIndentStyleArg>,
616    /// Set the indentation width for space indentation.
617    #[arg(long, value_name = "WIDTH")]
618    pub indent_width: Option<u8>,
619    /// Put binary operators on the next line when breaking lists and pipelines.
620    #[arg(long, overrides_with = "no_binary_next_line")]
621    pub(crate) binary_next_line: bool,
622    #[arg(
623        long = "no-binary-next-line",
624        overrides_with = "binary_next_line",
625        hide = true
626    )]
627    pub(crate) no_binary_next_line: bool,
628    /// Indent the bodies of `case` branches.
629    #[arg(long, overrides_with = "no_switch_case_indent")]
630    pub(crate) switch_case_indent: bool,
631    #[arg(
632        long = "no-switch-case-indent",
633        overrides_with = "switch_case_indent",
634        hide = true
635    )]
636    pub(crate) no_switch_case_indent: bool,
637    /// Insert spaces around redirection operators and targets.
638    #[arg(long, overrides_with = "no_space_redirects")]
639    pub(crate) space_redirects: bool,
640    #[arg(
641        long = "no-space-redirects",
642        overrides_with = "space_redirects",
643        hide = true
644    )]
645    pub(crate) no_space_redirects: bool,
646    /// Preserve source padding when it is safe to do so.
647    #[arg(long, overrides_with = "no_keep_padding")]
648    pub(crate) keep_padding: bool,
649    #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
650    pub(crate) no_keep_padding: bool,
651    /// Put function opening braces on the next line.
652    #[arg(long, overrides_with = "no_function_next_line")]
653    pub(crate) function_next_line: bool,
654    #[arg(
655        long = "no-function-next-line",
656        overrides_with = "function_next_line",
657        hide = true
658    )]
659    pub(crate) no_function_next_line: bool,
660    /// Prefer compact layouts and avoid optional splitting.
661    #[arg(long, overrides_with = "no_never_split")]
662    pub(crate) never_split: bool,
663    #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
664    pub(crate) no_never_split: bool,
665    /// Apply safe simplifications before formatting.
666    #[arg(long)]
667    pub simplify: bool,
668    /// Emit a compact minified form and drop comments.
669    #[arg(long)]
670    pub minify: bool,
671}
672
673impl FormatCommand {
674    pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
675        FormatSettingsPatch {
676            dialect: self.dialect.map(Into::into),
677            indent_style: self.indent_style.map(Into::into),
678            indent_width: self.indent_width,
679            binary_next_line: self.binary_next_line(),
680            switch_case_indent: self.switch_case_indent(),
681            space_redirects: self.space_redirects(),
682            keep_padding: self.keep_padding(),
683            function_next_line: self.function_next_line(),
684            never_split: self.never_split(),
685            simplify: self.simplify.then_some(true),
686            minify: self.minify.then_some(true),
687        }
688    }
689
690    /// Resolve the effective `binary-next-line` formatter option.
691    pub fn binary_next_line(&self) -> Option<bool> {
692        tri_state_bool(self.binary_next_line, self.no_binary_next_line)
693    }
694
695    /// Resolve the effective `switch-case-indent` formatter option.
696    pub fn switch_case_indent(&self) -> Option<bool> {
697        tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
698    }
699
700    /// Resolve the effective `space-redirects` formatter option.
701    pub fn space_redirects(&self) -> Option<bool> {
702        tri_state_bool(self.space_redirects, self.no_space_redirects)
703    }
704
705    /// Resolve the effective `keep-padding` formatter option.
706    pub fn keep_padding(&self) -> Option<bool> {
707        tri_state_bool(self.keep_padding, self.no_keep_padding)
708    }
709
710    /// Resolve the effective `function-next-line` formatter option.
711    pub fn function_next_line(&self) -> Option<bool> {
712        tri_state_bool(self.function_next_line, self.no_function_next_line)
713    }
714
715    /// Resolve the effective `never-split` formatter option.
716    pub fn never_split(&self) -> Option<bool> {
717        tri_state_bool(self.never_split, self.no_never_split)
718    }
719
720    /// Whether standard ignore files such as `.gitignore` should be respected.
721    pub fn respect_gitignore(&self) -> bool {
722        self.file_selection.respect_gitignore()
723    }
724
725    /// Whether excludes should also apply to explicitly passed paths.
726    pub fn force_exclude(&self) -> bool {
727        self.file_selection.force_exclude()
728    }
729}
730
731fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
732    match (positive, negative) {
733        (false, false) => None,
734        (true, false) => Some(true),
735        (false, true) => Some(false),
736        // The caller wires every positive/negative flag pair with
737        // `overrides_with`, so clap normalizes repeated input down to at most
738        // one active boolean before we derive the tri-state value.
739        (true, true) => unreachable!("clap should make this impossible"),
740    }
741}
742
743fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
744    match (positive, negative) {
745        (false, false) => default,
746        (true, false) => true,
747        (false, true) => false,
748        // Clap's `overrides_with` on these paired flags keeps only the
749        // last occurrence, so both booleans cannot remain set here.
750        (true, true) => unreachable!("clap should make this impossible"),
751    }
752}
753
754/// Arguments for `shuck clean`.
755#[derive(Debug, Clone, ClapArgs)]
756pub struct CleanCommand {
757    /// Files or directories whose project caches should be removed.
758    pub paths: Vec<PathBuf>,
759}
760
761#[cfg(test)]
762mod tests {
763    use super::*;
764    use clap::builder::TypedValueParser;
765    use shuck_linter::Rule;
766
767    #[test]
768    fn global_config_override_is_available_after_subcommand() {
769        let command = StableCli::command();
770        let override_argument = crate::config::ConfigArgumentParser
771            .parse_ref(
772                &command,
773                None,
774                std::ffi::OsStr::new("format.indent-width = 2"),
775            )
776            .unwrap();
777
778        let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
779            .unwrap();
780
781        assert_eq!(
782            args.config,
783            ConfigArguments::from_cli(vec![override_argument], false).unwrap()
784        );
785    }
786
787    #[test]
788    fn explicit_config_file_and_inline_override_both_parse_globally() {
789        let tempdir = tempfile::tempdir().unwrap();
790        let config_path = tempdir.path().join("shuck.toml");
791        std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
792        let command = StableCli::command();
793        let override_argument = crate::config::ConfigArgumentParser
794            .parse_ref(
795                &command,
796                None,
797                std::ffi::OsStr::new("format.function-next-line = true"),
798            )
799            .unwrap();
800
801        let args = Args::try_parse_from([
802            "shuck",
803            "--config",
804            config_path.to_str().unwrap(),
805            "--config",
806            "format.function-next-line = true",
807            "check",
808        ])
809        .unwrap();
810
811        assert_eq!(
812            args.config,
813            ConfigArguments::from_cli(
814                vec![
815                    SingleConfigArgument::FilePath(config_path),
816                    override_argument
817                ],
818                false,
819            )
820            .unwrap()
821        );
822    }
823
824    #[test]
825    fn global_color_can_be_parsed_before_subcommand() {
826        let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
827        assert_eq!(args.color, Some(TerminalColor::Never));
828    }
829
830    #[test]
831    fn preparse_color_uses_last_value() {
832        assert_eq!(
833            preparse_color(&[
834                OsString::from("shuck"),
835                OsString::from("--color=always"),
836                OsString::from("--color"),
837                OsString::from("never"),
838            ]),
839            Some(ColorChoice::Never)
840        );
841    }
842
843    fn parse_check<I, T>(args: I) -> CheckCommand
844    where
845        I: IntoIterator<Item = T>,
846        T: Into<OsString> + Clone,
847    {
848        let parsed = StableCli::try_parse_from(args).unwrap();
849        match Args::from_stable(parsed).unwrap().command {
850            Command::Check(command) => command,
851            command => panic!("expected check command, got {command:?}"),
852        }
853    }
854
855    #[test]
856    fn parses_add_ignore_without_reason() {
857        let command = parse_check(["shuck", "check", "--add-ignore"]);
858
859        assert_eq!(command.add_ignore, Some(String::new()));
860    }
861
862    #[test]
863    fn parses_add_ignore_with_reason() {
864        let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
865
866        assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
867    }
868
869    #[test]
870    fn parses_short_watch_flag() {
871        let command = parse_check(["shuck", "check", "-w"]);
872
873        assert!(command.watch);
874    }
875
876    #[test]
877    fn parses_long_watch_flag() {
878        let command = parse_check(["shuck", "check", "--watch"]);
879
880        assert!(command.watch);
881    }
882
883    #[test]
884    fn parses_all_check_output_formats() {
885        for (raw, expected) in [
886            ("concise", CheckOutputFormatArg::Concise),
887            ("full", CheckOutputFormatArg::Full),
888            ("json", CheckOutputFormatArg::Json),
889            ("json-lines", CheckOutputFormatArg::JsonLines),
890            ("junit", CheckOutputFormatArg::Junit),
891            ("grouped", CheckOutputFormatArg::Grouped),
892            ("github", CheckOutputFormatArg::Github),
893            ("gitlab", CheckOutputFormatArg::Gitlab),
894            ("rdjson", CheckOutputFormatArg::Rdjson),
895            ("sarif", CheckOutputFormatArg::Sarif),
896        ] {
897            let command = parse_check(["shuck", "check", "--output-format", raw]);
898            assert_eq!(command.output_format, expected, "failed to parse {raw}");
899        }
900    }
901
902    #[test]
903    fn parses_rule_selection_flags() {
904        let command = parse_check([
905            "shuck",
906            "check",
907            "--select",
908            "C001",
909            "--select",
910            "S,C002",
911            "--ignore",
912            "C003,C004",
913            "--extend-select",
914            "X",
915            "--fixable",
916            "ALL",
917            "--unfixable",
918            "C001",
919            "--extend-fixable",
920            "S074",
921        ]);
922
923        assert_eq!(
924            command.rule_selection.select,
925            Some(vec![
926                RuleSelector::Rule(Rule::UnusedAssignment),
927                RuleSelector::Category(shuck_linter::Category::Style),
928                RuleSelector::Rule(Rule::DynamicSourcePath),
929            ])
930        );
931        assert_eq!(
932            command.rule_selection.ignore,
933            vec![
934                RuleSelector::Rule(Rule::UntrackedSourceFile),
935                RuleSelector::Rule(Rule::UncheckedDirectoryChange),
936            ]
937        );
938        assert_eq!(
939            command.rule_selection.extend_select,
940            vec![RuleSelector::Category(shuck_linter::Category::Portability)]
941        );
942        assert_eq!(
943            command.rule_selection.fixable,
944            Some(vec![RuleSelector::All])
945        );
946        assert_eq!(
947            command.rule_selection.unfixable,
948            vec![RuleSelector::Rule(Rule::UnusedAssignment)]
949        );
950        assert_eq!(
951            command.rule_selection.extend_fixable,
952            vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
953        );
954    }
955
956    #[test]
957    fn parses_per_file_ignore_pairs() {
958        let command = parse_check([
959            "shuck",
960            "check",
961            "--per-file-ignores",
962            "tests/*.sh:C001",
963            "--extend-per-file-ignores",
964            "!src/*.sh:S",
965        ]);
966
967        assert_eq!(
968            command.rule_selection.per_file_ignores,
969            Some(vec![PatternRuleSelectorPair {
970                pattern: "tests/*.sh".to_owned(),
971                selector: RuleSelector::Rule(Rule::UnusedAssignment),
972            }])
973        );
974        assert_eq!(
975            command.rule_selection.extend_per_file_ignores,
976            vec![PatternRuleSelectorPair {
977                pattern: "!src/*.sh".to_owned(),
978                selector: RuleSelector::Category(shuck_linter::Category::Style),
979            }]
980        );
981    }
982
983    #[test]
984    fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
985        let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
986
987        assert_eq!(
988            command.rule_selection.per_file_ignores,
989            Some(vec![PatternRuleSelectorPair {
990                pattern: r"C:\repo\*.sh".to_owned(),
991                selector: RuleSelector::Rule(Rule::UnusedAssignment),
992            }])
993        );
994    }
995
996    #[test]
997    fn rejects_empty_cli_rule_selectors() {
998        let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
999
1000        assert_eq!(error.kind(), ErrorKind::ValueValidation);
1001    }
1002
1003    #[test]
1004    fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1005        let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1006
1007        assert_eq!(error.kind(), ErrorKind::ValueValidation);
1008    }
1009
1010    #[test]
1011    fn rejects_add_noqa_alias() {
1012        let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1013
1014        assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1015    }
1016
1017    #[test]
1018    fn rejects_add_ignore_with_fix_flags() {
1019        let error =
1020            StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1021
1022        assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1023    }
1024
1025    #[test]
1026    fn rejects_watch_with_add_ignore() {
1027        let error =
1028            StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1029
1030        assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1031    }
1032
1033    #[test]
1034    fn check_file_selection_negative_flags_override_positive_flags() {
1035        let args = Args::try_parse_from([
1036            "shuck",
1037            "check",
1038            "--respect-gitignore",
1039            "--no-respect-gitignore",
1040            "--force-exclude",
1041            "--no-force-exclude",
1042        ])
1043        .unwrap();
1044
1045        let Command::Check(command) = args.command else {
1046            panic!("expected check command");
1047        };
1048
1049        assert!(!command.respect_gitignore());
1050        assert!(!command.force_exclude());
1051    }
1052
1053    #[test]
1054    fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1055        let args = Args::try_parse_from([
1056            "shuck",
1057            "check",
1058            "--exclude",
1059            "base.sh",
1060            "--extend-exclude",
1061            "extra.sh",
1062        ])
1063        .unwrap();
1064
1065        let Command::Check(command) = args.command else {
1066            panic!("expected check command");
1067        };
1068
1069        assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1070        assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1071    }
1072}