1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
27pub enum FormatDialectArg {
28 Auto,
30 Bash,
32 Posix,
34 Mksh,
36 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
54pub enum FormatIndentStyleArg {
55 Tab,
57 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#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
72pub enum CheckOutputFormatArg {
73 Concise,
75 Full,
77 Json,
79 JsonLines,
81 Junit,
83 Grouped,
85 Github,
87 Gitlab,
89 Rdjson,
91 Sarif,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
97pub enum TerminalColor {
98 Auto,
100 Always,
102 Never,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
108pub enum ManagedShellArg {
109 Bash,
111 Gbash,
113 Bashkit,
115 Zsh,
117 Dash,
119 Mksh,
121 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(styles = STYLES)]
143struct StableCli {
144 #[command(flatten)]
145 global: GlobalArgs,
146 #[command(subcommand)]
147 command: StableCommand,
148}
149
150#[derive(Debug, Parser)]
151#[command(name = "shuck")]
152#[command(about = "Shell checker CLI for shuck")]
153#[command(styles = STYLES)]
154struct ExperimentalCli {
155 #[command(flatten)]
156 global: GlobalArgs,
157 #[command(subcommand)]
158 command: ExperimentalCommand,
159}
160
161#[derive(Debug, Clone, ClapArgs)]
162struct GlobalArgs {
163 #[arg(
170 long,
171 action = clap::ArgAction::Append,
172 value_name = "CONFIG_OPTION",
173 value_parser = ConfigArgumentParser,
174 global = true,
175 help_heading = "Global options"
176 )]
177 config: Vec<SingleConfigArgument>,
178 #[arg(long, global = true, help_heading = "Global options")]
180 isolated: bool,
181 #[arg(
183 long,
184 value_enum,
185 value_name = "WHEN",
186 global = true,
187 help_heading = "Global options"
188 )]
189 color: Option<TerminalColor>,
190 #[arg(
192 long,
193 env = "SHUCK_CACHE_DIR",
194 global = true,
195 value_name = "PATH",
196 help_heading = "Miscellaneous"
197 )]
198 cache_dir: Option<PathBuf>,
199}
200
201#[derive(Debug, Subcommand)]
202enum StableCommand {
203 Check(Box<CheckCommand>),
205 Run(RunCommand),
207 Install(InstallCommand),
209 Shell(ShellCommand),
211 #[command(hide = true)]
212 Format(FormatCommand),
213 Clean(CleanCommand),
215}
216
217#[derive(Debug, Subcommand)]
218enum ExperimentalCommand {
219 Check(Box<CheckCommand>),
221 Run(RunCommand),
223 Install(InstallCommand),
225 Shell(ShellCommand),
227 Format(FormatCommand),
229 Clean(CleanCommand),
231}
232
233#[derive(Debug, Clone)]
235pub struct Args {
236 pub cache_dir: Option<PathBuf>,
238 pub(crate) config: ConfigArguments,
239 pub(crate) color: Option<TerminalColor>,
240 pub command: Command,
242}
243
244impl Args {
245 pub fn parse() -> Self {
247 Self::try_parse().unwrap_or_else(|err| err.exit())
248 }
249
250 pub fn try_parse() -> Result<Self, clap::Error> {
252 Self::try_parse_from(std::env::args_os())
253 }
254
255 pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
257 where
258 I: IntoIterator<Item = T>,
259 T: Into<OsString> + Clone,
260 {
261 if experimental_enabled() {
262 let parsed = parse_with_color::<ExperimentalCli, _, _>(itr)?;
263 Self::from_experimental(parsed)
264 } else {
265 let parsed = parse_with_color::<StableCli, _, _>(itr)?;
266 Self::from_stable(parsed)
267 }
268 }
269}
270
271impl Args {
272 fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
273 let StableCli { global, command } = value;
274 let GlobalArgs {
275 cache_dir,
276 config,
277 isolated,
278 color,
279 } = global;
280 let command = match command {
281 StableCommand::Check(command) => Command::Check(command),
282 StableCommand::Run(command) => Command::Run(command),
283 StableCommand::Install(command) => Command::Install(command),
284 StableCommand::Shell(command) => Command::Shell(command),
285 StableCommand::Format(_) => {
286 return Err(clap::Error::raw(
287 ErrorKind::InvalidSubcommand,
288 format!(
289 "the `format` subcommand is experimental; set {EXPERIMENTAL_ENV_VAR}=1 to enable it"
290 ),
291 ));
292 }
293 StableCommand::Clean(command) => Command::Clean(command),
294 };
295
296 Ok(Self {
297 cache_dir,
298 config: ConfigArguments::from_cli(config, isolated)?,
299 color,
300 command,
301 })
302 }
303
304 fn from_experimental(value: ExperimentalCli) -> Result<Self, clap::Error> {
305 let ExperimentalCli { global, command } = value;
306 let GlobalArgs {
307 cache_dir,
308 config,
309 isolated,
310 color,
311 } = global;
312 let command = match command {
313 ExperimentalCommand::Check(command) => Command::Check(command),
314 ExperimentalCommand::Run(command) => Command::Run(command),
315 ExperimentalCommand::Install(command) => Command::Install(command),
316 ExperimentalCommand::Shell(command) => Command::Shell(command),
317 ExperimentalCommand::Format(command) => Command::Format(command),
318 ExperimentalCommand::Clean(command) => Command::Clean(command),
319 };
320
321 Ok(Self {
322 cache_dir,
323 config: ConfigArguments::from_cli(config, isolated)?,
324 color,
325 command,
326 })
327 }
328}
329
330#[derive(Debug, Clone, Subcommand)]
332pub enum Command {
333 Check(Box<CheckCommand>),
335 Run(RunCommand),
337 Install(InstallCommand),
339 Shell(ShellCommand),
341 Format(FormatCommand),
343 Clean(CleanCommand),
345}
346
347fn experimental_enabled() -> bool {
348 std::env::var_os(EXPERIMENTAL_ENV_VAR).is_some_and(|value| {
349 !matches!(
350 value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
351 "" | "0" | "false" | "no" | "off"
352 )
353 })
354}
355
356#[derive(Debug, Clone, ClapArgs)]
358pub struct CheckCommand {
359 #[arg(long)]
361 pub fix: bool,
362 #[arg(long = "unsafe-fixes")]
364 pub unsafe_fixes: bool,
365 #[arg(
368 long = "add-ignore",
369 value_name = "REASON",
370 default_missing_value = "",
371 num_args = 0..=1,
372 require_equals = true,
373 conflicts_with = "fix",
374 conflicts_with = "unsafe_fixes",
375 )]
376 pub add_ignore: Option<String>,
377 #[arg(
380 long = "output-format",
381 value_enum,
382 env = "SHUCK_OUTPUT_FORMAT",
383 default_value_t = CheckOutputFormatArg::Full
384 )]
385 pub output_format: CheckOutputFormatArg,
386 #[arg(short = 'w', long, conflicts_with = "add_ignore")]
388 pub watch: bool,
389 pub paths: Vec<PathBuf>,
391 #[command(flatten)]
393 pub rule_selection: RuleSelectionArgs,
394 #[command(flatten)]
396 pub file_selection: FileSelectionArgs,
397 #[arg(long = "no-cache", help_heading = "Miscellaneous")]
399 pub no_cache: bool,
400 #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
402 pub exit_zero: bool,
403 #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
405 pub exit_non_zero_on_fix: bool,
406}
407
408impl CheckCommand {
409 pub fn respect_gitignore(&self) -> bool {
411 self.file_selection.respect_gitignore()
412 }
413
414 pub fn force_exclude(&self) -> bool {
416 self.file_selection.force_exclude()
417 }
418}
419
420#[derive(Debug, Clone, ClapArgs)]
422pub struct RunCommand {
423 #[arg(short = 's', long, value_enum)]
425 pub shell: Option<ManagedShellArg>,
426 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
428 pub shell_version: Option<String>,
429 #[arg(long)]
431 pub system: bool,
432 #[arg(long)]
434 pub dry_run: bool,
435 #[arg(short = 'v', long)]
437 pub verbose: bool,
438 #[arg(
440 short = 'c',
441 long = "command",
442 value_name = "COMMAND",
443 conflicts_with = "script"
444 )]
445 pub command: Option<String>,
446 pub script: Option<PathBuf>,
448 #[arg(last = true, value_name = "ARGS")]
450 pub script_args: Vec<OsString>,
451}
452
453#[derive(Debug, Clone, ClapArgs)]
455pub struct InstallCommand {
456 #[arg(long)]
458 pub list: bool,
459 #[arg(long)]
461 pub refresh: bool,
462 #[arg(required_unless_present = "list", value_enum)]
464 pub shell: Option<ManagedShellArg>,
465 #[arg(required_unless_present = "list")]
467 pub version: Option<String>,
468}
469
470#[derive(Debug, Clone, ClapArgs)]
472pub struct ShellCommand {
473 #[arg(short = 's', long, value_enum)]
475 pub shell: Option<ManagedShellArg>,
476 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
478 pub shell_version: Option<String>,
479 #[arg(long)]
481 pub system: bool,
482 #[arg(short = 'v', long)]
484 pub verbose: bool,
485}
486
487#[derive(Debug, Clone, PartialEq, Eq)]
489pub struct PatternRuleSelectorPair {
490 pub pattern: String,
492 pub selector: RuleSelector,
494}
495
496impl std::str::FromStr for PatternRuleSelectorPair {
497 type Err = String;
498
499 fn from_str(value: &str) -> Result<Self, Self::Err> {
500 let (pattern, selector) = value
501 .rsplit_once(':')
502 .ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
503 let pattern = pattern.trim();
504 let selector = selector.trim();
505
506 if pattern.is_empty() || selector.is_empty() {
507 return Err("expected <FilePattern>:<RuleCode>".to_owned());
508 }
509
510 Ok(Self {
511 pattern: pattern.to_owned(),
512 selector: parse_cli_rule_selector(selector)?,
513 })
514 }
515}
516
517#[derive(Debug, Clone, PartialEq, Eq)]
519pub struct PatternShellPair {
520 pub pattern: String,
522 pub shell: shuck_linter::ShellDialect,
524}
525
526impl std::str::FromStr for PatternShellPair {
527 type Err = String;
528
529 fn from_str(value: &str) -> Result<Self, Self::Err> {
530 let (pattern, shell) = value
531 .rsplit_once(':')
532 .ok_or_else(|| "expected <FilePattern>:<Shell>".to_owned())?;
533 let pattern = pattern.trim();
534 let shell = shell.trim();
535
536 if pattern.is_empty() || shell.is_empty() {
537 return Err("expected <FilePattern>:<Shell>".to_owned());
538 }
539
540 let shell = shuck_linter::ShellDialect::from_name(shell);
541 if shell == shuck_linter::ShellDialect::Unknown {
542 return Err(
543 "expected shell dialect to be one of sh, bash, dash, ksh, mksh, zsh".to_owned(),
544 );
545 }
546
547 Ok(Self {
548 pattern: pattern.to_owned(),
549 shell,
550 })
551 }
552}
553
554fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
555 let value = value.trim();
556 if value.is_empty() {
557 return Err("rule selector cannot be empty".to_owned());
558 }
559
560 value.parse::<RuleSelector>().map_err(|err| err.to_string())
561}
562
563#[derive(Debug, Clone, Default, ClapArgs)]
565pub struct RuleSelectionArgs {
566 #[arg(
568 long,
569 value_delimiter = ',',
570 value_parser = parse_cli_rule_selector,
571 value_name = "RULE_CODE",
572 help_heading = "Rule selection",
573 hide_possible_values = true
574 )]
575 pub select: Option<Vec<RuleSelector>>,
576 #[arg(
578 long,
579 value_delimiter = ',',
580 value_parser = parse_cli_rule_selector,
581 value_name = "RULE_CODE",
582 help_heading = "Rule selection",
583 hide_possible_values = true
584 )]
585 pub ignore: Vec<RuleSelector>,
586 #[arg(
588 long,
589 value_delimiter = ',',
590 value_parser = parse_cli_rule_selector,
591 value_name = "RULE_CODE",
592 help_heading = "Rule selection",
593 hide_possible_values = true
594 )]
595 pub extend_select: Vec<RuleSelector>,
596 #[arg(
598 long,
599 value_delimiter = ',',
600 value_name = "PER_FILE_IGNORES",
601 help_heading = "Rule selection"
602 )]
603 pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
604 #[arg(
606 long,
607 value_delimiter = ',',
608 value_name = "EXTEND_PER_FILE_IGNORES",
609 help_heading = "Rule selection"
610 )]
611 pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
612 #[arg(
614 long,
615 value_delimiter = ',',
616 value_name = "PER_FILE_SHELL",
617 help_heading = "Rule selection"
618 )]
619 pub per_file_shell: Option<Vec<PatternShellPair>>,
620 #[arg(
622 long,
623 value_delimiter = ',',
624 value_name = "EXTEND_PER_FILE_SHELL",
625 help_heading = "Rule selection"
626 )]
627 pub extend_per_file_shell: Vec<PatternShellPair>,
628 #[arg(
630 long,
631 value_delimiter = ',',
632 value_parser = parse_cli_rule_selector,
633 value_name = "RULE_CODE",
634 help_heading = "Rule selection",
635 hide_possible_values = true
636 )]
637 pub fixable: Option<Vec<RuleSelector>>,
638 #[arg(
640 long,
641 value_delimiter = ',',
642 value_parser = parse_cli_rule_selector,
643 value_name = "RULE_CODE",
644 help_heading = "Rule selection",
645 hide_possible_values = true
646 )]
647 pub unfixable: Vec<RuleSelector>,
648 #[arg(
650 long,
651 value_delimiter = ',',
652 value_parser = parse_cli_rule_selector,
653 value_name = "RULE_CODE",
654 help_heading = "Rule selection",
655 hide_possible_values = true
656 )]
657 pub extend_fixable: Vec<RuleSelector>,
658}
659
660fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
661where
662 Cli: CommandFactory + FromArgMatches,
663 I: IntoIterator<Item = T>,
664 T: Into<OsString> + Clone,
665{
666 let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
667 let mut command = Cli::command().color(command_color_choice(&args));
668 let matches = command.try_get_matches_from_mut(args)?;
669 Cli::from_arg_matches(&matches)
670}
671
672fn command_color_choice(args: &[OsString]) -> ColorChoice {
673 match preparse_color(args) {
674 Some(ColorChoice::Always) => ColorChoice::Always,
675 Some(ColorChoice::Never) => ColorChoice::Never,
676 Some(ColorChoice::Auto) | None => {
677 if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
678 ColorChoice::Always
679 } else {
680 ColorChoice::Auto
681 }
682 }
683 }
684}
685
686fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
687 let mut expect_value = false;
688 let mut color = None;
689
690 for argument in args.iter().skip(1) {
691 if expect_value {
692 let value = argument.to_string_lossy();
693 color = value.parse().ok();
694 expect_value = false;
695 continue;
696 }
697
698 let argument = argument.to_string_lossy();
699 if argument == "--" {
700 break;
701 }
702 if argument == "--color" {
703 expect_value = true;
704 continue;
705 }
706 if let Some(value) = argument.strip_prefix("--color=") {
707 color = value.parse().ok();
708 }
709 }
710
711 color
712}
713
714#[derive(Debug, Clone, Default, ClapArgs)]
716pub struct FileSelectionArgs {
717 #[arg(
719 long,
720 value_delimiter = ',',
721 value_name = "FILE_PATTERN",
722 help_heading = "File selection"
723 )]
724 pub exclude: Vec<String>,
725 #[arg(
727 long,
728 value_delimiter = ',',
729 value_name = "FILE_PATTERN",
730 help_heading = "File selection"
731 )]
732 pub extend_exclude: Vec<String>,
733 #[arg(
736 long,
737 overrides_with = "no_respect_gitignore",
738 help_heading = "File selection"
739 )]
740 pub(crate) respect_gitignore: bool,
741 #[arg(long, overrides_with = "respect_gitignore", hide = true)]
742 pub(crate) no_respect_gitignore: bool,
743 #[arg(
746 long,
747 overrides_with = "no_force_exclude",
748 help_heading = "File selection"
749 )]
750 pub(crate) force_exclude: bool,
751 #[arg(long, overrides_with = "force_exclude", hide = true)]
752 pub(crate) no_force_exclude: bool,
753}
754
755impl FileSelectionArgs {
756 pub fn respect_gitignore(&self) -> bool {
758 resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
759 }
760
761 pub fn force_exclude(&self) -> bool {
763 resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
764 }
765}
766
767#[derive(Debug, Clone, ClapArgs)]
769pub struct FormatCommand {
770 pub files: Vec<PathBuf>,
772 #[arg(long)]
774 pub check: bool,
775 #[arg(long)]
777 pub diff: bool,
778 #[arg(long = "no-cache")]
780 pub no_cache: bool,
781 #[arg(long)]
783 pub stdin_filename: Option<PathBuf>,
784 #[command(flatten)]
786 pub file_selection: FileSelectionArgs,
787 #[arg(long, value_enum)]
789 pub dialect: Option<FormatDialectArg>,
790 #[arg(long, value_enum)]
792 pub indent_style: Option<FormatIndentStyleArg>,
793 #[arg(long, value_name = "WIDTH")]
795 pub indent_width: Option<u8>,
796 #[arg(long, overrides_with = "no_binary_next_line")]
798 pub(crate) binary_next_line: bool,
799 #[arg(
800 long = "no-binary-next-line",
801 overrides_with = "binary_next_line",
802 hide = true
803 )]
804 pub(crate) no_binary_next_line: bool,
805 #[arg(long, overrides_with = "no_switch_case_indent")]
807 pub(crate) switch_case_indent: bool,
808 #[arg(
809 long = "no-switch-case-indent",
810 overrides_with = "switch_case_indent",
811 hide = true
812 )]
813 pub(crate) no_switch_case_indent: bool,
814 #[arg(long, overrides_with = "no_space_redirects")]
816 pub(crate) space_redirects: bool,
817 #[arg(
818 long = "no-space-redirects",
819 overrides_with = "space_redirects",
820 hide = true
821 )]
822 pub(crate) no_space_redirects: bool,
823 #[arg(long, overrides_with = "no_keep_padding")]
825 pub(crate) keep_padding: bool,
826 #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
827 pub(crate) no_keep_padding: bool,
828 #[arg(long, overrides_with = "no_function_next_line")]
830 pub(crate) function_next_line: bool,
831 #[arg(
832 long = "no-function-next-line",
833 overrides_with = "function_next_line",
834 hide = true
835 )]
836 pub(crate) no_function_next_line: bool,
837 #[arg(long, overrides_with = "no_never_split")]
839 pub(crate) never_split: bool,
840 #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
841 pub(crate) no_never_split: bool,
842 #[arg(long)]
844 pub simplify: bool,
845 #[arg(long)]
847 pub minify: bool,
848}
849
850impl FormatCommand {
851 pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
852 FormatSettingsPatch {
853 dialect: self.dialect.map(Into::into),
854 indent_style: self.indent_style.map(Into::into),
855 indent_width: self.indent_width,
856 binary_next_line: self.binary_next_line(),
857 switch_case_indent: self.switch_case_indent(),
858 space_redirects: self.space_redirects(),
859 keep_padding: self.keep_padding(),
860 function_next_line: self.function_next_line(),
861 never_split: self.never_split(),
862 simplify: self.simplify.then_some(true),
863 minify: self.minify.then_some(true),
864 }
865 }
866
867 pub fn binary_next_line(&self) -> Option<bool> {
869 tri_state_bool(self.binary_next_line, self.no_binary_next_line)
870 }
871
872 pub fn switch_case_indent(&self) -> Option<bool> {
874 tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
875 }
876
877 pub fn space_redirects(&self) -> Option<bool> {
879 tri_state_bool(self.space_redirects, self.no_space_redirects)
880 }
881
882 pub fn keep_padding(&self) -> Option<bool> {
884 tri_state_bool(self.keep_padding, self.no_keep_padding)
885 }
886
887 pub fn function_next_line(&self) -> Option<bool> {
889 tri_state_bool(self.function_next_line, self.no_function_next_line)
890 }
891
892 pub fn never_split(&self) -> Option<bool> {
894 tri_state_bool(self.never_split, self.no_never_split)
895 }
896
897 pub fn respect_gitignore(&self) -> bool {
899 self.file_selection.respect_gitignore()
900 }
901
902 pub fn force_exclude(&self) -> bool {
904 self.file_selection.force_exclude()
905 }
906}
907
908fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
909 match (positive, negative) {
910 (false, false) => None,
911 (true, false) => Some(true),
912 (false, true) => Some(false),
913 (true, true) => unreachable!("clap should make this impossible"),
917 }
918}
919
920fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
921 match (positive, negative) {
922 (false, false) => default,
923 (true, false) => true,
924 (false, true) => false,
925 (true, true) => unreachable!("clap should make this impossible"),
928 }
929}
930
931#[derive(Debug, Clone, ClapArgs)]
933pub struct CleanCommand {
934 pub paths: Vec<PathBuf>,
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941 use clap::builder::TypedValueParser;
942 use shuck_linter::Rule;
943
944 #[test]
945 fn global_config_override_is_available_after_subcommand() {
946 let command = StableCli::command();
947 let override_argument = crate::config::ConfigArgumentParser
948 .parse_ref(
949 &command,
950 None,
951 std::ffi::OsStr::new("format.indent-width = 2"),
952 )
953 .unwrap();
954
955 let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
956 .unwrap();
957
958 assert_eq!(
959 args.config,
960 ConfigArguments::from_cli(vec![override_argument], false).unwrap()
961 );
962 }
963
964 #[test]
965 fn explicit_config_file_and_inline_override_both_parse_globally() {
966 let tempdir = tempfile::tempdir().unwrap();
967 let config_path = tempdir.path().join("shuck.toml");
968 std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
969 let command = StableCli::command();
970 let override_argument = crate::config::ConfigArgumentParser
971 .parse_ref(
972 &command,
973 None,
974 std::ffi::OsStr::new("format.function-next-line = true"),
975 )
976 .unwrap();
977
978 let args = Args::try_parse_from([
979 "shuck",
980 "--config",
981 config_path.to_str().unwrap(),
982 "--config",
983 "format.function-next-line = true",
984 "check",
985 ])
986 .unwrap();
987
988 assert_eq!(
989 args.config,
990 ConfigArguments::from_cli(
991 vec![
992 SingleConfigArgument::FilePath(config_path),
993 override_argument
994 ],
995 false,
996 )
997 .unwrap()
998 );
999 }
1000
1001 #[test]
1002 fn global_color_can_be_parsed_before_subcommand() {
1003 let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
1004 assert_eq!(args.color, Some(TerminalColor::Never));
1005 }
1006
1007 #[test]
1008 fn preparse_color_uses_last_value() {
1009 assert_eq!(
1010 preparse_color(&[
1011 OsString::from("shuck"),
1012 OsString::from("--color=always"),
1013 OsString::from("--color"),
1014 OsString::from("never"),
1015 ]),
1016 Some(ColorChoice::Never)
1017 );
1018 }
1019
1020 fn parse_check<I, T>(args: I) -> CheckCommand
1021 where
1022 I: IntoIterator<Item = T>,
1023 T: Into<OsString> + Clone,
1024 {
1025 let parsed = StableCli::try_parse_from(args).unwrap();
1026 match Args::from_stable(parsed).unwrap().command {
1027 Command::Check(command) => *command,
1028 command => panic!("expected check command, got {command:?}"),
1029 }
1030 }
1031
1032 fn parse_run<I, T>(args: I) -> RunCommand
1033 where
1034 I: IntoIterator<Item = T>,
1035 T: Into<OsString> + Clone,
1036 {
1037 let parsed = StableCli::try_parse_from(args).unwrap();
1038 match Args::from_stable(parsed).unwrap().command {
1039 Command::Run(command) => command,
1040 command => panic!("expected run command, got {command:?}"),
1041 }
1042 }
1043
1044 fn parse_install<I, T>(args: I) -> InstallCommand
1045 where
1046 I: IntoIterator<Item = T>,
1047 T: Into<OsString> + Clone,
1048 {
1049 let parsed = StableCli::try_parse_from(args).unwrap();
1050 match Args::from_stable(parsed).unwrap().command {
1051 Command::Install(command) => command,
1052 command => panic!("expected install command, got {command:?}"),
1053 }
1054 }
1055
1056 fn parse_shell<I, T>(args: I) -> ShellCommand
1057 where
1058 I: IntoIterator<Item = T>,
1059 T: Into<OsString> + Clone,
1060 {
1061 let parsed = StableCli::try_parse_from(args).unwrap();
1062 match Args::from_stable(parsed).unwrap().command {
1063 Command::Shell(command) => command,
1064 command => panic!("expected shell command, got {command:?}"),
1065 }
1066 }
1067
1068 #[test]
1069 fn parses_add_ignore_without_reason() {
1070 let command = parse_check(["shuck", "check", "--add-ignore"]);
1071
1072 assert_eq!(command.add_ignore, Some(String::new()));
1073 }
1074
1075 #[test]
1076 fn parses_add_ignore_with_reason() {
1077 let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
1078
1079 assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
1080 }
1081
1082 #[test]
1083 fn parses_short_watch_flag() {
1084 let command = parse_check(["shuck", "check", "-w"]);
1085
1086 assert!(command.watch);
1087 }
1088
1089 #[test]
1090 fn parses_long_watch_flag() {
1091 let command = parse_check(["shuck", "check", "--watch"]);
1092
1093 assert!(command.watch);
1094 }
1095
1096 #[test]
1097 fn parses_all_check_output_formats() {
1098 for (raw, expected) in [
1099 ("concise", CheckOutputFormatArg::Concise),
1100 ("full", CheckOutputFormatArg::Full),
1101 ("json", CheckOutputFormatArg::Json),
1102 ("json-lines", CheckOutputFormatArg::JsonLines),
1103 ("junit", CheckOutputFormatArg::Junit),
1104 ("grouped", CheckOutputFormatArg::Grouped),
1105 ("github", CheckOutputFormatArg::Github),
1106 ("gitlab", CheckOutputFormatArg::Gitlab),
1107 ("rdjson", CheckOutputFormatArg::Rdjson),
1108 ("sarif", CheckOutputFormatArg::Sarif),
1109 ] {
1110 let command = parse_check(["shuck", "check", "--output-format", raw]);
1111 assert_eq!(command.output_format, expected, "failed to parse {raw}");
1112 }
1113 }
1114
1115 #[test]
1116 fn parses_run_command_flags_and_passthrough_args() {
1117 let command = parse_run([
1118 "shuck",
1119 "run",
1120 "--shell",
1121 "bash",
1122 "--shell-version",
1123 "5.2",
1124 "--system",
1125 "--dry-run",
1126 "--verbose",
1127 "deploy.sh",
1128 "--",
1129 "--env",
1130 "staging",
1131 ]);
1132
1133 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1134 assert_eq!(command.shell_version.as_deref(), Some("5.2"));
1135 assert!(command.system);
1136 assert!(command.dry_run);
1137 assert!(command.verbose);
1138 assert_eq!(
1139 command.script.as_deref(),
1140 Some(PathBuf::from("deploy.sh").as_path())
1141 );
1142 assert_eq!(
1143 command.script_args,
1144 vec![OsString::from("--env"), OsString::from("staging")]
1145 );
1146 }
1147
1148 #[test]
1149 fn parses_run_command_string_mode() {
1150 let command = parse_run([
1151 "shuck", "run", "-s", "bash", "-c", "echo hi", "--", "one", "two",
1152 ]);
1153
1154 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1155 assert_eq!(command.command.as_deref(), Some("echo hi"));
1156 assert!(command.script.is_none());
1157 assert_eq!(
1158 command.script_args,
1159 vec![OsString::from("one"), OsString::from("two")]
1160 );
1161 }
1162
1163 #[test]
1164 fn parses_busybox_shell_variants() {
1165 let run = parse_run(["shuck", "run", "--shell", "busybox", "deploy.sh"]);
1166 assert_eq!(run.shell, Some(ManagedShellArg::Busybox));
1167
1168 let install = parse_install(["shuck", "install", "busybox", "1.36"]);
1169 assert_eq!(install.shell, Some(ManagedShellArg::Busybox));
1170
1171 let shell = parse_shell(["shuck", "shell", "--shell", "busybox"]);
1172 assert_eq!(shell.shell, Some(ManagedShellArg::Busybox));
1173 }
1174
1175 #[test]
1176 fn parses_install_list_without_version() {
1177 let command = parse_install(["shuck", "install", "--list", "bash"]);
1178 assert!(command.list);
1179 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1180 assert!(command.version.is_none());
1181 }
1182
1183 #[test]
1184 fn parses_shell_command_flags() {
1185 let command = parse_shell([
1186 "shuck",
1187 "shell",
1188 "--shell",
1189 "zsh",
1190 "--shell-version",
1191 "5.9",
1192 "--system",
1193 "--verbose",
1194 ]);
1195
1196 assert_eq!(command.shell, Some(ManagedShellArg::Zsh));
1197 assert_eq!(command.shell_version.as_deref(), Some("5.9"));
1198 assert!(command.system);
1199 assert!(command.verbose);
1200 }
1201
1202 #[test]
1203 fn parses_extended_managed_shell_names() {
1204 let run_command = parse_run(["shuck", "run", "--shell", "gbash", "-c", "echo hi"]);
1205 assert_eq!(run_command.shell, Some(ManagedShellArg::Gbash));
1206
1207 let install_command = parse_install(["shuck", "install", "--list", "bashkit"]);
1208 assert_eq!(install_command.shell, Some(ManagedShellArg::Bashkit));
1209 }
1210
1211 #[test]
1212 fn parses_rule_selection_flags() {
1213 let command = parse_check([
1214 "shuck",
1215 "check",
1216 "--select",
1217 "C001",
1218 "--select",
1219 "S,C002",
1220 "--ignore",
1221 "C003,C004",
1222 "--extend-select",
1223 "X",
1224 "--fixable",
1225 "ALL",
1226 "--unfixable",
1227 "C001",
1228 "--extend-fixable",
1229 "S074",
1230 ]);
1231
1232 assert_eq!(
1233 command.rule_selection.select,
1234 Some(vec![
1235 RuleSelector::Rule(Rule::UnusedAssignment),
1236 RuleSelector::Category(shuck_linter::Category::Style),
1237 RuleSelector::Rule(Rule::DynamicSourcePath),
1238 ])
1239 );
1240 assert_eq!(
1241 command.rule_selection.ignore,
1242 vec![
1243 RuleSelector::Rule(Rule::UntrackedSourceFile),
1244 RuleSelector::Rule(Rule::UncheckedDirectoryChange),
1245 ]
1246 );
1247 assert_eq!(
1248 command.rule_selection.extend_select,
1249 vec![RuleSelector::Category(shuck_linter::Category::Portability)]
1250 );
1251 assert_eq!(
1252 command.rule_selection.fixable,
1253 Some(vec![RuleSelector::All])
1254 );
1255 assert_eq!(
1256 command.rule_selection.unfixable,
1257 vec![RuleSelector::Rule(Rule::UnusedAssignment)]
1258 );
1259 assert_eq!(
1260 command.rule_selection.extend_fixable,
1261 vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
1262 );
1263 }
1264
1265 #[test]
1266 fn parses_named_rule_selection_flags() {
1267 let command = parse_check([
1268 "shuck",
1269 "check",
1270 "--select",
1271 "google",
1272 "--extend-select",
1273 "google",
1274 "--fixable",
1275 "google",
1276 ]);
1277
1278 assert_eq!(
1279 command.rule_selection.select,
1280 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1281 );
1282 assert_eq!(
1283 command.rule_selection.extend_select,
1284 vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)]
1285 );
1286 assert_eq!(
1287 command.rule_selection.fixable,
1288 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1289 );
1290 }
1291
1292 #[test]
1293 fn parses_per_file_ignore_pairs() {
1294 let command = parse_check([
1295 "shuck",
1296 "check",
1297 "--per-file-ignores",
1298 "tests/*.sh:C001",
1299 "--extend-per-file-ignores",
1300 "!src/*.sh:S",
1301 ]);
1302
1303 assert_eq!(
1304 command.rule_selection.per_file_ignores,
1305 Some(vec![PatternRuleSelectorPair {
1306 pattern: "tests/*.sh".to_owned(),
1307 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1308 }])
1309 );
1310 assert_eq!(
1311 command.rule_selection.extend_per_file_ignores,
1312 vec![PatternRuleSelectorPair {
1313 pattern: "!src/*.sh".to_owned(),
1314 selector: RuleSelector::Category(shuck_linter::Category::Style),
1315 }]
1316 );
1317 }
1318
1319 #[test]
1320 fn parses_named_per_file_ignore_pairs() {
1321 let command = parse_check(["shuck", "check", "--per-file-ignores", "tests/*.sh:google"]);
1322
1323 assert_eq!(
1324 command.rule_selection.per_file_ignores,
1325 Some(vec![PatternRuleSelectorPair {
1326 pattern: "tests/*.sh".to_owned(),
1327 selector: RuleSelector::Named(shuck_linter::NamedGroup::Google),
1328 }])
1329 );
1330 }
1331
1332 #[test]
1333 fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
1334 let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
1335
1336 assert_eq!(
1337 command.rule_selection.per_file_ignores,
1338 Some(vec![PatternRuleSelectorPair {
1339 pattern: r"C:\repo\*.sh".to_owned(),
1340 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1341 }])
1342 );
1343 }
1344
1345 #[test]
1346 fn parses_per_file_shell_pairs() {
1347 let command = parse_check([
1348 "shuck",
1349 "check",
1350 "--per-file-shell",
1351 "tests/*.sh:bash",
1352 "--extend-per-file-shell",
1353 "!src/*.sh:zsh",
1354 ]);
1355
1356 assert_eq!(
1357 command.rule_selection.per_file_shell,
1358 Some(vec![PatternShellPair {
1359 pattern: "tests/*.sh".to_owned(),
1360 shell: shuck_linter::ShellDialect::Bash,
1361 }])
1362 );
1363 assert_eq!(
1364 command.rule_selection.extend_per_file_shell,
1365 vec![PatternShellPair {
1366 pattern: "!src/*.sh".to_owned(),
1367 shell: shuck_linter::ShellDialect::Zsh,
1368 }]
1369 );
1370 }
1371
1372 #[test]
1373 fn rejects_empty_cli_rule_selectors() {
1374 let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
1375
1376 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1377 }
1378
1379 #[test]
1380 fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1381 let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1382
1383 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1384 }
1385
1386 #[test]
1387 fn rejects_add_noqa_alias() {
1388 let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1389
1390 assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1391 }
1392
1393 #[test]
1394 fn rejects_add_ignore_with_fix_flags() {
1395 let error =
1396 StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1397
1398 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1399 }
1400
1401 #[test]
1402 fn rejects_watch_with_add_ignore() {
1403 let error =
1404 StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1405
1406 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1407 }
1408
1409 #[test]
1410 fn check_file_selection_negative_flags_override_positive_flags() {
1411 let args = Args::try_parse_from([
1412 "shuck",
1413 "check",
1414 "--respect-gitignore",
1415 "--no-respect-gitignore",
1416 "--force-exclude",
1417 "--no-force-exclude",
1418 ])
1419 .unwrap();
1420
1421 let Command::Check(command) = args.command else {
1422 panic!("expected check command");
1423 };
1424
1425 assert!(!command.respect_gitignore());
1426 assert!(!command.force_exclude());
1427 }
1428
1429 #[test]
1430 fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1431 let args = Args::try_parse_from([
1432 "shuck",
1433 "check",
1434 "--exclude",
1435 "base.sh",
1436 "--extend-exclude",
1437 "extra.sh",
1438 ])
1439 .unwrap();
1440
1441 let Command::Check(command) = args.command else {
1442 panic!("expected check command");
1443 };
1444
1445 assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1446 assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1447 }
1448}