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