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    /// Lint shell files and supported embedded shell scripts.
171    Check(Box<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    /// Lint shell files and supported embedded shell scripts.
181    Check(Box<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    /// Lint shell files and supported embedded shell scripts.
283    Check(Box<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
393/// A `<pattern>:<shell>` mapping from the CLI.
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct PatternShellPair {
396    /// Glob-style file pattern.
397    pub pattern: String,
398    /// Shell dialect applied to matching files.
399    pub shell: shuck_linter::ShellDialect,
400}
401
402impl std::str::FromStr for PatternShellPair {
403    type Err = String;
404
405    fn from_str(value: &str) -> Result<Self, Self::Err> {
406        let (pattern, shell) = value
407            .rsplit_once(':')
408            .ok_or_else(|| "expected <FilePattern>:<Shell>".to_owned())?;
409        let pattern = pattern.trim();
410        let shell = shell.trim();
411
412        if pattern.is_empty() || shell.is_empty() {
413            return Err("expected <FilePattern>:<Shell>".to_owned());
414        }
415
416        let shell = shuck_linter::ShellDialect::from_name(shell);
417        if shell == shuck_linter::ShellDialect::Unknown {
418            return Err(
419                "expected shell dialect to be one of sh, bash, dash, ksh, mksh, zsh".to_owned(),
420            );
421        }
422
423        Ok(Self {
424            pattern: pattern.to_owned(),
425            shell,
426        })
427    }
428}
429
430fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
431    let value = value.trim();
432    if value.is_empty() {
433        return Err("rule selector cannot be empty".to_owned());
434    }
435
436    value.parse::<RuleSelector>().map_err(|err| err.to_string())
437}
438
439/// Rule-selection flags shared by `shuck check`.
440#[derive(Debug, Clone, Default, ClapArgs)]
441pub struct RuleSelectionArgs {
442    /// Comma-separated list of rule codes to enable (or ALL, to enable all rules).
443    #[arg(
444        long,
445        value_delimiter = ',',
446        value_parser = parse_cli_rule_selector,
447        value_name = "RULE_CODE",
448        help_heading = "Rule selection",
449        hide_possible_values = true
450    )]
451    pub select: Option<Vec<RuleSelector>>,
452    /// Comma-separated list of rule codes to disable.
453    #[arg(
454        long,
455        value_delimiter = ',',
456        value_parser = parse_cli_rule_selector,
457        value_name = "RULE_CODE",
458        help_heading = "Rule selection",
459        hide_possible_values = true
460    )]
461    pub ignore: Vec<RuleSelector>,
462    /// Like --select, but adds additional rule codes on top of those already specified.
463    #[arg(
464        long,
465        value_delimiter = ',',
466        value_parser = parse_cli_rule_selector,
467        value_name = "RULE_CODE",
468        help_heading = "Rule selection",
469        hide_possible_values = true
470    )]
471    pub extend_select: Vec<RuleSelector>,
472    /// List of mappings from file pattern to code to exclude.
473    #[arg(
474        long,
475        value_delimiter = ',',
476        value_name = "PER_FILE_IGNORES",
477        help_heading = "Rule selection"
478    )]
479    pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
480    /// Like `--per-file-ignores`, but adds additional ignores on top of those already specified.
481    #[arg(
482        long,
483        value_delimiter = ',',
484        value_name = "EXTEND_PER_FILE_IGNORES",
485        help_heading = "Rule selection"
486    )]
487    pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
488    /// List of mappings from file pattern to shell dialect.
489    #[arg(
490        long,
491        value_delimiter = ',',
492        value_name = "PER_FILE_SHELL",
493        help_heading = "Rule selection"
494    )]
495    pub per_file_shell: Option<Vec<PatternShellPair>>,
496    /// Like `--per-file-shell`, but adds additional shell mappings on top of those already specified.
497    #[arg(
498        long,
499        value_delimiter = ',',
500        value_name = "EXTEND_PER_FILE_SHELL",
501        help_heading = "Rule selection"
502    )]
503    pub extend_per_file_shell: Vec<PatternShellPair>,
504    /// List of rule codes to treat as eligible for fix. Only applicable when fix itself is enabled (e.g., via `--fix`).
505    #[arg(
506        long,
507        value_delimiter = ',',
508        value_parser = parse_cli_rule_selector,
509        value_name = "RULE_CODE",
510        help_heading = "Rule selection",
511        hide_possible_values = true
512    )]
513    pub fixable: Option<Vec<RuleSelector>>,
514    /// List of rule codes to treat as ineligible for fix. Only applicable when fix itself is enabled (e.g., via `--fix`).
515    #[arg(
516        long,
517        value_delimiter = ',',
518        value_parser = parse_cli_rule_selector,
519        value_name = "RULE_CODE",
520        help_heading = "Rule selection",
521        hide_possible_values = true
522    )]
523    pub unfixable: Vec<RuleSelector>,
524    /// Like --fixable, but adds additional rule codes on top of those already specified.
525    #[arg(
526        long,
527        value_delimiter = ',',
528        value_parser = parse_cli_rule_selector,
529        value_name = "RULE_CODE",
530        help_heading = "Rule selection",
531        hide_possible_values = true
532    )]
533    pub extend_fixable: Vec<RuleSelector>,
534}
535
536fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
537where
538    Cli: CommandFactory + FromArgMatches,
539    I: IntoIterator<Item = T>,
540    T: Into<OsString> + Clone,
541{
542    let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
543    let mut command = Cli::command().color(command_color_choice(&args));
544    let matches = command.try_get_matches_from_mut(args)?;
545    Cli::from_arg_matches(&matches)
546}
547
548fn command_color_choice(args: &[OsString]) -> ColorChoice {
549    match preparse_color(args) {
550        Some(ColorChoice::Always) => ColorChoice::Always,
551        Some(ColorChoice::Never) => ColorChoice::Never,
552        Some(ColorChoice::Auto) | None => {
553            if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
554                ColorChoice::Always
555            } else {
556                ColorChoice::Auto
557            }
558        }
559    }
560}
561
562fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
563    let mut expect_value = false;
564    let mut color = None;
565
566    for argument in args.iter().skip(1) {
567        if expect_value {
568            let value = argument.to_string_lossy();
569            color = value.parse().ok();
570            expect_value = false;
571            continue;
572        }
573
574        let argument = argument.to_string_lossy();
575        if argument == "--" {
576            break;
577        }
578        if argument == "--color" {
579            expect_value = true;
580            continue;
581        }
582        if let Some(value) = argument.strip_prefix("--color=") {
583            color = value.parse().ok();
584        }
585    }
586
587    color
588}
589
590/// File-discovery and exclusion flags shared by multiple commands.
591#[derive(Debug, Clone, Default, ClapArgs)]
592pub struct FileSelectionArgs {
593    /// List of paths, used to omit files and/or directories from analysis.
594    #[arg(
595        long,
596        value_delimiter = ',',
597        value_name = "FILE_PATTERN",
598        help_heading = "File selection"
599    )]
600    pub exclude: Vec<String>,
601    /// Like --exclude, but adds additional files and directories on top of those already excluded.
602    #[arg(
603        long,
604        value_delimiter = ',',
605        value_name = "FILE_PATTERN",
606        help_heading = "File selection"
607    )]
608    pub extend_exclude: Vec<String>,
609    /// Respect file exclusions via `.gitignore` and other standard ignore files.
610    /// Use `--no-respect-gitignore` to disable.
611    #[arg(
612        long,
613        overrides_with = "no_respect_gitignore",
614        help_heading = "File selection"
615    )]
616    pub(crate) respect_gitignore: bool,
617    #[arg(long, overrides_with = "respect_gitignore", hide = true)]
618    pub(crate) no_respect_gitignore: bool,
619    /// Enforce exclusions, even for paths passed to shuck directly on the command-line.
620    /// Use `--no-force-exclude` to disable.
621    #[arg(
622        long,
623        overrides_with = "no_force_exclude",
624        help_heading = "File selection"
625    )]
626    pub(crate) force_exclude: bool,
627    #[arg(long, overrides_with = "force_exclude", hide = true)]
628    pub(crate) no_force_exclude: bool,
629}
630
631impl FileSelectionArgs {
632    /// Resolve the effective `respect_gitignore` setting after CLI overrides.
633    pub fn respect_gitignore(&self) -> bool {
634        resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
635    }
636
637    /// Resolve the effective `force_exclude` setting after CLI overrides.
638    pub fn force_exclude(&self) -> bool {
639        resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
640    }
641}
642
643/// Arguments for `shuck format`.
644#[derive(Debug, Clone, ClapArgs)]
645pub struct FormatCommand {
646    /// List of files or directories to format, or `-` to read from stdin.
647    pub files: Vec<PathBuf>,
648    /// Avoid writing any formatted files back; instead, exit non-zero if any files would change.
649    #[arg(long)]
650    pub check: bool,
651    /// Avoid writing any formatted files back; instead, print a diff for each changed file.
652    #[arg(long)]
653    pub diff: bool,
654    /// Disable cache reads and writes.
655    #[arg(long = "no-cache")]
656    pub no_cache: bool,
657    /// The name of the file when reading the source from stdin.
658    #[arg(long)]
659    pub stdin_filename: Option<PathBuf>,
660    /// File discovery and exclusion settings.
661    #[command(flatten)]
662    pub file_selection: FileSelectionArgs,
663    /// Override the auto-discovered shell dialect used for parsing and formatting.
664    #[arg(long, value_enum)]
665    pub dialect: Option<FormatDialectArg>,
666    /// Choose the indentation style.
667    #[arg(long, value_enum)]
668    pub indent_style: Option<FormatIndentStyleArg>,
669    /// Set the indentation width for space indentation.
670    #[arg(long, value_name = "WIDTH")]
671    pub indent_width: Option<u8>,
672    /// Put binary operators on the next line when breaking lists and pipelines.
673    #[arg(long, overrides_with = "no_binary_next_line")]
674    pub(crate) binary_next_line: bool,
675    #[arg(
676        long = "no-binary-next-line",
677        overrides_with = "binary_next_line",
678        hide = true
679    )]
680    pub(crate) no_binary_next_line: bool,
681    /// Indent the bodies of `case` branches.
682    #[arg(long, overrides_with = "no_switch_case_indent")]
683    pub(crate) switch_case_indent: bool,
684    #[arg(
685        long = "no-switch-case-indent",
686        overrides_with = "switch_case_indent",
687        hide = true
688    )]
689    pub(crate) no_switch_case_indent: bool,
690    /// Insert spaces around redirection operators and targets.
691    #[arg(long, overrides_with = "no_space_redirects")]
692    pub(crate) space_redirects: bool,
693    #[arg(
694        long = "no-space-redirects",
695        overrides_with = "space_redirects",
696        hide = true
697    )]
698    pub(crate) no_space_redirects: bool,
699    /// Preserve source padding when it is safe to do so.
700    #[arg(long, overrides_with = "no_keep_padding")]
701    pub(crate) keep_padding: bool,
702    #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
703    pub(crate) no_keep_padding: bool,
704    /// Put function opening braces on the next line.
705    #[arg(long, overrides_with = "no_function_next_line")]
706    pub(crate) function_next_line: bool,
707    #[arg(
708        long = "no-function-next-line",
709        overrides_with = "function_next_line",
710        hide = true
711    )]
712    pub(crate) no_function_next_line: bool,
713    /// Prefer compact layouts and avoid optional splitting.
714    #[arg(long, overrides_with = "no_never_split")]
715    pub(crate) never_split: bool,
716    #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
717    pub(crate) no_never_split: bool,
718    /// Apply safe simplifications before formatting.
719    #[arg(long)]
720    pub simplify: bool,
721    /// Emit a compact minified form and drop comments.
722    #[arg(long)]
723    pub minify: bool,
724}
725
726impl FormatCommand {
727    pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
728        FormatSettingsPatch {
729            dialect: self.dialect.map(Into::into),
730            indent_style: self.indent_style.map(Into::into),
731            indent_width: self.indent_width,
732            binary_next_line: self.binary_next_line(),
733            switch_case_indent: self.switch_case_indent(),
734            space_redirects: self.space_redirects(),
735            keep_padding: self.keep_padding(),
736            function_next_line: self.function_next_line(),
737            never_split: self.never_split(),
738            simplify: self.simplify.then_some(true),
739            minify: self.minify.then_some(true),
740        }
741    }
742
743    /// Resolve the effective `binary-next-line` formatter option.
744    pub fn binary_next_line(&self) -> Option<bool> {
745        tri_state_bool(self.binary_next_line, self.no_binary_next_line)
746    }
747
748    /// Resolve the effective `switch-case-indent` formatter option.
749    pub fn switch_case_indent(&self) -> Option<bool> {
750        tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
751    }
752
753    /// Resolve the effective `space-redirects` formatter option.
754    pub fn space_redirects(&self) -> Option<bool> {
755        tri_state_bool(self.space_redirects, self.no_space_redirects)
756    }
757
758    /// Resolve the effective `keep-padding` formatter option.
759    pub fn keep_padding(&self) -> Option<bool> {
760        tri_state_bool(self.keep_padding, self.no_keep_padding)
761    }
762
763    /// Resolve the effective `function-next-line` formatter option.
764    pub fn function_next_line(&self) -> Option<bool> {
765        tri_state_bool(self.function_next_line, self.no_function_next_line)
766    }
767
768    /// Resolve the effective `never-split` formatter option.
769    pub fn never_split(&self) -> Option<bool> {
770        tri_state_bool(self.never_split, self.no_never_split)
771    }
772
773    /// Whether standard ignore files such as `.gitignore` should be respected.
774    pub fn respect_gitignore(&self) -> bool {
775        self.file_selection.respect_gitignore()
776    }
777
778    /// Whether excludes should also apply to explicitly passed paths.
779    pub fn force_exclude(&self) -> bool {
780        self.file_selection.force_exclude()
781    }
782}
783
784fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
785    match (positive, negative) {
786        (false, false) => None,
787        (true, false) => Some(true),
788        (false, true) => Some(false),
789        // The caller wires every positive/negative flag pair with
790        // `overrides_with`, so clap normalizes repeated input down to at most
791        // one active boolean before we derive the tri-state value.
792        (true, true) => unreachable!("clap should make this impossible"),
793    }
794}
795
796fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
797    match (positive, negative) {
798        (false, false) => default,
799        (true, false) => true,
800        (false, true) => false,
801        // Clap's `overrides_with` on these paired flags keeps only the
802        // last occurrence, so both booleans cannot remain set here.
803        (true, true) => unreachable!("clap should make this impossible"),
804    }
805}
806
807/// Arguments for `shuck clean`.
808#[derive(Debug, Clone, ClapArgs)]
809pub struct CleanCommand {
810    /// Files or directories whose project caches should be removed.
811    pub paths: Vec<PathBuf>,
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use clap::builder::TypedValueParser;
818    use shuck_linter::Rule;
819
820    #[test]
821    fn global_config_override_is_available_after_subcommand() {
822        let command = StableCli::command();
823        let override_argument = crate::config::ConfigArgumentParser
824            .parse_ref(
825                &command,
826                None,
827                std::ffi::OsStr::new("format.indent-width = 2"),
828            )
829            .unwrap();
830
831        let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
832            .unwrap();
833
834        assert_eq!(
835            args.config,
836            ConfigArguments::from_cli(vec![override_argument], false).unwrap()
837        );
838    }
839
840    #[test]
841    fn explicit_config_file_and_inline_override_both_parse_globally() {
842        let tempdir = tempfile::tempdir().unwrap();
843        let config_path = tempdir.path().join("shuck.toml");
844        std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
845        let command = StableCli::command();
846        let override_argument = crate::config::ConfigArgumentParser
847            .parse_ref(
848                &command,
849                None,
850                std::ffi::OsStr::new("format.function-next-line = true"),
851            )
852            .unwrap();
853
854        let args = Args::try_parse_from([
855            "shuck",
856            "--config",
857            config_path.to_str().unwrap(),
858            "--config",
859            "format.function-next-line = true",
860            "check",
861        ])
862        .unwrap();
863
864        assert_eq!(
865            args.config,
866            ConfigArguments::from_cli(
867                vec![
868                    SingleConfigArgument::FilePath(config_path),
869                    override_argument
870                ],
871                false,
872            )
873            .unwrap()
874        );
875    }
876
877    #[test]
878    fn global_color_can_be_parsed_before_subcommand() {
879        let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
880        assert_eq!(args.color, Some(TerminalColor::Never));
881    }
882
883    #[test]
884    fn preparse_color_uses_last_value() {
885        assert_eq!(
886            preparse_color(&[
887                OsString::from("shuck"),
888                OsString::from("--color=always"),
889                OsString::from("--color"),
890                OsString::from("never"),
891            ]),
892            Some(ColorChoice::Never)
893        );
894    }
895
896    fn parse_check<I, T>(args: I) -> CheckCommand
897    where
898        I: IntoIterator<Item = T>,
899        T: Into<OsString> + Clone,
900    {
901        let parsed = StableCli::try_parse_from(args).unwrap();
902        match Args::from_stable(parsed).unwrap().command {
903            Command::Check(command) => *command,
904            command => panic!("expected check command, got {command:?}"),
905        }
906    }
907
908    #[test]
909    fn parses_add_ignore_without_reason() {
910        let command = parse_check(["shuck", "check", "--add-ignore"]);
911
912        assert_eq!(command.add_ignore, Some(String::new()));
913    }
914
915    #[test]
916    fn parses_add_ignore_with_reason() {
917        let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
918
919        assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
920    }
921
922    #[test]
923    fn parses_short_watch_flag() {
924        let command = parse_check(["shuck", "check", "-w"]);
925
926        assert!(command.watch);
927    }
928
929    #[test]
930    fn parses_long_watch_flag() {
931        let command = parse_check(["shuck", "check", "--watch"]);
932
933        assert!(command.watch);
934    }
935
936    #[test]
937    fn parses_all_check_output_formats() {
938        for (raw, expected) in [
939            ("concise", CheckOutputFormatArg::Concise),
940            ("full", CheckOutputFormatArg::Full),
941            ("json", CheckOutputFormatArg::Json),
942            ("json-lines", CheckOutputFormatArg::JsonLines),
943            ("junit", CheckOutputFormatArg::Junit),
944            ("grouped", CheckOutputFormatArg::Grouped),
945            ("github", CheckOutputFormatArg::Github),
946            ("gitlab", CheckOutputFormatArg::Gitlab),
947            ("rdjson", CheckOutputFormatArg::Rdjson),
948            ("sarif", CheckOutputFormatArg::Sarif),
949        ] {
950            let command = parse_check(["shuck", "check", "--output-format", raw]);
951            assert_eq!(command.output_format, expected, "failed to parse {raw}");
952        }
953    }
954
955    #[test]
956    fn parses_rule_selection_flags() {
957        let command = parse_check([
958            "shuck",
959            "check",
960            "--select",
961            "C001",
962            "--select",
963            "S,C002",
964            "--ignore",
965            "C003,C004",
966            "--extend-select",
967            "X",
968            "--fixable",
969            "ALL",
970            "--unfixable",
971            "C001",
972            "--extend-fixable",
973            "S074",
974        ]);
975
976        assert_eq!(
977            command.rule_selection.select,
978            Some(vec![
979                RuleSelector::Rule(Rule::UnusedAssignment),
980                RuleSelector::Category(shuck_linter::Category::Style),
981                RuleSelector::Rule(Rule::DynamicSourcePath),
982            ])
983        );
984        assert_eq!(
985            command.rule_selection.ignore,
986            vec![
987                RuleSelector::Rule(Rule::UntrackedSourceFile),
988                RuleSelector::Rule(Rule::UncheckedDirectoryChange),
989            ]
990        );
991        assert_eq!(
992            command.rule_selection.extend_select,
993            vec![RuleSelector::Category(shuck_linter::Category::Portability)]
994        );
995        assert_eq!(
996            command.rule_selection.fixable,
997            Some(vec![RuleSelector::All])
998        );
999        assert_eq!(
1000            command.rule_selection.unfixable,
1001            vec![RuleSelector::Rule(Rule::UnusedAssignment)]
1002        );
1003        assert_eq!(
1004            command.rule_selection.extend_fixable,
1005            vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
1006        );
1007    }
1008
1009    #[test]
1010    fn parses_per_file_ignore_pairs() {
1011        let command = parse_check([
1012            "shuck",
1013            "check",
1014            "--per-file-ignores",
1015            "tests/*.sh:C001",
1016            "--extend-per-file-ignores",
1017            "!src/*.sh:S",
1018        ]);
1019
1020        assert_eq!(
1021            command.rule_selection.per_file_ignores,
1022            Some(vec![PatternRuleSelectorPair {
1023                pattern: "tests/*.sh".to_owned(),
1024                selector: RuleSelector::Rule(Rule::UnusedAssignment),
1025            }])
1026        );
1027        assert_eq!(
1028            command.rule_selection.extend_per_file_ignores,
1029            vec![PatternRuleSelectorPair {
1030                pattern: "!src/*.sh".to_owned(),
1031                selector: RuleSelector::Category(shuck_linter::Category::Style),
1032            }]
1033        );
1034    }
1035
1036    #[test]
1037    fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
1038        let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
1039
1040        assert_eq!(
1041            command.rule_selection.per_file_ignores,
1042            Some(vec![PatternRuleSelectorPair {
1043                pattern: r"C:\repo\*.sh".to_owned(),
1044                selector: RuleSelector::Rule(Rule::UnusedAssignment),
1045            }])
1046        );
1047    }
1048
1049    #[test]
1050    fn parses_per_file_shell_pairs() {
1051        let command = parse_check([
1052            "shuck",
1053            "check",
1054            "--per-file-shell",
1055            "tests/*.sh:bash",
1056            "--extend-per-file-shell",
1057            "!src/*.sh:zsh",
1058        ]);
1059
1060        assert_eq!(
1061            command.rule_selection.per_file_shell,
1062            Some(vec![PatternShellPair {
1063                pattern: "tests/*.sh".to_owned(),
1064                shell: shuck_linter::ShellDialect::Bash,
1065            }])
1066        );
1067        assert_eq!(
1068            command.rule_selection.extend_per_file_shell,
1069            vec![PatternShellPair {
1070                pattern: "!src/*.sh".to_owned(),
1071                shell: shuck_linter::ShellDialect::Zsh,
1072            }]
1073        );
1074    }
1075
1076    #[test]
1077    fn rejects_empty_cli_rule_selectors() {
1078        let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
1079
1080        assert_eq!(error.kind(), ErrorKind::ValueValidation);
1081    }
1082
1083    #[test]
1084    fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1085        let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1086
1087        assert_eq!(error.kind(), ErrorKind::ValueValidation);
1088    }
1089
1090    #[test]
1091    fn rejects_add_noqa_alias() {
1092        let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1093
1094        assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1095    }
1096
1097    #[test]
1098    fn rejects_add_ignore_with_fix_flags() {
1099        let error =
1100            StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1101
1102        assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1103    }
1104
1105    #[test]
1106    fn rejects_watch_with_add_ignore() {
1107        let error =
1108            StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1109
1110        assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1111    }
1112
1113    #[test]
1114    fn check_file_selection_negative_flags_override_positive_flags() {
1115        let args = Args::try_parse_from([
1116            "shuck",
1117            "check",
1118            "--respect-gitignore",
1119            "--no-respect-gitignore",
1120            "--force-exclude",
1121            "--no-force-exclude",
1122        ])
1123        .unwrap();
1124
1125        let Command::Check(command) = args.command else {
1126            panic!("expected check command");
1127        };
1128
1129        assert!(!command.respect_gitignore());
1130        assert!(!command.force_exclude());
1131    }
1132
1133    #[test]
1134    fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1135        let args = Args::try_parse_from([
1136            "shuck",
1137            "check",
1138            "--exclude",
1139            "base.sh",
1140            "--extend-exclude",
1141            "extra.sh",
1142        ])
1143        .unwrap();
1144
1145        let Command::Check(command) = args.command else {
1146            panic!("expected check command");
1147        };
1148
1149        assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1150        assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1151    }
1152}