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