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, 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 #[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 #[arg(long, global = true, help_heading = "Global options")]
147 isolated: bool,
148 #[arg(
150 long,
151 value_enum,
152 value_name = "WHEN",
153 global = true,
154 help_heading = "Global options"
155 )]
156 color: Option<TerminalColor>,
157 #[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 Check(CheckCommand),
172 #[command(hide = true)]
173 Format(FormatCommand),
174 Clean(CleanCommand),
176}
177
178#[derive(Debug, Subcommand)]
179enum ExperimentalCommand {
180 Check(CheckCommand),
182 Format(FormatCommand),
184 Clean(CleanCommand),
186}
187
188#[derive(Debug, Clone)]
190pub struct Args {
191 pub cache_dir: Option<PathBuf>,
193 pub(crate) config: ConfigArguments,
194 pub(crate) color: Option<TerminalColor>,
195 pub command: Command,
197}
198
199impl Args {
200 pub fn parse() -> Self {
202 Self::try_parse().unwrap_or_else(|err| err.exit())
203 }
204
205 pub fn try_parse() -> Result<Self, clap::Error> {
207 Self::try_parse_from(std::env::args_os())
208 }
209
210 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#[derive(Debug, Clone, Subcommand)]
281pub enum Command {
282 Check(CheckCommand),
284 Format(FormatCommand),
286 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#[derive(Debug, Clone, ClapArgs)]
301pub struct CheckCommand {
302 #[arg(long)]
304 pub fix: bool,
305 #[arg(long = "unsafe-fixes")]
307 pub unsafe_fixes: bool,
308 #[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 #[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 #[arg(short = 'w', long, conflicts_with = "add_ignore")]
331 pub watch: bool,
332 pub paths: Vec<PathBuf>,
334 #[command(flatten)]
336 pub rule_selection: RuleSelectionArgs,
337 #[command(flatten)]
339 pub file_selection: FileSelectionArgs,
340 #[arg(long = "no-cache", help_heading = "Miscellaneous")]
342 pub no_cache: bool,
343 #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
345 pub exit_zero: bool,
346 #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
348 pub exit_non_zero_on_fix: bool,
349}
350
351impl CheckCommand {
352 pub fn respect_gitignore(&self) -> bool {
354 self.file_selection.respect_gitignore()
355 }
356
357 pub fn force_exclude(&self) -> bool {
359 self.file_selection.force_exclude()
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct PatternRuleSelectorPair {
366 pub pattern: String,
368 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#[derive(Debug, Clone, Default, ClapArgs)]
404pub struct RuleSelectionArgs {
405 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default, ClapArgs)]
539pub struct FileSelectionArgs {
540 #[arg(
542 long,
543 value_delimiter = ',',
544 value_name = "FILE_PATTERN",
545 help_heading = "File selection"
546 )]
547 pub exclude: Vec<String>,
548 #[arg(
550 long,
551 value_delimiter = ',',
552 value_name = "FILE_PATTERN",
553 help_heading = "File selection"
554 )]
555 pub extend_exclude: Vec<String>,
556 #[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 #[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 pub fn respect_gitignore(&self) -> bool {
581 resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
582 }
583
584 pub fn force_exclude(&self) -> bool {
586 resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
587 }
588}
589
590#[derive(Debug, Clone, ClapArgs)]
592pub struct FormatCommand {
593 pub files: Vec<PathBuf>,
595 #[arg(long)]
597 pub check: bool,
598 #[arg(long)]
600 pub diff: bool,
601 #[arg(long = "no-cache")]
603 pub no_cache: bool,
604 #[arg(long)]
606 pub stdin_filename: Option<PathBuf>,
607 #[command(flatten)]
609 pub file_selection: FileSelectionArgs,
610 #[arg(long, value_enum)]
612 pub dialect: Option<FormatDialectArg>,
613 #[arg(long, value_enum)]
615 pub indent_style: Option<FormatIndentStyleArg>,
616 #[arg(long, value_name = "WIDTH")]
618 pub indent_width: Option<u8>,
619 #[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 #[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 #[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 #[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 #[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 #[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 #[arg(long)]
667 pub simplify: bool,
668 #[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 pub fn binary_next_line(&self) -> Option<bool> {
692 tri_state_bool(self.binary_next_line, self.no_binary_next_line)
693 }
694
695 pub fn switch_case_indent(&self) -> Option<bool> {
697 tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
698 }
699
700 pub fn space_redirects(&self) -> Option<bool> {
702 tri_state_bool(self.space_redirects, self.no_space_redirects)
703 }
704
705 pub fn keep_padding(&self) -> Option<bool> {
707 tri_state_bool(self.keep_padding, self.no_keep_padding)
708 }
709
710 pub fn function_next_line(&self) -> Option<bool> {
712 tri_state_bool(self.function_next_line, self.no_function_next_line)
713 }
714
715 pub fn never_split(&self) -> Option<bool> {
717 tri_state_bool(self.never_split, self.no_never_split)
718 }
719
720 pub fn respect_gitignore(&self) -> bool {
722 self.file_selection.respect_gitignore()
723 }
724
725 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 (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 (true, true) => unreachable!("clap should make this impossible"),
751 }
752}
753
754#[derive(Debug, Clone, ClapArgs)]
756pub struct CleanCommand {
757 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}