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(Box<CheckCommand>),
172 #[command(hide = true)]
173 Format(FormatCommand),
174 Clean(CleanCommand),
176}
177
178#[derive(Debug, Subcommand)]
179enum ExperimentalCommand {
180 Check(Box<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(Box<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
393#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct PatternShellPair {
396 pub pattern: String,
398 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#[derive(Debug, Clone, Default, ClapArgs)]
441pub struct RuleSelectionArgs {
442 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default, ClapArgs)]
592pub struct FileSelectionArgs {
593 #[arg(
595 long,
596 value_delimiter = ',',
597 value_name = "FILE_PATTERN",
598 help_heading = "File selection"
599 )]
600 pub exclude: Vec<String>,
601 #[arg(
603 long,
604 value_delimiter = ',',
605 value_name = "FILE_PATTERN",
606 help_heading = "File selection"
607 )]
608 pub extend_exclude: Vec<String>,
609 #[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 #[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 pub fn respect_gitignore(&self) -> bool {
634 resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
635 }
636
637 pub fn force_exclude(&self) -> bool {
639 resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
640 }
641}
642
643#[derive(Debug, Clone, ClapArgs)]
645pub struct FormatCommand {
646 pub files: Vec<PathBuf>,
648 #[arg(long)]
650 pub check: bool,
651 #[arg(long)]
653 pub diff: bool,
654 #[arg(long = "no-cache")]
656 pub no_cache: bool,
657 #[arg(long)]
659 pub stdin_filename: Option<PathBuf>,
660 #[command(flatten)]
662 pub file_selection: FileSelectionArgs,
663 #[arg(long, value_enum)]
665 pub dialect: Option<FormatDialectArg>,
666 #[arg(long, value_enum)]
668 pub indent_style: Option<FormatIndentStyleArg>,
669 #[arg(long, value_name = "WIDTH")]
671 pub indent_width: Option<u8>,
672 #[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 #[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 #[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 #[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 #[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 #[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 #[arg(long)]
720 pub simplify: bool,
721 #[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 pub fn binary_next_line(&self) -> Option<bool> {
745 tri_state_bool(self.binary_next_line, self.no_binary_next_line)
746 }
747
748 pub fn switch_case_indent(&self) -> Option<bool> {
750 tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
751 }
752
753 pub fn space_redirects(&self) -> Option<bool> {
755 tri_state_bool(self.space_redirects, self.no_space_redirects)
756 }
757
758 pub fn keep_padding(&self) -> Option<bool> {
760 tri_state_bool(self.keep_padding, self.no_keep_padding)
761 }
762
763 pub fn function_next_line(&self) -> Option<bool> {
765 tri_state_bool(self.function_next_line, self.no_function_next_line)
766 }
767
768 pub fn never_split(&self) -> Option<bool> {
770 tri_state_bool(self.never_split, self.no_never_split)
771 }
772
773 pub fn respect_gitignore(&self) -> bool {
775 self.file_selection.respect_gitignore()
776 }
777
778 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 (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 (true, true) => unreachable!("clap should make this impossible"),
804 }
805}
806
807#[derive(Debug, Clone, ClapArgs)]
809pub struct CleanCommand {
810 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}