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 parse() -> Self {
221 Self::try_parse().unwrap_or_else(|err| err.exit())
222 }
223
224 pub fn try_parse() -> Result<Self, clap::Error> {
226 Self::try_parse_from(std::env::args_os())
227 }
228
229 pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
231 where
232 I: IntoIterator<Item = T>,
233 T: Into<OsString> + Clone,
234 {
235 let parsed = parse_with_color::<StableCli, _, _>(itr)?;
236 Self::from_stable(parsed)
237 }
238}
239
240impl Args {
241 fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
242 let StableCli { global, command } = value;
243 let GlobalArgs {
244 cache_dir,
245 config,
246 isolated,
247 color,
248 } = global;
249 let command = match command {
250 StableCommand::Check(command) => Command::Check(command),
251 StableCommand::Server(command) => Command::Server(command),
252 StableCommand::Run(command) => Command::Run(command),
253 StableCommand::Install(command) => Command::Install(command),
254 StableCommand::Shell(command) => Command::Shell(command),
255 StableCommand::Format(command) => Command::Format(command),
256 StableCommand::Clean(command) => Command::Clean(command),
257 };
258
259 Ok(Self {
260 cache_dir,
261 config: ConfigArguments::from_cli(config, isolated)?,
262 color,
263 command,
264 })
265 }
266}
267
268#[derive(Debug, Clone, Subcommand)]
270pub enum Command {
271 Check(Box<CheckCommand>),
273 Server(ServerCommand),
275 Run(RunCommand),
277 Install(InstallCommand),
279 Shell(ShellCommand),
281 Format(FormatCommand),
283 Clean(CleanCommand),
285}
286
287#[derive(Debug, Clone, Default, ClapArgs)]
289pub struct ServerCommand {}
290
291#[derive(Debug, Clone, ClapArgs)]
293pub struct CheckCommand {
294 #[arg(long)]
296 pub fix: bool,
297 #[arg(long = "unsafe-fixes")]
299 pub unsafe_fixes: bool,
300 #[arg(
303 long = "add-ignore",
304 value_name = "REASON",
305 default_missing_value = "",
306 num_args = 0..=1,
307 require_equals = true,
308 conflicts_with = "fix",
309 conflicts_with = "unsafe_fixes",
310 )]
311 pub add_ignore: Option<String>,
312 #[arg(
315 long = "output-format",
316 value_enum,
317 env = "SHUCK_OUTPUT_FORMAT",
318 default_value_t = CheckOutputFormatArg::Full
319 )]
320 pub output_format: CheckOutputFormatArg,
321 #[arg(short = 'w', long, conflicts_with = "add_ignore")]
323 pub watch: bool,
324 pub paths: Vec<PathBuf>,
326 #[arg(long, help_heading = "Miscellaneous")]
328 pub stdin_filename: Option<PathBuf>,
329 #[command(flatten)]
331 pub rule_selection: RuleSelectionArgs,
332 #[command(flatten)]
334 pub zsh_plugin_resolution: ZshPluginArgs,
335 #[command(flatten)]
337 pub file_selection: FileSelectionArgs,
338 #[arg(long = "no-cache", help_heading = "Miscellaneous")]
340 pub no_cache: bool,
341 #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
343 pub exit_zero: bool,
344 #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
346 pub exit_non_zero_on_fix: bool,
347}
348
349impl CheckCommand {
350 pub fn respect_gitignore(&self) -> bool {
352 self.file_selection.respect_gitignore()
353 }
354
355 pub fn force_exclude(&self) -> bool {
357 self.file_selection.force_exclude()
358 }
359}
360
361#[derive(Debug, Clone, ClapArgs)]
363pub struct RunCommand {
364 #[arg(short = 's', long, value_enum)]
366 pub shell: Option<ManagedShellArg>,
367 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
369 pub shell_version: Option<String>,
370 #[arg(long)]
372 pub system: bool,
373 #[arg(long)]
375 pub dry_run: bool,
376 #[arg(short = 'v', long)]
378 pub verbose: bool,
379 #[arg(
381 short = 'c',
382 long = "command",
383 value_name = "COMMAND",
384 conflicts_with = "script"
385 )]
386 pub command: Option<String>,
387 pub script: Option<PathBuf>,
389 #[arg(last = true, value_name = "ARGS")]
391 pub script_args: Vec<OsString>,
392}
393
394#[derive(Debug, Clone, ClapArgs)]
396pub struct InstallCommand {
397 #[arg(long)]
399 pub list: bool,
400 #[arg(long)]
402 pub refresh: bool,
403 #[arg(required_unless_present = "list", value_enum)]
405 pub shell: Option<ManagedShellArg>,
406 #[arg(required_unless_present = "list")]
408 pub version: Option<String>,
409}
410
411#[derive(Debug, Clone, ClapArgs)]
413pub struct ShellCommand {
414 #[arg(short = 's', long, value_enum)]
416 pub shell: Option<ManagedShellArg>,
417 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
419 pub shell_version: Option<String>,
420 #[arg(long)]
422 pub system: bool,
423 #[arg(short = 'v', long)]
425 pub verbose: bool,
426}
427
428#[derive(Debug, Clone, PartialEq, Eq)]
430pub struct PatternRuleSelectorPair {
431 pub pattern: String,
433 pub selector: RuleSelector,
435}
436
437impl std::str::FromStr for PatternRuleSelectorPair {
438 type Err = String;
439
440 fn from_str(value: &str) -> Result<Self, Self::Err> {
441 let (pattern, selector) = value
442 .rsplit_once(':')
443 .ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
444 let pattern = pattern.trim();
445 let selector = selector.trim();
446
447 if pattern.is_empty() || selector.is_empty() {
448 return Err("expected <FilePattern>:<RuleCode>".to_owned());
449 }
450
451 Ok(Self {
452 pattern: pattern.to_owned(),
453 selector: parse_cli_rule_selector(selector)?,
454 })
455 }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct PatternShellPair {
461 pub pattern: String,
463 pub shell: shuck_linter::ShellDialect,
465}
466
467impl std::str::FromStr for PatternShellPair {
468 type Err = String;
469
470 fn from_str(value: &str) -> Result<Self, Self::Err> {
471 let (pattern, shell) = value
472 .rsplit_once(':')
473 .ok_or_else(|| "expected <FilePattern>:<Shell>".to_owned())?;
474 let pattern = pattern.trim();
475 let shell = shell.trim();
476
477 if pattern.is_empty() || shell.is_empty() {
478 return Err("expected <FilePattern>:<Shell>".to_owned());
479 }
480
481 let shell = shuck_linter::ShellDialect::from_name(shell);
482 if shell == shuck_linter::ShellDialect::Unknown {
483 return Err(
484 "expected shell dialect to be one of sh, bash, dash, ksh, mksh, zsh".to_owned(),
485 );
486 }
487
488 Ok(Self {
489 pattern: pattern.to_owned(),
490 shell,
491 })
492 }
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct FrameworkRootPair {
498 pub framework: String,
500 pub path: String,
502}
503
504impl std::str::FromStr for FrameworkRootPair {
505 type Err = String;
506
507 fn from_str(value: &str) -> Result<Self, Self::Err> {
508 let (framework, path) = value
509 .split_once('=')
510 .ok_or_else(|| "expected <Framework>=<Path>".to_owned())?;
511 let framework = framework.trim();
512 let path = path.trim();
513
514 if framework.is_empty() || path.is_empty() {
515 return Err("expected <Framework>=<Path>".to_owned());
516 }
517
518 Ok(Self {
519 framework: framework.to_owned(),
520 path: path.to_owned(),
521 })
522 }
523}
524
525#[derive(Debug, Clone, PartialEq, Eq)]
527pub struct PatternFrameworkNameTriple {
528 pub pattern: String,
530 pub framework: String,
532 pub name: String,
534}
535
536impl std::str::FromStr for PatternFrameworkNameTriple {
537 type Err = String;
538
539 fn from_str(value: &str) -> Result<Self, Self::Err> {
540 let mut parts = value.rsplitn(3, ':');
541 let name = parts
542 .next()
543 .ok_or_else(|| "expected <FilePattern>:<Framework>:<Name>".to_owned())?;
544 let framework = parts
545 .next()
546 .ok_or_else(|| "expected <FilePattern>:<Framework>:<Name>".to_owned())?;
547 let pattern = parts
548 .next()
549 .ok_or_else(|| "expected <FilePattern>:<Framework>:<Name>".to_owned())?;
550 let pattern = pattern.trim();
551 let framework = framework.trim();
552 let name = name.trim();
553
554 if pattern.is_empty() || framework.is_empty() || name.is_empty() {
555 return Err("expected <FilePattern>:<Framework>:<Name>".to_owned());
556 }
557
558 Ok(Self {
559 pattern: pattern.to_owned(),
560 framework: framework.to_owned(),
561 name: name.to_owned(),
562 })
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct PatternPathPair {
569 pub pattern: String,
571 pub path: String,
573}
574
575impl std::str::FromStr for PatternPathPair {
576 type Err = String;
577
578 fn from_str(value: &str) -> Result<Self, Self::Err> {
579 let (pattern, path) = split_pattern_path_pair(value)
580 .ok_or_else(|| "expected <FilePattern>:<Path>".to_owned())?;
581 let pattern = pattern.trim();
582 let path = path.trim();
583
584 if pattern.is_empty() || path.is_empty() {
585 return Err("expected <FilePattern>:<Path>".to_owned());
586 }
587
588 Ok(Self {
589 pattern: pattern.to_owned(),
590 path: path.to_owned(),
591 })
592 }
593}
594
595fn split_pattern_path_pair(value: &str) -> Option<(&str, &str)> {
596 let bytes = value.as_bytes();
597 for (index, byte) in bytes.iter().enumerate() {
598 if *byte != b':' {
599 continue;
600 }
601 if index == 1
602 && bytes.first().is_some_and(|byte| byte.is_ascii_alphabetic())
603 && bytes
604 .get(2)
605 .is_some_and(|byte| *byte == b'/' || *byte == b'\\')
606 {
607 continue;
608 }
609 return Some((&value[..index], &value[index + 1..]));
610 }
611 None
612}
613
614fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
615 let value = value.trim();
616 if value.is_empty() {
617 return Err("rule selector cannot be empty".to_owned());
618 }
619
620 value.parse::<RuleSelector>().map_err(|err| err.to_string())
621}
622
623#[derive(Debug, Clone, Default, ClapArgs)]
625pub struct RuleSelectionArgs {
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 select: Option<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 ignore: Vec<RuleSelector>,
646 #[arg(
648 long,
649 value_delimiter = ',',
650 value_parser = parse_cli_rule_selector,
651 value_name = "RULE_CODE",
652 help_heading = "Rule selection",
653 hide_possible_values = true
654 )]
655 pub extend_select: Vec<RuleSelector>,
656 #[arg(
658 long,
659 value_delimiter = ',',
660 value_name = "PER_FILE_IGNORES",
661 help_heading = "Rule selection"
662 )]
663 pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
664 #[arg(
666 long,
667 value_delimiter = ',',
668 value_name = "EXTEND_PER_FILE_IGNORES",
669 help_heading = "Rule selection"
670 )]
671 pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
672 #[arg(
674 long,
675 value_delimiter = ',',
676 value_name = "PER_FILE_SHELL",
677 help_heading = "Rule selection"
678 )]
679 pub per_file_shell: Option<Vec<PatternShellPair>>,
680 #[arg(
682 long,
683 value_delimiter = ',',
684 value_name = "EXTEND_PER_FILE_SHELL",
685 help_heading = "Rule selection"
686 )]
687 pub extend_per_file_shell: Vec<PatternShellPair>,
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 fixable: Option<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 unfixable: Vec<RuleSelector>,
708 #[arg(
710 long,
711 value_delimiter = ',',
712 value_parser = parse_cli_rule_selector,
713 value_name = "RULE_CODE",
714 help_heading = "Rule selection",
715 hide_possible_values = true
716 )]
717 pub extend_fixable: Vec<RuleSelector>,
718}
719
720#[derive(Debug, Clone, Default, ClapArgs)]
722pub struct ZshPluginArgs {
723 #[arg(
725 long,
726 overrides_with = "no_zsh_plugin_resolution",
727 help_heading = "Zsh plugin resolution"
728 )]
729 pub(crate) zsh_plugin_resolution: bool,
730 #[arg(long, overrides_with = "zsh_plugin_resolution", hide = true)]
731 pub(crate) no_zsh_plugin_resolution: bool,
732 #[arg(
734 long = "zsh-plugin-root",
735 value_delimiter = ',',
736 value_name = "FRAMEWORK=PATH",
737 help_heading = "Zsh plugin resolution"
738 )]
739 pub zsh_plugin_root: Option<Vec<FrameworkRootPair>>,
740 #[arg(
742 long = "extend-zsh-plugin-root",
743 value_delimiter = ',',
744 value_name = "FRAMEWORK=PATH",
745 help_heading = "Zsh plugin resolution"
746 )]
747 pub extend_zsh_plugin_root: Vec<FrameworkRootPair>,
748 #[arg(
750 long = "zsh-plugin",
751 value_delimiter = ',',
752 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
753 help_heading = "Zsh plugin resolution"
754 )]
755 pub zsh_plugin: Option<Vec<PatternFrameworkNameTriple>>,
756 #[arg(
758 long = "extend-zsh-plugin",
759 value_delimiter = ',',
760 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
761 help_heading = "Zsh plugin resolution"
762 )]
763 pub extend_zsh_plugin: Vec<PatternFrameworkNameTriple>,
764 #[arg(
766 long = "zsh-theme",
767 value_delimiter = ',',
768 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
769 help_heading = "Zsh plugin resolution"
770 )]
771 pub zsh_theme: Option<Vec<PatternFrameworkNameTriple>>,
772 #[arg(
774 long = "extend-zsh-theme",
775 value_delimiter = ',',
776 value_name = "FILE_PATTERN:FRAMEWORK:NAME",
777 help_heading = "Zsh plugin resolution"
778 )]
779 pub extend_zsh_theme: Vec<PatternFrameworkNameTriple>,
780 #[arg(
782 long = "zsh-plugin-entrypoint",
783 value_delimiter = ',',
784 value_name = "FILE_PATTERN:PATH",
785 help_heading = "Zsh plugin resolution"
786 )]
787 pub zsh_plugin_entrypoint: Option<Vec<PatternPathPair>>,
788 #[arg(
790 long = "extend-zsh-plugin-entrypoint",
791 value_delimiter = ',',
792 value_name = "FILE_PATTERN:PATH",
793 help_heading = "Zsh plugin resolution"
794 )]
795 pub extend_zsh_plugin_entrypoint: Vec<PatternPathPair>,
796}
797
798impl ZshPluginArgs {
799 pub fn resolution(&self) -> Option<bool> {
801 if self.zsh_plugin_resolution {
802 Some(true)
803 } else if self.no_zsh_plugin_resolution {
804 Some(false)
805 } else {
806 None
807 }
808 }
809}
810
811fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
812where
813 Cli: CommandFactory + FromArgMatches,
814 I: IntoIterator<Item = T>,
815 T: Into<OsString> + Clone,
816{
817 let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
818 let mut command = Cli::command().color(command_color_choice(&args));
819 let matches = command.try_get_matches_from_mut(args)?;
820 Cli::from_arg_matches(&matches)
821}
822
823fn command_color_choice(args: &[OsString]) -> ColorChoice {
824 match preparse_color(args) {
825 Some(ColorChoice::Always) => ColorChoice::Always,
826 Some(ColorChoice::Never) => ColorChoice::Never,
827 Some(ColorChoice::Auto) | None => {
828 if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
829 ColorChoice::Always
830 } else {
831 ColorChoice::Auto
832 }
833 }
834 }
835}
836
837fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
838 let mut expect_value = false;
839 let mut color = None;
840
841 for argument in args.iter().skip(1) {
842 if expect_value {
843 let value = argument.to_string_lossy();
844 color = value.parse().ok();
845 expect_value = false;
846 continue;
847 }
848
849 let argument = argument.to_string_lossy();
850 if argument == "--" {
851 break;
852 }
853 if argument == "--color" {
854 expect_value = true;
855 continue;
856 }
857 if let Some(value) = argument.strip_prefix("--color=") {
858 color = value.parse().ok();
859 }
860 }
861
862 color
863}
864
865#[derive(Debug, Clone, Default, ClapArgs)]
867pub struct FileSelectionArgs {
868 #[arg(
870 long,
871 value_delimiter = ',',
872 value_name = "FILE_PATTERN",
873 help_heading = "File selection"
874 )]
875 pub exclude: Vec<String>,
876 #[arg(
878 long,
879 value_delimiter = ',',
880 value_name = "FILE_PATTERN",
881 help_heading = "File selection"
882 )]
883 pub extend_exclude: Vec<String>,
884 #[arg(
887 long,
888 overrides_with = "no_respect_gitignore",
889 help_heading = "File selection"
890 )]
891 pub(crate) respect_gitignore: bool,
892 #[arg(long, overrides_with = "respect_gitignore", hide = true)]
893 pub(crate) no_respect_gitignore: bool,
894 #[arg(
897 long,
898 overrides_with = "no_force_exclude",
899 help_heading = "File selection"
900 )]
901 pub(crate) force_exclude: bool,
902 #[arg(long, overrides_with = "force_exclude", hide = true)]
903 pub(crate) no_force_exclude: bool,
904}
905
906impl FileSelectionArgs {
907 pub fn respect_gitignore(&self) -> bool {
909 resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
910 }
911
912 pub fn force_exclude(&self) -> bool {
914 resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
915 }
916}
917
918#[derive(Debug, Clone, ClapArgs)]
920pub struct FormatCommand {
921 pub files: Vec<PathBuf>,
923 #[arg(long)]
925 pub check: bool,
926 #[arg(long)]
928 pub diff: bool,
929 #[arg(long = "no-cache")]
931 pub no_cache: bool,
932 #[arg(long)]
934 pub stdin_filename: Option<PathBuf>,
935 #[command(flatten)]
937 pub file_selection: FileSelectionArgs,
938 #[arg(long, value_enum)]
940 pub dialect: Option<FormatDialectArg>,
941 #[arg(long, value_enum)]
943 pub indent_style: Option<FormatIndentStyleArg>,
944 #[arg(long, value_name = "WIDTH")]
946 pub indent_width: Option<u8>,
947 #[arg(long, overrides_with = "no_binary_next_line")]
949 pub(crate) binary_next_line: bool,
950 #[arg(
951 long = "no-binary-next-line",
952 overrides_with = "binary_next_line",
953 hide = true
954 )]
955 pub(crate) no_binary_next_line: bool,
956 #[arg(long, overrides_with = "no_switch_case_indent")]
958 pub(crate) switch_case_indent: bool,
959 #[arg(
960 long = "no-switch-case-indent",
961 overrides_with = "switch_case_indent",
962 hide = true
963 )]
964 pub(crate) no_switch_case_indent: bool,
965 #[arg(long, overrides_with = "no_space_redirects")]
967 pub(crate) space_redirects: bool,
968 #[arg(
969 long = "no-space-redirects",
970 overrides_with = "space_redirects",
971 hide = true
972 )]
973 pub(crate) no_space_redirects: bool,
974 #[arg(long, overrides_with = "no_keep_padding")]
976 pub(crate) keep_padding: bool,
977 #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
978 pub(crate) no_keep_padding: bool,
979 #[arg(long, overrides_with = "no_function_next_line")]
981 pub(crate) function_next_line: bool,
982 #[arg(
983 long = "no-function-next-line",
984 overrides_with = "function_next_line",
985 hide = true
986 )]
987 pub(crate) no_function_next_line: bool,
988 #[arg(long, overrides_with = "no_never_split")]
990 pub(crate) never_split: bool,
991 #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
992 pub(crate) no_never_split: bool,
993 #[arg(long)]
995 pub simplify: bool,
996 #[arg(long)]
998 pub minify: bool,
999}
1000
1001impl FormatCommand {
1002 pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
1003 FormatSettingsPatch {
1004 dialect: self.dialect.map(Into::into),
1005 indent_style: self.indent_style.map(Into::into),
1006 indent_width: self.indent_width,
1007 binary_next_line: self.binary_next_line(),
1008 switch_case_indent: self.switch_case_indent(),
1009 space_redirects: self.space_redirects(),
1010 keep_padding: self.keep_padding(),
1011 function_next_line: self.function_next_line(),
1012 never_split: self.never_split(),
1013 simplify: self.simplify.then_some(true),
1014 minify: self.minify.then_some(true),
1015 }
1016 }
1017
1018 pub fn binary_next_line(&self) -> Option<bool> {
1020 tri_state_bool(self.binary_next_line, self.no_binary_next_line)
1021 }
1022
1023 pub fn switch_case_indent(&self) -> Option<bool> {
1025 tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
1026 }
1027
1028 pub fn space_redirects(&self) -> Option<bool> {
1030 tri_state_bool(self.space_redirects, self.no_space_redirects)
1031 }
1032
1033 pub fn keep_padding(&self) -> Option<bool> {
1035 tri_state_bool(self.keep_padding, self.no_keep_padding)
1036 }
1037
1038 pub fn function_next_line(&self) -> Option<bool> {
1040 tri_state_bool(self.function_next_line, self.no_function_next_line)
1041 }
1042
1043 pub fn never_split(&self) -> Option<bool> {
1045 tri_state_bool(self.never_split, self.no_never_split)
1046 }
1047
1048 pub fn respect_gitignore(&self) -> bool {
1050 self.file_selection.respect_gitignore()
1051 }
1052
1053 pub fn force_exclude(&self) -> bool {
1055 self.file_selection.force_exclude()
1056 }
1057}
1058
1059fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
1060 match (positive, negative) {
1061 (false, false) => None,
1062 (true, false) => Some(true),
1063 (false, true) => Some(false),
1064 (true, true) => unreachable!("clap should make this impossible"),
1068 }
1069}
1070
1071fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
1072 match (positive, negative) {
1073 (false, false) => default,
1074 (true, false) => true,
1075 (false, true) => false,
1076 (true, true) => unreachable!("clap should make this impossible"),
1079 }
1080}
1081
1082#[derive(Debug, Clone, ClapArgs)]
1084pub struct CleanCommand {
1085 pub paths: Vec<PathBuf>,
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092 use clap::builder::TypedValueParser;
1093 use clap::error::ErrorKind;
1094 use shuck_linter::Rule;
1095
1096 #[test]
1097 fn global_config_override_is_available_after_subcommand() {
1098 let command = StableCli::command();
1099 let override_argument = shuck_config::ConfigArgumentParser
1100 .parse_ref(
1101 &command,
1102 None,
1103 std::ffi::OsStr::new("format.indent-width = 2"),
1104 )
1105 .unwrap();
1106
1107 let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
1108 .unwrap();
1109
1110 assert_eq!(
1111 args.config,
1112 ConfigArguments::from_cli(vec![override_argument], false).unwrap()
1113 );
1114 }
1115
1116 #[test]
1117 fn explicit_config_file_and_inline_override_both_parse_globally() {
1118 let tempdir = tempfile::tempdir().unwrap();
1119 let config_path = tempdir.path().join("shuck.toml");
1120 std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
1121 let command = StableCli::command();
1122 let override_argument = shuck_config::ConfigArgumentParser
1123 .parse_ref(
1124 &command,
1125 None,
1126 std::ffi::OsStr::new("format.function-next-line = true"),
1127 )
1128 .unwrap();
1129
1130 let args = Args::try_parse_from([
1131 "shuck",
1132 "--config",
1133 config_path.to_str().unwrap(),
1134 "--config",
1135 "format.function-next-line = true",
1136 "check",
1137 ])
1138 .unwrap();
1139
1140 assert_eq!(
1141 args.config,
1142 ConfigArguments::from_cli(
1143 vec![
1144 SingleConfigArgument::FilePath(config_path),
1145 override_argument
1146 ],
1147 false,
1148 )
1149 .unwrap()
1150 );
1151 }
1152
1153 #[test]
1154 fn global_color_can_be_parsed_before_subcommand() {
1155 let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
1156 assert_eq!(args.color, Some(TerminalColor::Never));
1157 }
1158
1159 #[test]
1160 fn preparse_color_uses_last_value() {
1161 assert_eq!(
1162 preparse_color(&[
1163 OsString::from("shuck"),
1164 OsString::from("--color=always"),
1165 OsString::from("--color"),
1166 OsString::from("never"),
1167 ]),
1168 Some(ColorChoice::Never)
1169 );
1170 }
1171
1172 fn parse_check<I, T>(args: I) -> CheckCommand
1173 where
1174 I: IntoIterator<Item = T>,
1175 T: Into<OsString> + Clone,
1176 {
1177 let parsed = StableCli::try_parse_from(args).unwrap();
1178 match Args::from_stable(parsed).unwrap().command {
1179 Command::Check(command) => *command,
1180 command => panic!("expected check command, got {command:?}"),
1181 }
1182 }
1183
1184 fn parse_run<I, T>(args: I) -> RunCommand
1185 where
1186 I: IntoIterator<Item = T>,
1187 T: Into<OsString> + Clone,
1188 {
1189 let parsed = StableCli::try_parse_from(args).unwrap();
1190 match Args::from_stable(parsed).unwrap().command {
1191 Command::Run(command) => command,
1192 command => panic!("expected run command, got {command:?}"),
1193 }
1194 }
1195
1196 fn parse_install<I, T>(args: I) -> InstallCommand
1197 where
1198 I: IntoIterator<Item = T>,
1199 T: Into<OsString> + Clone,
1200 {
1201 let parsed = StableCli::try_parse_from(args).unwrap();
1202 match Args::from_stable(parsed).unwrap().command {
1203 Command::Install(command) => command,
1204 command => panic!("expected install command, got {command:?}"),
1205 }
1206 }
1207
1208 fn parse_shell<I, T>(args: I) -> ShellCommand
1209 where
1210 I: IntoIterator<Item = T>,
1211 T: Into<OsString> + Clone,
1212 {
1213 let parsed = StableCli::try_parse_from(args).unwrap();
1214 match Args::from_stable(parsed).unwrap().command {
1215 Command::Shell(command) => command,
1216 command => panic!("expected shell command, got {command:?}"),
1217 }
1218 }
1219
1220 #[test]
1221 fn parses_add_ignore_without_reason() {
1222 let command = parse_check(["shuck", "check", "--add-ignore"]);
1223
1224 assert_eq!(command.add_ignore, Some(String::new()));
1225 }
1226
1227 #[test]
1228 fn parses_add_ignore_with_reason() {
1229 let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
1230
1231 assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
1232 }
1233
1234 #[test]
1235 fn parses_short_watch_flag() {
1236 let command = parse_check(["shuck", "check", "-w"]);
1237
1238 assert!(command.watch);
1239 }
1240
1241 #[test]
1242 fn parses_long_watch_flag() {
1243 let command = parse_check(["shuck", "check", "--watch"]);
1244
1245 assert!(command.watch);
1246 }
1247
1248 #[test]
1249 fn parses_all_check_output_formats() {
1250 for (raw, expected) in [
1251 ("concise", CheckOutputFormatArg::Concise),
1252 ("full", CheckOutputFormatArg::Full),
1253 ("json", CheckOutputFormatArg::Json),
1254 ("json-lines", CheckOutputFormatArg::JsonLines),
1255 ("junit", CheckOutputFormatArg::Junit),
1256 ("grouped", CheckOutputFormatArg::Grouped),
1257 ("github", CheckOutputFormatArg::Github),
1258 ("gitlab", CheckOutputFormatArg::Gitlab),
1259 ("rdjson", CheckOutputFormatArg::Rdjson),
1260 ("sarif", CheckOutputFormatArg::Sarif),
1261 ] {
1262 let command = parse_check(["shuck", "check", "--output-format", raw]);
1263 assert_eq!(command.output_format, expected, "failed to parse {raw}");
1264 }
1265 }
1266
1267 #[test]
1268 fn parses_run_command_flags_and_passthrough_args() {
1269 let command = parse_run([
1270 "shuck",
1271 "run",
1272 "--shell",
1273 "bash",
1274 "--shell-version",
1275 "5.2",
1276 "--system",
1277 "--dry-run",
1278 "--verbose",
1279 "deploy.sh",
1280 "--",
1281 "--env",
1282 "staging",
1283 ]);
1284
1285 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1286 assert_eq!(command.shell_version.as_deref(), Some("5.2"));
1287 assert!(command.system);
1288 assert!(command.dry_run);
1289 assert!(command.verbose);
1290 assert_eq!(
1291 command.script.as_deref(),
1292 Some(PathBuf::from("deploy.sh").as_path())
1293 );
1294 assert_eq!(
1295 command.script_args,
1296 vec![OsString::from("--env"), OsString::from("staging")]
1297 );
1298 }
1299
1300 #[test]
1301 fn parses_run_command_string_mode() {
1302 let command = parse_run([
1303 "shuck", "run", "-s", "bash", "-c", "echo hi", "--", "one", "two",
1304 ]);
1305
1306 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1307 assert_eq!(command.command.as_deref(), Some("echo hi"));
1308 assert!(command.script.is_none());
1309 assert_eq!(
1310 command.script_args,
1311 vec![OsString::from("one"), OsString::from("two")]
1312 );
1313 }
1314
1315 #[test]
1316 fn parses_busybox_shell_variants() {
1317 let run = parse_run(["shuck", "run", "--shell", "busybox", "deploy.sh"]);
1318 assert_eq!(run.shell, Some(ManagedShellArg::Busybox));
1319
1320 let install = parse_install(["shuck", "install", "busybox", "1.36"]);
1321 assert_eq!(install.shell, Some(ManagedShellArg::Busybox));
1322
1323 let shell = parse_shell(["shuck", "shell", "--shell", "busybox"]);
1324 assert_eq!(shell.shell, Some(ManagedShellArg::Busybox));
1325 }
1326
1327 #[test]
1328 fn parses_install_list_without_version() {
1329 let command = parse_install(["shuck", "install", "--list", "bash"]);
1330 assert!(command.list);
1331 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1332 assert!(command.version.is_none());
1333 }
1334
1335 #[test]
1336 fn parses_shell_command_flags() {
1337 let command = parse_shell([
1338 "shuck",
1339 "shell",
1340 "--shell",
1341 "zsh",
1342 "--shell-version",
1343 "5.9",
1344 "--system",
1345 "--verbose",
1346 ]);
1347
1348 assert_eq!(command.shell, Some(ManagedShellArg::Zsh));
1349 assert_eq!(command.shell_version.as_deref(), Some("5.9"));
1350 assert!(command.system);
1351 assert!(command.verbose);
1352 }
1353
1354 #[test]
1355 fn parses_extended_managed_shell_names() {
1356 let run_command = parse_run(["shuck", "run", "--shell", "gbash", "-c", "echo hi"]);
1357 assert_eq!(run_command.shell, Some(ManagedShellArg::Gbash));
1358
1359 let install_command = parse_install(["shuck", "install", "--list", "bashkit"]);
1360 assert_eq!(install_command.shell, Some(ManagedShellArg::Bashkit));
1361 }
1362
1363 #[test]
1364 fn parses_rule_selection_flags() {
1365 let command = parse_check([
1366 "shuck",
1367 "check",
1368 "--select",
1369 "C001",
1370 "--select",
1371 "S,C002",
1372 "--ignore",
1373 "C003,C004",
1374 "--extend-select",
1375 "X",
1376 "--fixable",
1377 "ALL",
1378 "--unfixable",
1379 "C001",
1380 "--extend-fixable",
1381 "S074",
1382 ]);
1383
1384 assert_eq!(
1385 command.rule_selection.select,
1386 Some(vec![
1387 RuleSelector::Rule(Rule::UnusedAssignment),
1388 RuleSelector::Category(shuck_linter::Category::Style),
1389 RuleSelector::Rule(Rule::DynamicSourcePath),
1390 ])
1391 );
1392 assert_eq!(
1393 command.rule_selection.ignore,
1394 vec![
1395 RuleSelector::Rule(Rule::UntrackedSourceFile),
1396 RuleSelector::Rule(Rule::UncheckedDirectoryChange),
1397 ]
1398 );
1399 assert_eq!(
1400 command.rule_selection.extend_select,
1401 vec![RuleSelector::Category(shuck_linter::Category::Portability)]
1402 );
1403 assert_eq!(
1404 command.rule_selection.fixable,
1405 Some(vec![RuleSelector::All])
1406 );
1407 assert_eq!(
1408 command.rule_selection.unfixable,
1409 vec![RuleSelector::Rule(Rule::UnusedAssignment)]
1410 );
1411 assert_eq!(
1412 command.rule_selection.extend_fixable,
1413 vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
1414 );
1415 }
1416
1417 #[test]
1418 fn parses_named_rule_selection_flags() {
1419 let command = parse_check([
1420 "shuck",
1421 "check",
1422 "--select",
1423 "google",
1424 "--extend-select",
1425 "google",
1426 "--fixable",
1427 "google",
1428 ]);
1429
1430 assert_eq!(
1431 command.rule_selection.select,
1432 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1433 );
1434 assert_eq!(
1435 command.rule_selection.extend_select,
1436 vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)]
1437 );
1438 assert_eq!(
1439 command.rule_selection.fixable,
1440 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1441 );
1442 }
1443
1444 #[test]
1445 fn parses_per_file_ignore_pairs() {
1446 let command = parse_check([
1447 "shuck",
1448 "check",
1449 "--per-file-ignores",
1450 "tests/*.sh:C001",
1451 "--extend-per-file-ignores",
1452 "!src/*.sh:S",
1453 ]);
1454
1455 assert_eq!(
1456 command.rule_selection.per_file_ignores,
1457 Some(vec![PatternRuleSelectorPair {
1458 pattern: "tests/*.sh".to_owned(),
1459 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1460 }])
1461 );
1462 assert_eq!(
1463 command.rule_selection.extend_per_file_ignores,
1464 vec![PatternRuleSelectorPair {
1465 pattern: "!src/*.sh".to_owned(),
1466 selector: RuleSelector::Category(shuck_linter::Category::Style),
1467 }]
1468 );
1469 }
1470
1471 #[test]
1472 fn parses_named_per_file_ignore_pairs() {
1473 let command = parse_check(["shuck", "check", "--per-file-ignores", "tests/*.sh:google"]);
1474
1475 assert_eq!(
1476 command.rule_selection.per_file_ignores,
1477 Some(vec![PatternRuleSelectorPair {
1478 pattern: "tests/*.sh".to_owned(),
1479 selector: RuleSelector::Named(shuck_linter::NamedGroup::Google),
1480 }])
1481 );
1482 }
1483
1484 #[test]
1485 fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
1486 let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
1487
1488 assert_eq!(
1489 command.rule_selection.per_file_ignores,
1490 Some(vec![PatternRuleSelectorPair {
1491 pattern: r"C:\repo\*.sh".to_owned(),
1492 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1493 }])
1494 );
1495 }
1496
1497 #[test]
1498 fn parses_per_file_shell_pairs() {
1499 let command = parse_check([
1500 "shuck",
1501 "check",
1502 "--per-file-shell",
1503 "tests/*.sh:bash",
1504 "--extend-per-file-shell",
1505 "!src/*.sh:zsh",
1506 ]);
1507
1508 assert_eq!(
1509 command.rule_selection.per_file_shell,
1510 Some(vec![PatternShellPair {
1511 pattern: "tests/*.sh".to_owned(),
1512 shell: shuck_linter::ShellDialect::Bash,
1513 }])
1514 );
1515 assert_eq!(
1516 command.rule_selection.extend_per_file_shell,
1517 vec![PatternShellPair {
1518 pattern: "!src/*.sh".to_owned(),
1519 shell: shuck_linter::ShellDialect::Zsh,
1520 }]
1521 );
1522 }
1523
1524 #[test]
1525 fn parses_zsh_plugin_resolution_pairs() {
1526 let command = parse_check([
1527 "shuck",
1528 "check",
1529 "--zsh-plugin-root",
1530 "oh-my-zsh=~/.oh-my-zsh",
1531 "--extend-zsh-plugin-root",
1532 "custom=./vendor/plugins",
1533 "--zsh-plugin",
1534 "tests/.zshrc:oh-my-zsh:git",
1535 "--extend-zsh-plugin",
1536 "!src/.zshrc:oh-my-zsh:docker",
1537 "--zsh-theme",
1538 "tests/.zshrc:oh-my-zsh:agnoster",
1539 "--extend-zsh-theme",
1540 "!src/.zshrc:oh-my-zsh:robbyrussell",
1541 "--zsh-plugin-entrypoint",
1542 "tests/.zshrc:./vendor/prompt.plugin.zsh",
1543 "--extend-zsh-plugin-entrypoint",
1544 "!src/.zshrc:./vendor/theme.zsh",
1545 ]);
1546
1547 assert_eq!(
1548 command.zsh_plugin_resolution.zsh_plugin_root,
1549 Some(vec![FrameworkRootPair {
1550 framework: "oh-my-zsh".to_owned(),
1551 path: "~/.oh-my-zsh".to_owned(),
1552 }])
1553 );
1554 assert_eq!(
1555 command.zsh_plugin_resolution.extend_zsh_plugin_root,
1556 vec![FrameworkRootPair {
1557 framework: "custom".to_owned(),
1558 path: "./vendor/plugins".to_owned(),
1559 }]
1560 );
1561 assert_eq!(
1562 command.zsh_plugin_resolution.zsh_plugin,
1563 Some(vec![PatternFrameworkNameTriple {
1564 pattern: "tests/.zshrc".to_owned(),
1565 framework: "oh-my-zsh".to_owned(),
1566 name: "git".to_owned(),
1567 }])
1568 );
1569 assert_eq!(
1570 command.zsh_plugin_resolution.extend_zsh_plugin,
1571 vec![PatternFrameworkNameTriple {
1572 pattern: "!src/.zshrc".to_owned(),
1573 framework: "oh-my-zsh".to_owned(),
1574 name: "docker".to_owned(),
1575 }]
1576 );
1577 assert_eq!(
1578 command.zsh_plugin_resolution.zsh_theme,
1579 Some(vec![PatternFrameworkNameTriple {
1580 pattern: "tests/.zshrc".to_owned(),
1581 framework: "oh-my-zsh".to_owned(),
1582 name: "agnoster".to_owned(),
1583 }])
1584 );
1585 assert_eq!(
1586 command.zsh_plugin_resolution.extend_zsh_theme,
1587 vec![PatternFrameworkNameTriple {
1588 pattern: "!src/.zshrc".to_owned(),
1589 framework: "oh-my-zsh".to_owned(),
1590 name: "robbyrussell".to_owned(),
1591 }]
1592 );
1593 assert_eq!(
1594 command.zsh_plugin_resolution.zsh_plugin_entrypoint,
1595 Some(vec![PatternPathPair {
1596 pattern: "tests/.zshrc".to_owned(),
1597 path: "./vendor/prompt.plugin.zsh".to_owned(),
1598 }])
1599 );
1600 assert_eq!(
1601 command.zsh_plugin_resolution.extend_zsh_plugin_entrypoint,
1602 vec![PatternPathPair {
1603 pattern: "!src/.zshrc".to_owned(),
1604 path: "./vendor/theme.zsh".to_owned(),
1605 }]
1606 );
1607 }
1608
1609 #[test]
1610 fn parses_zsh_plugin_entrypoints_with_windows_absolute_paths() {
1611 let command = parse_check([
1612 "shuck",
1613 "check",
1614 "--zsh-plugin-entrypoint",
1615 r"**/.zshrc:C:/plugins/git.plugin.zsh",
1616 "--extend-zsh-plugin-entrypoint",
1617 r"C:/repo/**/*.zshrc:C:/plugins/theme.zsh",
1618 ]);
1619
1620 assert_eq!(
1621 command.zsh_plugin_resolution.zsh_plugin_entrypoint,
1622 Some(vec![PatternPathPair {
1623 pattern: "**/.zshrc".to_owned(),
1624 path: "C:/plugins/git.plugin.zsh".to_owned(),
1625 }])
1626 );
1627 assert_eq!(
1628 command.zsh_plugin_resolution.extend_zsh_plugin_entrypoint,
1629 vec![PatternPathPair {
1630 pattern: r"C:/repo/**/*.zshrc".to_owned(),
1631 path: "C:/plugins/theme.zsh".to_owned(),
1632 }]
1633 );
1634 }
1635
1636 #[test]
1637 fn rejects_empty_cli_rule_selectors() {
1638 let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
1639
1640 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1641 }
1642
1643 #[test]
1644 fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1645 let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1646
1647 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1648 }
1649
1650 #[test]
1651 fn rejects_add_noqa_alias() {
1652 let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1653
1654 assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1655 }
1656
1657 #[test]
1658 fn rejects_add_ignore_with_fix_flags() {
1659 let error =
1660 StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1661
1662 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1663 }
1664
1665 #[test]
1666 fn rejects_watch_with_add_ignore() {
1667 let error =
1668 StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1669
1670 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1671 }
1672
1673 #[test]
1674 fn check_file_selection_negative_flags_override_positive_flags() {
1675 let args = Args::try_parse_from([
1676 "shuck",
1677 "check",
1678 "--respect-gitignore",
1679 "--no-respect-gitignore",
1680 "--force-exclude",
1681 "--no-force-exclude",
1682 ])
1683 .unwrap();
1684
1685 let Command::Check(command) = args.command else {
1686 panic!("expected check command");
1687 };
1688
1689 assert!(!command.respect_gitignore());
1690 assert!(!command.force_exclude());
1691 }
1692
1693 #[test]
1694 fn zsh_plugin_negative_flag_overrides_positive_flag() {
1695 let args = Args::try_parse_from([
1696 "shuck",
1697 "check",
1698 "--zsh-plugin-resolution",
1699 "--no-zsh-plugin-resolution",
1700 ])
1701 .unwrap();
1702
1703 let Command::Check(command) = args.command else {
1704 panic!("expected check command");
1705 };
1706
1707 assert_eq!(command.zsh_plugin_resolution.resolution(), Some(false));
1708 }
1709
1710 #[test]
1711 fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1712 let args = Args::try_parse_from([
1713 "shuck",
1714 "check",
1715 "--exclude",
1716 "base.sh",
1717 "--extend-exclude",
1718 "extra.sh",
1719 ])
1720 .unwrap();
1721
1722 let Command::Check(command) = args.command else {
1723 panic!("expected check command");
1724 };
1725
1726 assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1727 assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1728 }
1729}