1use std::ffi::OsString;
4use std::path::PathBuf;
5
6use clap::builder::Styles;
7use clap::builder::styling::{AnsiColor, Effects};
8use clap::{
9 Args as ClapArgs, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
10};
11use shuck_formatter::{IndentStyle, ShellDialect};
12use shuck_linter::RuleSelector;
13
14use shuck_config::FormatSettingsPatch;
15use shuck_config::{ConfigArgumentParser, ConfigArguments, SingleConfigArgument};
16
17const STYLES: Styles = Styles::styled()
18 .header(AnsiColor::Green.on_default().effects(Effects::BOLD))
19 .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
20 .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
21 .placeholder(AnsiColor::Cyan.on_default());
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
25pub enum FormatDialectArg {
26 Auto,
28 Bash,
30 Posix,
32 Mksh,
34 Zsh,
36}
37
38impl From<FormatDialectArg> for ShellDialect {
39 fn from(value: FormatDialectArg) -> Self {
40 match value {
41 FormatDialectArg::Auto => Self::Auto,
42 FormatDialectArg::Bash => Self::Bash,
43 FormatDialectArg::Posix => Self::Posix,
44 FormatDialectArg::Mksh => Self::Mksh,
45 FormatDialectArg::Zsh => Self::Zsh,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
52pub enum FormatIndentStyleArg {
53 Tab,
55 Space,
57}
58
59impl From<FormatIndentStyleArg> for IndentStyle {
60 fn from(value: FormatIndentStyleArg) -> Self {
61 match value {
62 FormatIndentStyleArg::Tab => Self::Tab,
63 FormatIndentStyleArg::Space => Self::Space,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
70pub enum CheckOutputFormatArg {
71 Concise,
73 Full,
75 Json,
77 JsonLines,
79 Junit,
81 Grouped,
83 Github,
85 Gitlab,
87 Rdjson,
89 Sarif,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
95pub enum TerminalColor {
96 Auto,
98 Always,
100 Never,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
106pub enum ManagedShellArg {
107 Bash,
109 Gbash,
111 Bashkit,
113 Zsh,
115 Dash,
117 Mksh,
119 Busybox,
121}
122
123impl From<ManagedShellArg> for shuck_run::Shell {
124 fn from(value: ManagedShellArg) -> Self {
125 match value {
126 ManagedShellArg::Bash => Self::Bash,
127 ManagedShellArg::Gbash => Self::Gbash,
128 ManagedShellArg::Bashkit => Self::Bashkit,
129 ManagedShellArg::Zsh => Self::Zsh,
130 ManagedShellArg::Dash => Self::Dash,
131 ManagedShellArg::Mksh => Self::Mksh,
132 ManagedShellArg::Busybox => Self::Busybox,
133 }
134 }
135}
136
137#[derive(Debug, Parser)]
138#[command(name = "shuck")]
139#[command(about = "Shell checker CLI for shuck")]
140#[command(version = env!("CARGO_PKG_VERSION"))]
141#[command(styles = STYLES)]
142struct StableCli {
143 #[command(flatten)]
144 global: GlobalArgs,
145 #[command(subcommand)]
146 command: StableCommand,
147}
148
149#[derive(Debug, Clone, ClapArgs)]
150struct GlobalArgs {
151 #[arg(
158 long,
159 action = clap::ArgAction::Append,
160 value_name = "CONFIG_OPTION",
161 value_parser = ConfigArgumentParser,
162 global = true,
163 help_heading = "Global options"
164 )]
165 config: Vec<SingleConfigArgument>,
166 #[arg(long, global = true, help_heading = "Global options")]
168 isolated: bool,
169 #[arg(
171 long,
172 value_enum,
173 value_name = "WHEN",
174 global = true,
175 help_heading = "Global options"
176 )]
177 color: Option<TerminalColor>,
178 #[arg(
180 long,
181 env = "SHUCK_CACHE_DIR",
182 global = true,
183 value_name = "PATH",
184 help_heading = "Miscellaneous"
185 )]
186 cache_dir: Option<PathBuf>,
187}
188
189#[derive(Debug, Subcommand)]
190enum StableCommand {
191 Check(Box<CheckCommand>),
193 Server(ServerCommand),
195 Run(RunCommand),
197 Install(InstallCommand),
199 Shell(ShellCommand),
201 Format(FormatCommand),
203 Clean(CleanCommand),
205}
206
207#[derive(Debug, Clone)]
209pub struct Args {
210 pub cache_dir: Option<PathBuf>,
212 pub(crate) config: ConfigArguments,
213 pub(crate) color: Option<TerminalColor>,
214 pub command: Command,
216}
217
218impl Args {
219 pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
221 where
222 I: IntoIterator<Item = T>,
223 T: Into<OsString> + Clone,
224 {
225 let parsed = parse_with_color::<StableCli, _, _>(itr)?;
226 Self::from_stable(parsed)
227 }
228}
229
230impl Args {
231 fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
232 let StableCli { global, command } = value;
233 let GlobalArgs {
234 cache_dir,
235 config,
236 isolated,
237 color,
238 } = global;
239 let command = match command {
240 StableCommand::Check(command) => Command::Check(command),
241 StableCommand::Server(command) => Command::Server(command),
242 StableCommand::Run(command) => Command::Run(command),
243 StableCommand::Install(command) => Command::Install(command),
244 StableCommand::Shell(command) => Command::Shell(command),
245 StableCommand::Format(command) => Command::Format(command),
246 StableCommand::Clean(command) => Command::Clean(command),
247 };
248
249 Ok(Self {
250 cache_dir,
251 config: ConfigArguments::from_cli(config, isolated)?,
252 color,
253 command,
254 })
255 }
256}
257
258#[derive(Debug, Clone, Subcommand)]
260pub enum Command {
261 Check(Box<CheckCommand>),
263 Server(ServerCommand),
265 Run(RunCommand),
267 Install(InstallCommand),
269 Shell(ShellCommand),
271 Format(FormatCommand),
273 Clean(CleanCommand),
275}
276
277#[derive(Debug, Clone, Default, ClapArgs)]
279pub struct ServerCommand {}
280
281#[derive(Debug, Clone, ClapArgs)]
283pub struct CheckCommand {
284 #[arg(long)]
286 pub fix: bool,
287 #[arg(long = "unsafe-fixes")]
289 pub unsafe_fixes: bool,
290 #[arg(
293 long = "add-ignore",
294 value_name = "REASON",
295 default_missing_value = "",
296 num_args = 0..=1,
297 require_equals = true,
298 conflicts_with = "fix",
299 conflicts_with = "unsafe_fixes",
300 )]
301 pub add_ignore: Option<String>,
302 #[arg(
305 long = "output-format",
306 value_enum,
307 env = "SHUCK_OUTPUT_FORMAT",
308 default_value_t = CheckOutputFormatArg::Full
309 )]
310 pub output_format: CheckOutputFormatArg,
311 #[arg(short = 'w', long, conflicts_with = "add_ignore")]
313 pub watch: bool,
314 pub paths: Vec<PathBuf>,
316 #[arg(long, help_heading = "Miscellaneous")]
318 pub stdin_filename: Option<PathBuf>,
319 #[command(flatten)]
321 pub rule_selection: RuleSelectionArgs,
322 #[command(flatten)]
324 pub zsh_plugin_resolution: ZshPluginArgs,
325 #[command(flatten)]
327 pub file_selection: FileSelectionArgs,
328 #[arg(long = "no-cache", help_heading = "Miscellaneous")]
330 pub no_cache: bool,
331 #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
333 pub exit_zero: bool,
334 #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
336 pub exit_non_zero_on_fix: bool,
337}
338
339impl CheckCommand {
340 pub fn respect_gitignore(&self) -> bool {
342 self.file_selection.respect_gitignore()
343 }
344
345 pub fn force_exclude(&self) -> bool {
347 self.file_selection.force_exclude()
348 }
349}
350
351#[derive(Debug, Clone, ClapArgs)]
353pub struct RunCommand {
354 #[arg(short = 's', long, value_enum)]
356 pub shell: Option<ManagedShellArg>,
357 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
359 pub shell_version: Option<String>,
360 #[arg(long)]
362 pub system: bool,
363 #[arg(long)]
365 pub dry_run: bool,
366 #[arg(short = 'v', long)]
368 pub verbose: bool,
369 #[arg(
371 short = 'c',
372 long = "command",
373 value_name = "COMMAND",
374 conflicts_with = "script"
375 )]
376 pub command: Option<String>,
377 pub script: Option<PathBuf>,
379 #[arg(last = true, value_name = "ARGS")]
381 pub script_args: Vec<OsString>,
382}
383
384#[derive(Debug, Clone, ClapArgs)]
386pub struct InstallCommand {
387 #[arg(long)]
389 pub list: bool,
390 #[arg(long)]
392 pub refresh: bool,
393 #[arg(required_unless_present = "list", value_enum)]
395 pub shell: Option<ManagedShellArg>,
396 #[arg(required_unless_present = "list")]
398 pub version: Option<String>,
399}
400
401#[derive(Debug, Clone, ClapArgs)]
403pub struct ShellCommand {
404 #[arg(short = 's', long, value_enum)]
406 pub shell: Option<ManagedShellArg>,
407 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
409 pub shell_version: Option<String>,
410 #[arg(long)]
412 pub system: bool,
413 #[arg(short = 'v', long)]
415 pub verbose: bool,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct PatternRuleSelectorPair {
421 pub pattern: String,
423 pub selector: RuleSelector,
425}
426
427impl std::str::FromStr for PatternRuleSelectorPair {
428 type Err = String;
429
430 fn from_str(value: &str) -> Result<Self, Self::Err> {
431 let (pattern, selector) = value
432 .rsplit_once(':')
433 .ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
434 let pattern = pattern.trim();
435 let selector = selector.trim();
436
437 if pattern.is_empty() || selector.is_empty() {
438 return Err("expected <FilePattern>:<RuleCode>".to_owned());
439 }
440
441 Ok(Self {
442 pattern: pattern.to_owned(),
443 selector: parse_cli_rule_selector(selector)?,
444 })
445 }
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct PatternShellPair {
451 pub pattern: String,
453 pub shell: shuck_linter::ShellDialect,
455}
456
457impl std::str::FromStr for PatternShellPair {
458 type Err = String;
459
460 fn from_str(value: &str) -> Result<Self, Self::Err> {
461 let (pattern, shell) = value
462 .rsplit_once(':')
463 .ok_or_else(|| "expected <FilePattern>:<Shell>".to_owned())?;
464 let pattern = pattern.trim();
465 let shell = shell.trim();
466
467 if pattern.is_empty() || shell.is_empty() {
468 return Err("expected <FilePattern>:<Shell>".to_owned());
469 }
470
471 let shell = shuck_linter::ShellDialect::from_name(shell);
472 if shell == shuck_linter::ShellDialect::Unknown {
473 return Err(
474 "expected shell dialect to be one of sh, bash, dash, ksh, mksh, zsh".to_owned(),
475 );
476 }
477
478 Ok(Self {
479 pattern: pattern.to_owned(),
480 shell,
481 })
482 }
483}
484
485#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct FrameworkRootPair {
488 pub framework: String,
490 pub path: String,
492}
493
494impl std::str::FromStr for FrameworkRootPair {
495 type Err = String;
496
497 fn from_str(value: &str) -> Result<Self, Self::Err> {
498 let (framework, path) = value
499 .split_once('=')
500 .ok_or_else(|| "expected <Framework>=<Path>".to_owned())?;
501 let framework = framework.trim();
502 let path = path.trim();
503
504 if framework.is_empty() || path.is_empty() {
505 return Err("expected <Framework>=<Path>".to_owned());
506 }
507
508 Ok(Self {
509 framework: framework.to_owned(),
510 path: path.to_owned(),
511 })
512 }
513}
514
515#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct PatternFrameworkNameTriple {
518 pub pattern: String,
520 pub framework: String,
522 pub name: String,
524}
525
526impl std::str::FromStr for PatternFrameworkNameTriple {
527 type Err = String;
528
529 fn from_str(value: &str) -> Result<Self, Self::Err> {
530 let mut parts = value.rsplitn(3, ':');
531 let name = parts
532 .next()
533 .ok_or_else(|| "expected <FilePattern>:<Framework>:<Name>".to_owned())?;
534 let framework = parts
535 .next()
536 .ok_or_else(|| "expected <FilePattern>:<Framework>:<Name>".to_owned())?;
537 let pattern = parts
538 .next()
539 .ok_or_else(|| "expected <FilePattern>:<Framework>:<Name>".to_owned())?;
540 let pattern = pattern.trim();
541 let framework = framework.trim();
542 let name = name.trim();
543
544 if pattern.is_empty() || framework.is_empty() || name.is_empty() {
545 return Err("expected <FilePattern>:<Framework>:<Name>".to_owned());
546 }
547
548 Ok(Self {
549 pattern: pattern.to_owned(),
550 framework: framework.to_owned(),
551 name: name.to_owned(),
552 })
553 }
554}
555
556#[derive(Debug, Clone, PartialEq, Eq)]
558pub struct PatternPathPair {
559 pub pattern: String,
561 pub path: String,
563}
564
565impl std::str::FromStr for PatternPathPair {
566 type Err = String;
567
568 fn from_str(value: &str) -> Result<Self, Self::Err> {
569 let (pattern, path) = split_pattern_path_pair(value)
570 .ok_or_else(|| "expected <FilePattern>:<Path>".to_owned())?;
571 let pattern = pattern.trim();
572 let path = path.trim();
573
574 if pattern.is_empty() || path.is_empty() {
575 return Err("expected <FilePattern>:<Path>".to_owned());
576 }
577
578 Ok(Self {
579 pattern: pattern.to_owned(),
580 path: path.to_owned(),
581 })
582 }
583}
584
585fn split_pattern_path_pair(value: &str) -> Option<(&str, &str)> {
586 let bytes = value.as_bytes();
587 for (index, byte) in bytes.iter().enumerate() {
588 if *byte != b':' {
589 continue;
590 }
591 if index == 1
592 && bytes.first().is_some_and(|byte| byte.is_ascii_alphabetic())
593 && bytes
594 .get(2)
595 .is_some_and(|byte| *byte == b'/' || *byte == b'\\')
596 {
597 continue;
598 }
599 return Some((&value[..index], &value[index + 1..]));
600 }
601 None
602}
603
604fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
605 let value = value.trim();
606 if value.is_empty() {
607 return Err("rule selector cannot be empty".to_owned());
608 }
609
610 value.parse::<RuleSelector>().map_err(|err| err.to_string())
611}
612
613#[derive(Debug, Clone, Default, ClapArgs)]
615pub struct RuleSelectionArgs {
616 #[arg(
618 long,
619 value_delimiter = ',',
620 value_parser = parse_cli_rule_selector,
621 value_name = "RULE_CODE",
622 help_heading = "Rule selection",
623 hide_possible_values = true
624 )]
625 pub select: Option<Vec<RuleSelector>>,
626 #[arg(
628 long,
629 value_delimiter = ',',
630 value_parser = parse_cli_rule_selector,
631 value_name = "RULE_CODE",
632 help_heading = "Rule selection",
633 hide_possible_values = true
634 )]
635 pub ignore: Vec<RuleSelector>,
636 #[arg(
638 long,
639 value_delimiter = ',',
640 value_parser = parse_cli_rule_selector,
641 value_name = "RULE_CODE",
642 help_heading = "Rule selection",
643 hide_possible_values = true
644 )]
645 pub extend_select: Vec<RuleSelector>,
646 #[arg(
648 long,
649 value_delimiter = ',',
650 value_name = "PER_FILE_IGNORES",
651 help_heading = "Rule selection"
652 )]
653 pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
654 #[arg(
656 long,
657 value_delimiter = ',',
658 value_name = "EXTEND_PER_FILE_IGNORES",
659 help_heading = "Rule selection"
660 )]
661 pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
662 #[arg(
664 long,
665 value_delimiter = ',',
666 value_name = "PER_FILE_SHELL",
667 help_heading = "Rule selection"
668 )]
669 pub per_file_shell: Option<Vec<PatternShellPair>>,
670 #[arg(
672 long,
673 value_delimiter = ',',
674 value_name = "EXTEND_PER_FILE_SHELL",
675 help_heading = "Rule selection"
676 )]
677 pub extend_per_file_shell: Vec<PatternShellPair>,
678 #[arg(
680 long,
681 value_delimiter = ',',
682 value_parser = parse_cli_rule_selector,
683 value_name = "RULE_CODE",
684 help_heading = "Rule selection",
685 hide_possible_values = true
686 )]
687 pub fixable: Option<Vec<RuleSelector>>,
688 #[arg(
690 long,
691 value_delimiter = ',',
692 value_parser = parse_cli_rule_selector,
693 value_name = "RULE_CODE",
694 help_heading = "Rule selection",
695 hide_possible_values = true
696 )]
697 pub unfixable: Vec<RuleSelector>,
698 #[arg(
700 long,
701 value_delimiter = ',',
702 value_parser = parse_cli_rule_selector,
703 value_name = "RULE_CODE",
704 help_heading = "Rule selection",
705 hide_possible_values = true
706 )]
707 pub extend_fixable: Vec<RuleSelector>,
708}
709
710#[derive(Debug, Clone, Default, ClapArgs)]
712pub struct ZshPluginArgs {
713 #[arg(
715 long,
716 overrides_with = "no_zsh_plugin_resolution",
717 help_heading = "Zsh plugin resolution"
718 )]
719 pub(crate) zsh_plugin_resolution: bool,
720 #[arg(long, overrides_with = "zsh_plugin_resolution", hide = true)]
721 pub(crate) no_zsh_plugin_resolution: bool,
722 #[arg(
724 long = "zsh-plugin-root",
725 value_delimiter = ',',
726 value_name = "FRAMEWORK=PATH",
727 help_heading = "Zsh plugin resolution"
728 )]
729 pub zsh_plugin_root: Option<Vec<FrameworkRootPair>>,
730 #[arg(
732 long = "extend-zsh-plugin-root",
733 value_delimiter = ',',
734 value_name = "FRAMEWORK=PATH",
735 help_heading = "Zsh plugin resolution"
736 )]
737 pub extend_zsh_plugin_root: Vec<FrameworkRootPair>,
738 #[arg(
740 long = "zsh-plugin",
741 value_delimiter = ',',
742 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
743 help_heading = "Zsh plugin resolution"
744 )]
745 pub zsh_plugin: Option<Vec<PatternFrameworkNameTriple>>,
746 #[arg(
748 long = "extend-zsh-plugin",
749 value_delimiter = ',',
750 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
751 help_heading = "Zsh plugin resolution"
752 )]
753 pub extend_zsh_plugin: Vec<PatternFrameworkNameTriple>,
754 #[arg(
756 long = "zsh-theme",
757 value_delimiter = ',',
758 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
759 help_heading = "Zsh plugin resolution"
760 )]
761 pub zsh_theme: Option<Vec<PatternFrameworkNameTriple>>,
762 #[arg(
764 long = "extend-zsh-theme",
765 value_delimiter = ',',
766 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
767 help_heading = "Zsh plugin resolution"
768 )]
769 pub extend_zsh_theme: Vec<PatternFrameworkNameTriple>,
770 #[arg(
772 long = "zsh-plugin-entrypoint",
773 value_delimiter = ',',
774 value_name = "FILE_PATTERN:PATH",
775 help_heading = "Zsh plugin resolution"
776 )]
777 pub zsh_plugin_entrypoint: Option<Vec<PatternPathPair>>,
778 #[arg(
780 long = "extend-zsh-plugin-entrypoint",
781 value_delimiter = ',',
782 value_name = "FILE_PATTERN:PATH",
783 help_heading = "Zsh plugin resolution"
784 )]
785 pub extend_zsh_plugin_entrypoint: Vec<PatternPathPair>,
786}
787
788impl ZshPluginArgs {
789 pub fn resolution(&self) -> Option<bool> {
791 if self.zsh_plugin_resolution {
792 Some(true)
793 } else if self.no_zsh_plugin_resolution {
794 Some(false)
795 } else {
796 None
797 }
798 }
799}
800
801fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
802where
803 Cli: CommandFactory + FromArgMatches,
804 I: IntoIterator<Item = T>,
805 T: Into<OsString> + Clone,
806{
807 let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
808 let mut command = Cli::command().color(command_color_choice(&args));
809 let matches = command.try_get_matches_from_mut(args)?;
810 Cli::from_arg_matches(&matches)
811}
812
813fn command_color_choice(args: &[OsString]) -> ColorChoice {
814 match preparse_color(args) {
815 Some(ColorChoice::Always) => ColorChoice::Always,
816 Some(ColorChoice::Never) => ColorChoice::Never,
817 Some(ColorChoice::Auto) | None => {
818 if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
819 ColorChoice::Always
820 } else {
821 ColorChoice::Auto
822 }
823 }
824 }
825}
826
827fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
828 let mut expect_value = false;
829 let mut color = None;
830
831 for argument in args.iter().skip(1) {
832 if expect_value {
833 let value = argument.to_string_lossy();
834 color = value.parse().ok();
835 expect_value = false;
836 continue;
837 }
838
839 let argument = argument.to_string_lossy();
840 if argument == "--" {
841 break;
842 }
843 if argument == "--color" {
844 expect_value = true;
845 continue;
846 }
847 if let Some(value) = argument.strip_prefix("--color=") {
848 color = value.parse().ok();
849 }
850 }
851
852 color
853}
854
855#[derive(Debug, Clone, Default, ClapArgs)]
857pub struct FileSelectionArgs {
858 #[arg(
860 long,
861 value_delimiter = ',',
862 value_name = "FILE_PATTERN",
863 help_heading = "File selection"
864 )]
865 pub exclude: Vec<String>,
866 #[arg(
868 long,
869 value_delimiter = ',',
870 value_name = "FILE_PATTERN",
871 help_heading = "File selection"
872 )]
873 pub extend_exclude: Vec<String>,
874 #[arg(
877 long,
878 overrides_with = "no_respect_gitignore",
879 help_heading = "File selection"
880 )]
881 pub(crate) respect_gitignore: bool,
882 #[arg(long, overrides_with = "respect_gitignore", hide = true)]
883 pub(crate) no_respect_gitignore: bool,
884 #[arg(
887 long,
888 overrides_with = "no_force_exclude",
889 help_heading = "File selection"
890 )]
891 pub(crate) force_exclude: bool,
892 #[arg(long, overrides_with = "force_exclude", hide = true)]
893 pub(crate) no_force_exclude: bool,
894}
895
896impl FileSelectionArgs {
897 pub fn respect_gitignore(&self) -> bool {
899 resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
900 }
901
902 pub fn force_exclude(&self) -> bool {
904 resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
905 }
906}
907
908#[derive(Debug, Clone, ClapArgs)]
910pub struct FormatCommand {
911 pub files: Vec<PathBuf>,
913 #[arg(long)]
915 pub check: bool,
916 #[arg(long)]
918 pub diff: bool,
919 #[arg(long = "no-cache")]
921 pub no_cache: bool,
922 #[arg(long)]
924 pub stdin_filename: Option<PathBuf>,
925 #[command(flatten)]
927 pub file_selection: FileSelectionArgs,
928 #[arg(long, value_enum)]
930 pub dialect: Option<FormatDialectArg>,
931 #[arg(long, value_enum)]
933 pub indent_style: Option<FormatIndentStyleArg>,
934 #[arg(long, value_name = "WIDTH")]
936 pub indent_width: Option<u8>,
937 #[arg(long, overrides_with = "no_binary_next_line")]
939 pub(crate) binary_next_line: bool,
940 #[arg(
941 long = "no-binary-next-line",
942 overrides_with = "binary_next_line",
943 hide = true
944 )]
945 pub(crate) no_binary_next_line: bool,
946 #[arg(long, overrides_with = "no_switch_case_indent")]
948 pub(crate) switch_case_indent: bool,
949 #[arg(
950 long = "no-switch-case-indent",
951 overrides_with = "switch_case_indent",
952 hide = true
953 )]
954 pub(crate) no_switch_case_indent: bool,
955 #[arg(long, overrides_with = "no_space_redirects")]
957 pub(crate) space_redirects: bool,
958 #[arg(
959 long = "no-space-redirects",
960 overrides_with = "space_redirects",
961 hide = true
962 )]
963 pub(crate) no_space_redirects: bool,
964 #[arg(long, overrides_with = "no_keep_padding")]
966 pub(crate) keep_padding: bool,
967 #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
968 pub(crate) no_keep_padding: bool,
969 #[arg(long, overrides_with = "no_function_next_line")]
971 pub(crate) function_next_line: bool,
972 #[arg(
973 long = "no-function-next-line",
974 overrides_with = "function_next_line",
975 hide = true
976 )]
977 pub(crate) no_function_next_line: bool,
978 #[arg(long, overrides_with = "no_never_split")]
980 pub(crate) never_split: bool,
981 #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
982 pub(crate) no_never_split: bool,
983 #[arg(long)]
985 pub simplify: bool,
986 #[arg(long)]
988 pub minify: bool,
989}
990
991impl FormatCommand {
992 pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
993 FormatSettingsPatch {
994 dialect: self.dialect.map(Into::into),
995 indent_style: self.indent_style.map(Into::into),
996 indent_width: self.indent_width,
997 binary_next_line: self.binary_next_line(),
998 switch_case_indent: self.switch_case_indent(),
999 space_redirects: self.space_redirects(),
1000 keep_padding: self.keep_padding(),
1001 function_next_line: self.function_next_line(),
1002 never_split: self.never_split(),
1003 simplify: self.simplify.then_some(true),
1004 minify: self.minify.then_some(true),
1005 }
1006 }
1007
1008 pub fn binary_next_line(&self) -> Option<bool> {
1010 tri_state_bool(self.binary_next_line, self.no_binary_next_line)
1011 }
1012
1013 pub fn switch_case_indent(&self) -> Option<bool> {
1015 tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
1016 }
1017
1018 pub fn space_redirects(&self) -> Option<bool> {
1020 tri_state_bool(self.space_redirects, self.no_space_redirects)
1021 }
1022
1023 pub fn keep_padding(&self) -> Option<bool> {
1025 tri_state_bool(self.keep_padding, self.no_keep_padding)
1026 }
1027
1028 pub fn function_next_line(&self) -> Option<bool> {
1030 tri_state_bool(self.function_next_line, self.no_function_next_line)
1031 }
1032
1033 pub fn never_split(&self) -> Option<bool> {
1035 tri_state_bool(self.never_split, self.no_never_split)
1036 }
1037
1038 pub fn respect_gitignore(&self) -> bool {
1040 self.file_selection.respect_gitignore()
1041 }
1042
1043 pub fn force_exclude(&self) -> bool {
1045 self.file_selection.force_exclude()
1046 }
1047}
1048
1049fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
1050 match (positive, negative) {
1051 (false, false) => None,
1052 (true, false) => Some(true),
1053 (false, true) => Some(false),
1054 (true, true) => unreachable!("clap should make this impossible"),
1058 }
1059}
1060
1061fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
1062 match (positive, negative) {
1063 (false, false) => default,
1064 (true, false) => true,
1065 (false, true) => false,
1066 (true, true) => unreachable!("clap should make this impossible"),
1069 }
1070}
1071
1072#[derive(Debug, Clone, ClapArgs)]
1074pub struct CleanCommand {
1075 pub paths: Vec<PathBuf>,
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use clap::builder::TypedValueParser;
1083 use clap::error::ErrorKind;
1084 use shuck_linter::Rule;
1085
1086 #[test]
1087 fn global_config_override_is_available_after_subcommand() {
1088 let command = StableCli::command();
1089 let override_argument = shuck_config::ConfigArgumentParser
1090 .parse_ref(
1091 &command,
1092 None,
1093 std::ffi::OsStr::new("format.indent-width = 2"),
1094 )
1095 .unwrap();
1096
1097 let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
1098 .unwrap();
1099
1100 assert_eq!(
1101 args.config,
1102 ConfigArguments::from_cli(vec![override_argument], false).unwrap()
1103 );
1104 }
1105
1106 #[test]
1107 fn explicit_config_file_and_inline_override_both_parse_globally() {
1108 let tempdir = tempfile::tempdir().unwrap();
1109 let config_path = tempdir.path().join("shuck.toml");
1110 std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
1111 let command = StableCli::command();
1112 let override_argument = shuck_config::ConfigArgumentParser
1113 .parse_ref(
1114 &command,
1115 None,
1116 std::ffi::OsStr::new("format.function-next-line = true"),
1117 )
1118 .unwrap();
1119
1120 let args = Args::try_parse_from([
1121 "shuck",
1122 "--config",
1123 config_path.to_str().unwrap(),
1124 "--config",
1125 "format.function-next-line = true",
1126 "check",
1127 ])
1128 .unwrap();
1129
1130 assert_eq!(
1131 args.config,
1132 ConfigArguments::from_cli(
1133 vec![
1134 SingleConfigArgument::FilePath(config_path),
1135 override_argument
1136 ],
1137 false,
1138 )
1139 .unwrap()
1140 );
1141 }
1142
1143 #[test]
1144 fn global_color_can_be_parsed_before_subcommand() {
1145 let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
1146 assert_eq!(args.color, Some(TerminalColor::Never));
1147 }
1148
1149 #[test]
1150 fn preparse_color_uses_last_value() {
1151 assert_eq!(
1152 preparse_color(&[
1153 OsString::from("shuck"),
1154 OsString::from("--color=always"),
1155 OsString::from("--color"),
1156 OsString::from("never"),
1157 ]),
1158 Some(ColorChoice::Never)
1159 );
1160 }
1161
1162 fn parse_check<I, T>(args: I) -> CheckCommand
1163 where
1164 I: IntoIterator<Item = T>,
1165 T: Into<OsString> + Clone,
1166 {
1167 let parsed = StableCli::try_parse_from(args).unwrap();
1168 match Args::from_stable(parsed).unwrap().command {
1169 Command::Check(command) => *command,
1170 command => panic!("expected check command, got {command:?}"),
1171 }
1172 }
1173
1174 fn parse_run<I, T>(args: I) -> RunCommand
1175 where
1176 I: IntoIterator<Item = T>,
1177 T: Into<OsString> + Clone,
1178 {
1179 let parsed = StableCli::try_parse_from(args).unwrap();
1180 match Args::from_stable(parsed).unwrap().command {
1181 Command::Run(command) => command,
1182 command => panic!("expected run command, got {command:?}"),
1183 }
1184 }
1185
1186 fn parse_install<I, T>(args: I) -> InstallCommand
1187 where
1188 I: IntoIterator<Item = T>,
1189 T: Into<OsString> + Clone,
1190 {
1191 let parsed = StableCli::try_parse_from(args).unwrap();
1192 match Args::from_stable(parsed).unwrap().command {
1193 Command::Install(command) => command,
1194 command => panic!("expected install command, got {command:?}"),
1195 }
1196 }
1197
1198 fn parse_shell<I, T>(args: I) -> ShellCommand
1199 where
1200 I: IntoIterator<Item = T>,
1201 T: Into<OsString> + Clone,
1202 {
1203 let parsed = StableCli::try_parse_from(args).unwrap();
1204 match Args::from_stable(parsed).unwrap().command {
1205 Command::Shell(command) => command,
1206 command => panic!("expected shell command, got {command:?}"),
1207 }
1208 }
1209
1210 #[test]
1211 fn parses_add_ignore_without_reason() {
1212 let command = parse_check(["shuck", "check", "--add-ignore"]);
1213
1214 assert_eq!(command.add_ignore, Some(String::new()));
1215 }
1216
1217 #[test]
1218 fn parses_add_ignore_with_reason() {
1219 let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
1220
1221 assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
1222 }
1223
1224 #[test]
1225 fn parses_short_watch_flag() {
1226 let command = parse_check(["shuck", "check", "-w"]);
1227
1228 assert!(command.watch);
1229 }
1230
1231 #[test]
1232 fn parses_long_watch_flag() {
1233 let command = parse_check(["shuck", "check", "--watch"]);
1234
1235 assert!(command.watch);
1236 }
1237
1238 #[test]
1239 fn parses_all_check_output_formats() {
1240 for (raw, expected) in [
1241 ("concise", CheckOutputFormatArg::Concise),
1242 ("full", CheckOutputFormatArg::Full),
1243 ("json", CheckOutputFormatArg::Json),
1244 ("json-lines", CheckOutputFormatArg::JsonLines),
1245 ("junit", CheckOutputFormatArg::Junit),
1246 ("grouped", CheckOutputFormatArg::Grouped),
1247 ("github", CheckOutputFormatArg::Github),
1248 ("gitlab", CheckOutputFormatArg::Gitlab),
1249 ("rdjson", CheckOutputFormatArg::Rdjson),
1250 ("sarif", CheckOutputFormatArg::Sarif),
1251 ] {
1252 let command = parse_check(["shuck", "check", "--output-format", raw]);
1253 assert_eq!(command.output_format, expected, "failed to parse {raw}");
1254 }
1255 }
1256
1257 #[test]
1258 fn parses_run_command_flags_and_passthrough_args() {
1259 let command = parse_run([
1260 "shuck",
1261 "run",
1262 "--shell",
1263 "bash",
1264 "--shell-version",
1265 "5.2",
1266 "--system",
1267 "--dry-run",
1268 "--verbose",
1269 "deploy.sh",
1270 "--",
1271 "--env",
1272 "staging",
1273 ]);
1274
1275 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1276 assert_eq!(command.shell_version.as_deref(), Some("5.2"));
1277 assert!(command.system);
1278 assert!(command.dry_run);
1279 assert!(command.verbose);
1280 assert_eq!(
1281 command.script.as_deref(),
1282 Some(PathBuf::from("deploy.sh").as_path())
1283 );
1284 assert_eq!(
1285 command.script_args,
1286 vec![OsString::from("--env"), OsString::from("staging")]
1287 );
1288 }
1289
1290 #[test]
1291 fn parses_run_command_string_mode() {
1292 let command = parse_run([
1293 "shuck", "run", "-s", "bash", "-c", "echo hi", "--", "one", "two",
1294 ]);
1295
1296 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1297 assert_eq!(command.command.as_deref(), Some("echo hi"));
1298 assert!(command.script.is_none());
1299 assert_eq!(
1300 command.script_args,
1301 vec![OsString::from("one"), OsString::from("two")]
1302 );
1303 }
1304
1305 #[test]
1306 fn parses_busybox_shell_variants() {
1307 let run = parse_run(["shuck", "run", "--shell", "busybox", "deploy.sh"]);
1308 assert_eq!(run.shell, Some(ManagedShellArg::Busybox));
1309
1310 let install = parse_install(["shuck", "install", "busybox", "1.36"]);
1311 assert_eq!(install.shell, Some(ManagedShellArg::Busybox));
1312
1313 let shell = parse_shell(["shuck", "shell", "--shell", "busybox"]);
1314 assert_eq!(shell.shell, Some(ManagedShellArg::Busybox));
1315 }
1316
1317 #[test]
1318 fn parses_install_list_without_version() {
1319 let command = parse_install(["shuck", "install", "--list", "bash"]);
1320 assert!(command.list);
1321 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1322 assert!(command.version.is_none());
1323 }
1324
1325 #[test]
1326 fn parses_shell_command_flags() {
1327 let command = parse_shell([
1328 "shuck",
1329 "shell",
1330 "--shell",
1331 "zsh",
1332 "--shell-version",
1333 "5.9",
1334 "--system",
1335 "--verbose",
1336 ]);
1337
1338 assert_eq!(command.shell, Some(ManagedShellArg::Zsh));
1339 assert_eq!(command.shell_version.as_deref(), Some("5.9"));
1340 assert!(command.system);
1341 assert!(command.verbose);
1342 }
1343
1344 #[test]
1345 fn parses_extended_managed_shell_names() {
1346 let run_command = parse_run(["shuck", "run", "--shell", "gbash", "-c", "echo hi"]);
1347 assert_eq!(run_command.shell, Some(ManagedShellArg::Gbash));
1348
1349 let install_command = parse_install(["shuck", "install", "--list", "bashkit"]);
1350 assert_eq!(install_command.shell, Some(ManagedShellArg::Bashkit));
1351 }
1352
1353 #[test]
1354 fn parses_rule_selection_flags() {
1355 let command = parse_check([
1356 "shuck",
1357 "check",
1358 "--select",
1359 "C001",
1360 "--select",
1361 "S,C002",
1362 "--ignore",
1363 "C003,C004",
1364 "--extend-select",
1365 "X",
1366 "--fixable",
1367 "ALL",
1368 "--unfixable",
1369 "C001",
1370 "--extend-fixable",
1371 "S074",
1372 ]);
1373
1374 assert_eq!(
1375 command.rule_selection.select,
1376 Some(vec![
1377 RuleSelector::Rule(Rule::UnusedAssignment),
1378 RuleSelector::Category(shuck_linter::Category::Style),
1379 RuleSelector::Rule(Rule::DynamicSourcePath),
1380 ])
1381 );
1382 assert_eq!(
1383 command.rule_selection.ignore,
1384 vec![
1385 RuleSelector::Rule(Rule::UntrackedSourceFile),
1386 RuleSelector::Rule(Rule::UncheckedDirectoryChange),
1387 ]
1388 );
1389 assert_eq!(
1390 command.rule_selection.extend_select,
1391 vec![RuleSelector::Category(shuck_linter::Category::Portability)]
1392 );
1393 assert_eq!(
1394 command.rule_selection.fixable,
1395 Some(vec![RuleSelector::All])
1396 );
1397 assert_eq!(
1398 command.rule_selection.unfixable,
1399 vec![RuleSelector::Rule(Rule::UnusedAssignment)]
1400 );
1401 assert_eq!(
1402 command.rule_selection.extend_fixable,
1403 vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
1404 );
1405 }
1406
1407 #[test]
1408 fn parses_named_rule_selection_flags() {
1409 let command = parse_check([
1410 "shuck",
1411 "check",
1412 "--select",
1413 "google",
1414 "--extend-select",
1415 "google",
1416 "--fixable",
1417 "google",
1418 ]);
1419
1420 assert_eq!(
1421 command.rule_selection.select,
1422 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1423 );
1424 assert_eq!(
1425 command.rule_selection.extend_select,
1426 vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)]
1427 );
1428 assert_eq!(
1429 command.rule_selection.fixable,
1430 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1431 );
1432 }
1433
1434 #[test]
1435 fn parses_per_file_ignore_pairs() {
1436 let command = parse_check([
1437 "shuck",
1438 "check",
1439 "--per-file-ignores",
1440 "tests/*.sh:C001",
1441 "--extend-per-file-ignores",
1442 "!src/*.sh:S",
1443 ]);
1444
1445 assert_eq!(
1446 command.rule_selection.per_file_ignores,
1447 Some(vec![PatternRuleSelectorPair {
1448 pattern: "tests/*.sh".to_owned(),
1449 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1450 }])
1451 );
1452 assert_eq!(
1453 command.rule_selection.extend_per_file_ignores,
1454 vec![PatternRuleSelectorPair {
1455 pattern: "!src/*.sh".to_owned(),
1456 selector: RuleSelector::Category(shuck_linter::Category::Style),
1457 }]
1458 );
1459 }
1460
1461 #[test]
1462 fn parses_named_per_file_ignore_pairs() {
1463 let command = parse_check(["shuck", "check", "--per-file-ignores", "tests/*.sh:google"]);
1464
1465 assert_eq!(
1466 command.rule_selection.per_file_ignores,
1467 Some(vec![PatternRuleSelectorPair {
1468 pattern: "tests/*.sh".to_owned(),
1469 selector: RuleSelector::Named(shuck_linter::NamedGroup::Google),
1470 }])
1471 );
1472 }
1473
1474 #[test]
1475 fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
1476 let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
1477
1478 assert_eq!(
1479 command.rule_selection.per_file_ignores,
1480 Some(vec![PatternRuleSelectorPair {
1481 pattern: r"C:\repo\*.sh".to_owned(),
1482 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1483 }])
1484 );
1485 }
1486
1487 #[test]
1488 fn parses_per_file_shell_pairs() {
1489 let command = parse_check([
1490 "shuck",
1491 "check",
1492 "--per-file-shell",
1493 "tests/*.sh:bash",
1494 "--extend-per-file-shell",
1495 "!src/*.sh:zsh",
1496 ]);
1497
1498 assert_eq!(
1499 command.rule_selection.per_file_shell,
1500 Some(vec![PatternShellPair {
1501 pattern: "tests/*.sh".to_owned(),
1502 shell: shuck_linter::ShellDialect::Bash,
1503 }])
1504 );
1505 assert_eq!(
1506 command.rule_selection.extend_per_file_shell,
1507 vec![PatternShellPair {
1508 pattern: "!src/*.sh".to_owned(),
1509 shell: shuck_linter::ShellDialect::Zsh,
1510 }]
1511 );
1512 }
1513
1514 #[test]
1515 fn parses_zsh_plugin_resolution_pairs() {
1516 let command = parse_check([
1517 "shuck",
1518 "check",
1519 "--zsh-plugin-root",
1520 "oh-my-zsh=~/.oh-my-zsh",
1521 "--extend-zsh-plugin-root",
1522 "custom=./vendor/plugins",
1523 "--zsh-plugin",
1524 "tests/.zshrc:oh-my-zsh:git",
1525 "--extend-zsh-plugin",
1526 "!src/.zshrc:oh-my-zsh:docker",
1527 "--zsh-theme",
1528 "tests/.zshrc:oh-my-zsh:agnoster",
1529 "--extend-zsh-theme",
1530 "!src/.zshrc:oh-my-zsh:robbyrussell",
1531 "--zsh-plugin-entrypoint",
1532 "tests/.zshrc:./vendor/prompt.plugin.zsh",
1533 "--extend-zsh-plugin-entrypoint",
1534 "!src/.zshrc:./vendor/theme.zsh",
1535 ]);
1536
1537 assert_eq!(
1538 command.zsh_plugin_resolution.zsh_plugin_root,
1539 Some(vec![FrameworkRootPair {
1540 framework: "oh-my-zsh".to_owned(),
1541 path: "~/.oh-my-zsh".to_owned(),
1542 }])
1543 );
1544 assert_eq!(
1545 command.zsh_plugin_resolution.extend_zsh_plugin_root,
1546 vec![FrameworkRootPair {
1547 framework: "custom".to_owned(),
1548 path: "./vendor/plugins".to_owned(),
1549 }]
1550 );
1551 assert_eq!(
1552 command.zsh_plugin_resolution.zsh_plugin,
1553 Some(vec![PatternFrameworkNameTriple {
1554 pattern: "tests/.zshrc".to_owned(),
1555 framework: "oh-my-zsh".to_owned(),
1556 name: "git".to_owned(),
1557 }])
1558 );
1559 assert_eq!(
1560 command.zsh_plugin_resolution.extend_zsh_plugin,
1561 vec![PatternFrameworkNameTriple {
1562 pattern: "!src/.zshrc".to_owned(),
1563 framework: "oh-my-zsh".to_owned(),
1564 name: "docker".to_owned(),
1565 }]
1566 );
1567 assert_eq!(
1568 command.zsh_plugin_resolution.zsh_theme,
1569 Some(vec![PatternFrameworkNameTriple {
1570 pattern: "tests/.zshrc".to_owned(),
1571 framework: "oh-my-zsh".to_owned(),
1572 name: "agnoster".to_owned(),
1573 }])
1574 );
1575 assert_eq!(
1576 command.zsh_plugin_resolution.extend_zsh_theme,
1577 vec![PatternFrameworkNameTriple {
1578 pattern: "!src/.zshrc".to_owned(),
1579 framework: "oh-my-zsh".to_owned(),
1580 name: "robbyrussell".to_owned(),
1581 }]
1582 );
1583 assert_eq!(
1584 command.zsh_plugin_resolution.zsh_plugin_entrypoint,
1585 Some(vec![PatternPathPair {
1586 pattern: "tests/.zshrc".to_owned(),
1587 path: "./vendor/prompt.plugin.zsh".to_owned(),
1588 }])
1589 );
1590 assert_eq!(
1591 command.zsh_plugin_resolution.extend_zsh_plugin_entrypoint,
1592 vec![PatternPathPair {
1593 pattern: "!src/.zshrc".to_owned(),
1594 path: "./vendor/theme.zsh".to_owned(),
1595 }]
1596 );
1597 }
1598
1599 #[test]
1600 fn parses_zsh_plugin_entrypoints_with_windows_absolute_paths() {
1601 let command = parse_check([
1602 "shuck",
1603 "check",
1604 "--zsh-plugin-entrypoint",
1605 r"**/.zshrc:C:/plugins/git.plugin.zsh",
1606 "--extend-zsh-plugin-entrypoint",
1607 r"C:/repo/**/*.zshrc:C:/plugins/theme.zsh",
1608 ]);
1609
1610 assert_eq!(
1611 command.zsh_plugin_resolution.zsh_plugin_entrypoint,
1612 Some(vec![PatternPathPair {
1613 pattern: "**/.zshrc".to_owned(),
1614 path: "C:/plugins/git.plugin.zsh".to_owned(),
1615 }])
1616 );
1617 assert_eq!(
1618 command.zsh_plugin_resolution.extend_zsh_plugin_entrypoint,
1619 vec![PatternPathPair {
1620 pattern: r"C:/repo/**/*.zshrc".to_owned(),
1621 path: "C:/plugins/theme.zsh".to_owned(),
1622 }]
1623 );
1624 }
1625
1626 #[test]
1627 fn rejects_empty_cli_rule_selectors() {
1628 let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
1629
1630 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1631 }
1632
1633 #[test]
1634 fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1635 let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1636
1637 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1638 }
1639
1640 #[test]
1641 fn rejects_add_noqa_alias() {
1642 let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1643
1644 assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1645 }
1646
1647 #[test]
1648 fn rejects_add_ignore_with_fix_flags() {
1649 let error =
1650 StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1651
1652 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1653 }
1654
1655 #[test]
1656 fn rejects_watch_with_add_ignore() {
1657 let error =
1658 StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1659
1660 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1661 }
1662
1663 #[test]
1664 fn check_file_selection_negative_flags_override_positive_flags() {
1665 let args = Args::try_parse_from([
1666 "shuck",
1667 "check",
1668 "--respect-gitignore",
1669 "--no-respect-gitignore",
1670 "--force-exclude",
1671 "--no-force-exclude",
1672 ])
1673 .unwrap();
1674
1675 let Command::Check(command) = args.command else {
1676 panic!("expected check command");
1677 };
1678
1679 assert!(!command.respect_gitignore());
1680 assert!(!command.force_exclude());
1681 }
1682
1683 #[test]
1684 fn zsh_plugin_negative_flag_overrides_positive_flag() {
1685 let args = Args::try_parse_from([
1686 "shuck",
1687 "check",
1688 "--zsh-plugin-resolution",
1689 "--no-zsh-plugin-resolution",
1690 ])
1691 .unwrap();
1692
1693 let Command::Check(command) = args.command else {
1694 panic!("expected check command");
1695 };
1696
1697 assert_eq!(command.zsh_plugin_resolution.resolution(), Some(false));
1698 }
1699
1700 #[test]
1701 fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1702 let args = Args::try_parse_from([
1703 "shuck",
1704 "check",
1705 "--exclude",
1706 "base.sh",
1707 "--extend-exclude",
1708 "extra.sh",
1709 ])
1710 .unwrap();
1711
1712 let Command::Check(command) = args.command else {
1713 panic!("expected check command");
1714 };
1715
1716 assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1717 assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1718 }
1719}