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(styles = STYLES)]
143struct StableCli {
144 #[command(flatten)]
145 global: GlobalArgs,
146 #[command(subcommand)]
147 command: StableCommand,
148}
149
150#[derive(Debug, Parser)]
151#[command(name = "shuck")]
152#[command(about = "Shell checker CLI for shuck")]
153#[command(styles = STYLES)]
154struct ExperimentalCli {
155 #[command(flatten)]
156 global: GlobalArgs,
157 #[command(subcommand)]
158 command: ExperimentalCommand,
159}
160
161#[derive(Debug, Clone, ClapArgs)]
162struct GlobalArgs {
163 #[arg(
170 long,
171 action = clap::ArgAction::Append,
172 value_name = "CONFIG_OPTION",
173 value_parser = ConfigArgumentParser,
174 global = true,
175 help_heading = "Global options"
176 )]
177 config: Vec<SingleConfigArgument>,
178 #[arg(long, global = true, help_heading = "Global options")]
180 isolated: bool,
181 #[arg(
183 long,
184 value_enum,
185 value_name = "WHEN",
186 global = true,
187 help_heading = "Global options"
188 )]
189 color: Option<TerminalColor>,
190 #[arg(
192 long,
193 env = "SHUCK_CACHE_DIR",
194 global = true,
195 value_name = "PATH",
196 help_heading = "Miscellaneous"
197 )]
198 cache_dir: Option<PathBuf>,
199}
200
201#[derive(Debug, Subcommand)]
202enum StableCommand {
203 Check(Box<CheckCommand>),
205 Server(ServerCommand),
207 Run(RunCommand),
209 Install(InstallCommand),
211 Shell(ShellCommand),
213 #[command(hide = true)]
214 Format(FormatCommand),
215 Clean(CleanCommand),
217}
218
219#[derive(Debug, Subcommand)]
220enum ExperimentalCommand {
221 Check(Box<CheckCommand>),
223 Server(ServerCommand),
225 Run(RunCommand),
227 Install(InstallCommand),
229 Shell(ShellCommand),
231 Format(FormatCommand),
233 Clean(CleanCommand),
235}
236
237#[derive(Debug, Clone)]
239pub struct Args {
240 pub cache_dir: Option<PathBuf>,
242 pub(crate) config: ConfigArguments,
243 pub(crate) color: Option<TerminalColor>,
244 pub command: Command,
246}
247
248impl Args {
249 pub fn parse() -> Self {
251 Self::try_parse().unwrap_or_else(|err| err.exit())
252 }
253
254 pub fn try_parse() -> Result<Self, clap::Error> {
256 Self::try_parse_from(std::env::args_os())
257 }
258
259 pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
261 where
262 I: IntoIterator<Item = T>,
263 T: Into<OsString> + Clone,
264 {
265 if experimental_enabled() {
266 let parsed = parse_with_color::<ExperimentalCli, _, _>(itr)?;
267 Self::from_experimental(parsed)
268 } else {
269 let parsed = parse_with_color::<StableCli, _, _>(itr)?;
270 Self::from_stable(parsed)
271 }
272 }
273}
274
275impl Args {
276 fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
277 let StableCli { global, command } = value;
278 let GlobalArgs {
279 cache_dir,
280 config,
281 isolated,
282 color,
283 } = global;
284 let command = match command {
285 StableCommand::Check(command) => Command::Check(command),
286 StableCommand::Server(command) => Command::Server(command),
287 StableCommand::Run(command) => Command::Run(command),
288 StableCommand::Install(command) => Command::Install(command),
289 StableCommand::Shell(command) => Command::Shell(command),
290 StableCommand::Format(_) => {
291 return Err(clap::Error::raw(
292 ErrorKind::InvalidSubcommand,
293 format!(
294 "the `format` subcommand is experimental; set {EXPERIMENTAL_ENV_VAR}=1 to enable it"
295 ),
296 ));
297 }
298 StableCommand::Clean(command) => Command::Clean(command),
299 };
300
301 Ok(Self {
302 cache_dir,
303 config: ConfigArguments::from_cli(config, isolated)?,
304 color,
305 command,
306 })
307 }
308
309 fn from_experimental(value: ExperimentalCli) -> Result<Self, clap::Error> {
310 let ExperimentalCli { global, command } = value;
311 let GlobalArgs {
312 cache_dir,
313 config,
314 isolated,
315 color,
316 } = global;
317 let command = match command {
318 ExperimentalCommand::Check(command) => Command::Check(command),
319 ExperimentalCommand::Server(command) => Command::Server(command),
320 ExperimentalCommand::Run(command) => Command::Run(command),
321 ExperimentalCommand::Install(command) => Command::Install(command),
322 ExperimentalCommand::Shell(command) => Command::Shell(command),
323 ExperimentalCommand::Format(command) => Command::Format(command),
324 ExperimentalCommand::Clean(command) => Command::Clean(command),
325 };
326
327 Ok(Self {
328 cache_dir,
329 config: ConfigArguments::from_cli(config, isolated)?,
330 color,
331 command,
332 })
333 }
334}
335
336#[derive(Debug, Clone, Subcommand)]
338pub enum Command {
339 Check(Box<CheckCommand>),
341 Server(ServerCommand),
343 Run(RunCommand),
345 Install(InstallCommand),
347 Shell(ShellCommand),
349 Format(FormatCommand),
351 Clean(CleanCommand),
353}
354
355fn experimental_enabled() -> bool {
356 std::env::var_os(EXPERIMENTAL_ENV_VAR).is_some_and(|value| {
357 !matches!(
358 value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
359 "" | "0" | "false" | "no" | "off"
360 )
361 })
362}
363
364#[derive(Debug, Clone, Default, ClapArgs)]
366pub struct ServerCommand {}
367
368#[derive(Debug, Clone, ClapArgs)]
370pub struct CheckCommand {
371 #[arg(long)]
373 pub fix: bool,
374 #[arg(long = "unsafe-fixes")]
376 pub unsafe_fixes: bool,
377 #[arg(
380 long = "add-ignore",
381 value_name = "REASON",
382 default_missing_value = "",
383 num_args = 0..=1,
384 require_equals = true,
385 conflicts_with = "fix",
386 conflicts_with = "unsafe_fixes",
387 )]
388 pub add_ignore: Option<String>,
389 #[arg(
392 long = "output-format",
393 value_enum,
394 env = "SHUCK_OUTPUT_FORMAT",
395 default_value_t = CheckOutputFormatArg::Full
396 )]
397 pub output_format: CheckOutputFormatArg,
398 #[arg(short = 'w', long, conflicts_with = "add_ignore")]
400 pub watch: bool,
401 pub paths: Vec<PathBuf>,
403 #[command(flatten)]
405 pub rule_selection: RuleSelectionArgs,
406 #[command(flatten)]
408 pub file_selection: FileSelectionArgs,
409 #[arg(long = "no-cache", help_heading = "Miscellaneous")]
411 pub no_cache: bool,
412 #[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
414 pub exit_zero: bool,
415 #[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
417 pub exit_non_zero_on_fix: bool,
418}
419
420impl CheckCommand {
421 pub fn respect_gitignore(&self) -> bool {
423 self.file_selection.respect_gitignore()
424 }
425
426 pub fn force_exclude(&self) -> bool {
428 self.file_selection.force_exclude()
429 }
430}
431
432#[derive(Debug, Clone, ClapArgs)]
434pub struct RunCommand {
435 #[arg(short = 's', long, value_enum)]
437 pub shell: Option<ManagedShellArg>,
438 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
440 pub shell_version: Option<String>,
441 #[arg(long)]
443 pub system: bool,
444 #[arg(long)]
446 pub dry_run: bool,
447 #[arg(short = 'v', long)]
449 pub verbose: bool,
450 #[arg(
452 short = 'c',
453 long = "command",
454 value_name = "COMMAND",
455 conflicts_with = "script"
456 )]
457 pub command: Option<String>,
458 pub script: Option<PathBuf>,
460 #[arg(last = true, value_name = "ARGS")]
462 pub script_args: Vec<OsString>,
463}
464
465#[derive(Debug, Clone, ClapArgs)]
467pub struct InstallCommand {
468 #[arg(long)]
470 pub list: bool,
471 #[arg(long)]
473 pub refresh: bool,
474 #[arg(required_unless_present = "list", value_enum)]
476 pub shell: Option<ManagedShellArg>,
477 #[arg(required_unless_present = "list")]
479 pub version: Option<String>,
480}
481
482#[derive(Debug, Clone, ClapArgs)]
484pub struct ShellCommand {
485 #[arg(short = 's', long, value_enum)]
487 pub shell: Option<ManagedShellArg>,
488 #[arg(short = 'V', long = "shell-version", value_name = "CONSTRAINT")]
490 pub shell_version: Option<String>,
491 #[arg(long)]
493 pub system: bool,
494 #[arg(short = 'v', long)]
496 pub verbose: bool,
497}
498
499#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct PatternRuleSelectorPair {
502 pub pattern: String,
504 pub selector: RuleSelector,
506}
507
508impl std::str::FromStr for PatternRuleSelectorPair {
509 type Err = String;
510
511 fn from_str(value: &str) -> Result<Self, Self::Err> {
512 let (pattern, selector) = value
513 .rsplit_once(':')
514 .ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
515 let pattern = pattern.trim();
516 let selector = selector.trim();
517
518 if pattern.is_empty() || selector.is_empty() {
519 return Err("expected <FilePattern>:<RuleCode>".to_owned());
520 }
521
522 Ok(Self {
523 pattern: pattern.to_owned(),
524 selector: parse_cli_rule_selector(selector)?,
525 })
526 }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
531pub struct PatternShellPair {
532 pub pattern: String,
534 pub shell: shuck_linter::ShellDialect,
536}
537
538impl std::str::FromStr for PatternShellPair {
539 type Err = String;
540
541 fn from_str(value: &str) -> Result<Self, Self::Err> {
542 let (pattern, shell) = value
543 .rsplit_once(':')
544 .ok_or_else(|| "expected <FilePattern>:<Shell>".to_owned())?;
545 let pattern = pattern.trim();
546 let shell = shell.trim();
547
548 if pattern.is_empty() || shell.is_empty() {
549 return Err("expected <FilePattern>:<Shell>".to_owned());
550 }
551
552 let shell = shuck_linter::ShellDialect::from_name(shell);
553 if shell == shuck_linter::ShellDialect::Unknown {
554 return Err(
555 "expected shell dialect to be one of sh, bash, dash, ksh, mksh, zsh".to_owned(),
556 );
557 }
558
559 Ok(Self {
560 pattern: pattern.to_owned(),
561 shell,
562 })
563 }
564}
565
566fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
567 let value = value.trim();
568 if value.is_empty() {
569 return Err("rule selector cannot be empty".to_owned());
570 }
571
572 value.parse::<RuleSelector>().map_err(|err| err.to_string())
573}
574
575#[derive(Debug, Clone, Default, ClapArgs)]
577pub struct RuleSelectionArgs {
578 #[arg(
580 long,
581 value_delimiter = ',',
582 value_parser = parse_cli_rule_selector,
583 value_name = "RULE_CODE",
584 help_heading = "Rule selection",
585 hide_possible_values = true
586 )]
587 pub select: Option<Vec<RuleSelector>>,
588 #[arg(
590 long,
591 value_delimiter = ',',
592 value_parser = parse_cli_rule_selector,
593 value_name = "RULE_CODE",
594 help_heading = "Rule selection",
595 hide_possible_values = true
596 )]
597 pub ignore: Vec<RuleSelector>,
598 #[arg(
600 long,
601 value_delimiter = ',',
602 value_parser = parse_cli_rule_selector,
603 value_name = "RULE_CODE",
604 help_heading = "Rule selection",
605 hide_possible_values = true
606 )]
607 pub extend_select: Vec<RuleSelector>,
608 #[arg(
610 long,
611 value_delimiter = ',',
612 value_name = "PER_FILE_IGNORES",
613 help_heading = "Rule selection"
614 )]
615 pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
616 #[arg(
618 long,
619 value_delimiter = ',',
620 value_name = "EXTEND_PER_FILE_IGNORES",
621 help_heading = "Rule selection"
622 )]
623 pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
624 #[arg(
626 long,
627 value_delimiter = ',',
628 value_name = "PER_FILE_SHELL",
629 help_heading = "Rule selection"
630 )]
631 pub per_file_shell: Option<Vec<PatternShellPair>>,
632 #[arg(
634 long,
635 value_delimiter = ',',
636 value_name = "EXTEND_PER_FILE_SHELL",
637 help_heading = "Rule selection"
638 )]
639 pub extend_per_file_shell: Vec<PatternShellPair>,
640 #[arg(
642 long,
643 value_delimiter = ',',
644 value_parser = parse_cli_rule_selector,
645 value_name = "RULE_CODE",
646 help_heading = "Rule selection",
647 hide_possible_values = true
648 )]
649 pub fixable: Option<Vec<RuleSelector>>,
650 #[arg(
652 long,
653 value_delimiter = ',',
654 value_parser = parse_cli_rule_selector,
655 value_name = "RULE_CODE",
656 help_heading = "Rule selection",
657 hide_possible_values = true
658 )]
659 pub unfixable: Vec<RuleSelector>,
660 #[arg(
662 long,
663 value_delimiter = ',',
664 value_parser = parse_cli_rule_selector,
665 value_name = "RULE_CODE",
666 help_heading = "Rule selection",
667 hide_possible_values = true
668 )]
669 pub extend_fixable: Vec<RuleSelector>,
670}
671
672fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
673where
674 Cli: CommandFactory + FromArgMatches,
675 I: IntoIterator<Item = T>,
676 T: Into<OsString> + Clone,
677{
678 let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
679 let mut command = Cli::command().color(command_color_choice(&args));
680 let matches = command.try_get_matches_from_mut(args)?;
681 Cli::from_arg_matches(&matches)
682}
683
684fn command_color_choice(args: &[OsString]) -> ColorChoice {
685 match preparse_color(args) {
686 Some(ColorChoice::Always) => ColorChoice::Always,
687 Some(ColorChoice::Never) => ColorChoice::Never,
688 Some(ColorChoice::Auto) | None => {
689 if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
690 ColorChoice::Always
691 } else {
692 ColorChoice::Auto
693 }
694 }
695 }
696}
697
698fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
699 let mut expect_value = false;
700 let mut color = None;
701
702 for argument in args.iter().skip(1) {
703 if expect_value {
704 let value = argument.to_string_lossy();
705 color = value.parse().ok();
706 expect_value = false;
707 continue;
708 }
709
710 let argument = argument.to_string_lossy();
711 if argument == "--" {
712 break;
713 }
714 if argument == "--color" {
715 expect_value = true;
716 continue;
717 }
718 if let Some(value) = argument.strip_prefix("--color=") {
719 color = value.parse().ok();
720 }
721 }
722
723 color
724}
725
726#[derive(Debug, Clone, Default, ClapArgs)]
728pub struct FileSelectionArgs {
729 #[arg(
731 long,
732 value_delimiter = ',',
733 value_name = "FILE_PATTERN",
734 help_heading = "File selection"
735 )]
736 pub exclude: Vec<String>,
737 #[arg(
739 long,
740 value_delimiter = ',',
741 value_name = "FILE_PATTERN",
742 help_heading = "File selection"
743 )]
744 pub extend_exclude: Vec<String>,
745 #[arg(
748 long,
749 overrides_with = "no_respect_gitignore",
750 help_heading = "File selection"
751 )]
752 pub(crate) respect_gitignore: bool,
753 #[arg(long, overrides_with = "respect_gitignore", hide = true)]
754 pub(crate) no_respect_gitignore: bool,
755 #[arg(
758 long,
759 overrides_with = "no_force_exclude",
760 help_heading = "File selection"
761 )]
762 pub(crate) force_exclude: bool,
763 #[arg(long, overrides_with = "force_exclude", hide = true)]
764 pub(crate) no_force_exclude: bool,
765}
766
767impl FileSelectionArgs {
768 pub fn respect_gitignore(&self) -> bool {
770 resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
771 }
772
773 pub fn force_exclude(&self) -> bool {
775 resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
776 }
777}
778
779#[derive(Debug, Clone, ClapArgs)]
781pub struct FormatCommand {
782 pub files: Vec<PathBuf>,
784 #[arg(long)]
786 pub check: bool,
787 #[arg(long)]
789 pub diff: bool,
790 #[arg(long = "no-cache")]
792 pub no_cache: bool,
793 #[arg(long)]
795 pub stdin_filename: Option<PathBuf>,
796 #[command(flatten)]
798 pub file_selection: FileSelectionArgs,
799 #[arg(long, value_enum)]
801 pub dialect: Option<FormatDialectArg>,
802 #[arg(long, value_enum)]
804 pub indent_style: Option<FormatIndentStyleArg>,
805 #[arg(long, value_name = "WIDTH")]
807 pub indent_width: Option<u8>,
808 #[arg(long, overrides_with = "no_binary_next_line")]
810 pub(crate) binary_next_line: bool,
811 #[arg(
812 long = "no-binary-next-line",
813 overrides_with = "binary_next_line",
814 hide = true
815 )]
816 pub(crate) no_binary_next_line: bool,
817 #[arg(long, overrides_with = "no_switch_case_indent")]
819 pub(crate) switch_case_indent: bool,
820 #[arg(
821 long = "no-switch-case-indent",
822 overrides_with = "switch_case_indent",
823 hide = true
824 )]
825 pub(crate) no_switch_case_indent: bool,
826 #[arg(long, overrides_with = "no_space_redirects")]
828 pub(crate) space_redirects: bool,
829 #[arg(
830 long = "no-space-redirects",
831 overrides_with = "space_redirects",
832 hide = true
833 )]
834 pub(crate) no_space_redirects: bool,
835 #[arg(long, overrides_with = "no_keep_padding")]
837 pub(crate) keep_padding: bool,
838 #[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
839 pub(crate) no_keep_padding: bool,
840 #[arg(long, overrides_with = "no_function_next_line")]
842 pub(crate) function_next_line: bool,
843 #[arg(
844 long = "no-function-next-line",
845 overrides_with = "function_next_line",
846 hide = true
847 )]
848 pub(crate) no_function_next_line: bool,
849 #[arg(long, overrides_with = "no_never_split")]
851 pub(crate) never_split: bool,
852 #[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
853 pub(crate) no_never_split: bool,
854 #[arg(long)]
856 pub simplify: bool,
857 #[arg(long)]
859 pub minify: bool,
860}
861
862impl FormatCommand {
863 pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
864 FormatSettingsPatch {
865 dialect: self.dialect.map(Into::into),
866 indent_style: self.indent_style.map(Into::into),
867 indent_width: self.indent_width,
868 binary_next_line: self.binary_next_line(),
869 switch_case_indent: self.switch_case_indent(),
870 space_redirects: self.space_redirects(),
871 keep_padding: self.keep_padding(),
872 function_next_line: self.function_next_line(),
873 never_split: self.never_split(),
874 simplify: self.simplify.then_some(true),
875 minify: self.minify.then_some(true),
876 }
877 }
878
879 pub fn binary_next_line(&self) -> Option<bool> {
881 tri_state_bool(self.binary_next_line, self.no_binary_next_line)
882 }
883
884 pub fn switch_case_indent(&self) -> Option<bool> {
886 tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
887 }
888
889 pub fn space_redirects(&self) -> Option<bool> {
891 tri_state_bool(self.space_redirects, self.no_space_redirects)
892 }
893
894 pub fn keep_padding(&self) -> Option<bool> {
896 tri_state_bool(self.keep_padding, self.no_keep_padding)
897 }
898
899 pub fn function_next_line(&self) -> Option<bool> {
901 tri_state_bool(self.function_next_line, self.no_function_next_line)
902 }
903
904 pub fn never_split(&self) -> Option<bool> {
906 tri_state_bool(self.never_split, self.no_never_split)
907 }
908
909 pub fn respect_gitignore(&self) -> bool {
911 self.file_selection.respect_gitignore()
912 }
913
914 pub fn force_exclude(&self) -> bool {
916 self.file_selection.force_exclude()
917 }
918}
919
920fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
921 match (positive, negative) {
922 (false, false) => None,
923 (true, false) => Some(true),
924 (false, true) => Some(false),
925 (true, true) => unreachable!("clap should make this impossible"),
929 }
930}
931
932fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
933 match (positive, negative) {
934 (false, false) => default,
935 (true, false) => true,
936 (false, true) => false,
937 (true, true) => unreachable!("clap should make this impossible"),
940 }
941}
942
943#[derive(Debug, Clone, ClapArgs)]
945pub struct CleanCommand {
946 pub paths: Vec<PathBuf>,
948}
949
950#[cfg(test)]
951mod tests {
952 use super::*;
953 use clap::builder::TypedValueParser;
954 use shuck_linter::Rule;
955
956 #[test]
957 fn global_config_override_is_available_after_subcommand() {
958 let command = StableCli::command();
959 let override_argument = shuck_config::ConfigArgumentParser
960 .parse_ref(
961 &command,
962 None,
963 std::ffi::OsStr::new("format.indent-width = 2"),
964 )
965 .unwrap();
966
967 let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
968 .unwrap();
969
970 assert_eq!(
971 args.config,
972 ConfigArguments::from_cli(vec![override_argument], false).unwrap()
973 );
974 }
975
976 #[test]
977 fn explicit_config_file_and_inline_override_both_parse_globally() {
978 let tempdir = tempfile::tempdir().unwrap();
979 let config_path = tempdir.path().join("shuck.toml");
980 std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
981 let command = StableCli::command();
982 let override_argument = shuck_config::ConfigArgumentParser
983 .parse_ref(
984 &command,
985 None,
986 std::ffi::OsStr::new("format.function-next-line = true"),
987 )
988 .unwrap();
989
990 let args = Args::try_parse_from([
991 "shuck",
992 "--config",
993 config_path.to_str().unwrap(),
994 "--config",
995 "format.function-next-line = true",
996 "check",
997 ])
998 .unwrap();
999
1000 assert_eq!(
1001 args.config,
1002 ConfigArguments::from_cli(
1003 vec![
1004 SingleConfigArgument::FilePath(config_path),
1005 override_argument
1006 ],
1007 false,
1008 )
1009 .unwrap()
1010 );
1011 }
1012
1013 #[test]
1014 fn global_color_can_be_parsed_before_subcommand() {
1015 let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
1016 assert_eq!(args.color, Some(TerminalColor::Never));
1017 }
1018
1019 #[test]
1020 fn preparse_color_uses_last_value() {
1021 assert_eq!(
1022 preparse_color(&[
1023 OsString::from("shuck"),
1024 OsString::from("--color=always"),
1025 OsString::from("--color"),
1026 OsString::from("never"),
1027 ]),
1028 Some(ColorChoice::Never)
1029 );
1030 }
1031
1032 fn parse_check<I, T>(args: I) -> CheckCommand
1033 where
1034 I: IntoIterator<Item = T>,
1035 T: Into<OsString> + Clone,
1036 {
1037 let parsed = StableCli::try_parse_from(args).unwrap();
1038 match Args::from_stable(parsed).unwrap().command {
1039 Command::Check(command) => *command,
1040 command => panic!("expected check command, got {command:?}"),
1041 }
1042 }
1043
1044 fn parse_run<I, T>(args: I) -> RunCommand
1045 where
1046 I: IntoIterator<Item = T>,
1047 T: Into<OsString> + Clone,
1048 {
1049 let parsed = StableCli::try_parse_from(args).unwrap();
1050 match Args::from_stable(parsed).unwrap().command {
1051 Command::Run(command) => command,
1052 command => panic!("expected run command, got {command:?}"),
1053 }
1054 }
1055
1056 fn parse_install<I, T>(args: I) -> InstallCommand
1057 where
1058 I: IntoIterator<Item = T>,
1059 T: Into<OsString> + Clone,
1060 {
1061 let parsed = StableCli::try_parse_from(args).unwrap();
1062 match Args::from_stable(parsed).unwrap().command {
1063 Command::Install(command) => command,
1064 command => panic!("expected install command, got {command:?}"),
1065 }
1066 }
1067
1068 fn parse_shell<I, T>(args: I) -> ShellCommand
1069 where
1070 I: IntoIterator<Item = T>,
1071 T: Into<OsString> + Clone,
1072 {
1073 let parsed = StableCli::try_parse_from(args).unwrap();
1074 match Args::from_stable(parsed).unwrap().command {
1075 Command::Shell(command) => command,
1076 command => panic!("expected shell command, got {command:?}"),
1077 }
1078 }
1079
1080 #[test]
1081 fn parses_add_ignore_without_reason() {
1082 let command = parse_check(["shuck", "check", "--add-ignore"]);
1083
1084 assert_eq!(command.add_ignore, Some(String::new()));
1085 }
1086
1087 #[test]
1088 fn parses_add_ignore_with_reason() {
1089 let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
1090
1091 assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
1092 }
1093
1094 #[test]
1095 fn parses_short_watch_flag() {
1096 let command = parse_check(["shuck", "check", "-w"]);
1097
1098 assert!(command.watch);
1099 }
1100
1101 #[test]
1102 fn parses_long_watch_flag() {
1103 let command = parse_check(["shuck", "check", "--watch"]);
1104
1105 assert!(command.watch);
1106 }
1107
1108 #[test]
1109 fn parses_all_check_output_formats() {
1110 for (raw, expected) in [
1111 ("concise", CheckOutputFormatArg::Concise),
1112 ("full", CheckOutputFormatArg::Full),
1113 ("json", CheckOutputFormatArg::Json),
1114 ("json-lines", CheckOutputFormatArg::JsonLines),
1115 ("junit", CheckOutputFormatArg::Junit),
1116 ("grouped", CheckOutputFormatArg::Grouped),
1117 ("github", CheckOutputFormatArg::Github),
1118 ("gitlab", CheckOutputFormatArg::Gitlab),
1119 ("rdjson", CheckOutputFormatArg::Rdjson),
1120 ("sarif", CheckOutputFormatArg::Sarif),
1121 ] {
1122 let command = parse_check(["shuck", "check", "--output-format", raw]);
1123 assert_eq!(command.output_format, expected, "failed to parse {raw}");
1124 }
1125 }
1126
1127 #[test]
1128 fn parses_run_command_flags_and_passthrough_args() {
1129 let command = parse_run([
1130 "shuck",
1131 "run",
1132 "--shell",
1133 "bash",
1134 "--shell-version",
1135 "5.2",
1136 "--system",
1137 "--dry-run",
1138 "--verbose",
1139 "deploy.sh",
1140 "--",
1141 "--env",
1142 "staging",
1143 ]);
1144
1145 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1146 assert_eq!(command.shell_version.as_deref(), Some("5.2"));
1147 assert!(command.system);
1148 assert!(command.dry_run);
1149 assert!(command.verbose);
1150 assert_eq!(
1151 command.script.as_deref(),
1152 Some(PathBuf::from("deploy.sh").as_path())
1153 );
1154 assert_eq!(
1155 command.script_args,
1156 vec![OsString::from("--env"), OsString::from("staging")]
1157 );
1158 }
1159
1160 #[test]
1161 fn parses_run_command_string_mode() {
1162 let command = parse_run([
1163 "shuck", "run", "-s", "bash", "-c", "echo hi", "--", "one", "two",
1164 ]);
1165
1166 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1167 assert_eq!(command.command.as_deref(), Some("echo hi"));
1168 assert!(command.script.is_none());
1169 assert_eq!(
1170 command.script_args,
1171 vec![OsString::from("one"), OsString::from("two")]
1172 );
1173 }
1174
1175 #[test]
1176 fn parses_busybox_shell_variants() {
1177 let run = parse_run(["shuck", "run", "--shell", "busybox", "deploy.sh"]);
1178 assert_eq!(run.shell, Some(ManagedShellArg::Busybox));
1179
1180 let install = parse_install(["shuck", "install", "busybox", "1.36"]);
1181 assert_eq!(install.shell, Some(ManagedShellArg::Busybox));
1182
1183 let shell = parse_shell(["shuck", "shell", "--shell", "busybox"]);
1184 assert_eq!(shell.shell, Some(ManagedShellArg::Busybox));
1185 }
1186
1187 #[test]
1188 fn parses_install_list_without_version() {
1189 let command = parse_install(["shuck", "install", "--list", "bash"]);
1190 assert!(command.list);
1191 assert_eq!(command.shell, Some(ManagedShellArg::Bash));
1192 assert!(command.version.is_none());
1193 }
1194
1195 #[test]
1196 fn parses_shell_command_flags() {
1197 let command = parse_shell([
1198 "shuck",
1199 "shell",
1200 "--shell",
1201 "zsh",
1202 "--shell-version",
1203 "5.9",
1204 "--system",
1205 "--verbose",
1206 ]);
1207
1208 assert_eq!(command.shell, Some(ManagedShellArg::Zsh));
1209 assert_eq!(command.shell_version.as_deref(), Some("5.9"));
1210 assert!(command.system);
1211 assert!(command.verbose);
1212 }
1213
1214 #[test]
1215 fn parses_extended_managed_shell_names() {
1216 let run_command = parse_run(["shuck", "run", "--shell", "gbash", "-c", "echo hi"]);
1217 assert_eq!(run_command.shell, Some(ManagedShellArg::Gbash));
1218
1219 let install_command = parse_install(["shuck", "install", "--list", "bashkit"]);
1220 assert_eq!(install_command.shell, Some(ManagedShellArg::Bashkit));
1221 }
1222
1223 #[test]
1224 fn parses_rule_selection_flags() {
1225 let command = parse_check([
1226 "shuck",
1227 "check",
1228 "--select",
1229 "C001",
1230 "--select",
1231 "S,C002",
1232 "--ignore",
1233 "C003,C004",
1234 "--extend-select",
1235 "X",
1236 "--fixable",
1237 "ALL",
1238 "--unfixable",
1239 "C001",
1240 "--extend-fixable",
1241 "S074",
1242 ]);
1243
1244 assert_eq!(
1245 command.rule_selection.select,
1246 Some(vec![
1247 RuleSelector::Rule(Rule::UnusedAssignment),
1248 RuleSelector::Category(shuck_linter::Category::Style),
1249 RuleSelector::Rule(Rule::DynamicSourcePath),
1250 ])
1251 );
1252 assert_eq!(
1253 command.rule_selection.ignore,
1254 vec![
1255 RuleSelector::Rule(Rule::UntrackedSourceFile),
1256 RuleSelector::Rule(Rule::UncheckedDirectoryChange),
1257 ]
1258 );
1259 assert_eq!(
1260 command.rule_selection.extend_select,
1261 vec![RuleSelector::Category(shuck_linter::Category::Portability)]
1262 );
1263 assert_eq!(
1264 command.rule_selection.fixable,
1265 Some(vec![RuleSelector::All])
1266 );
1267 assert_eq!(
1268 command.rule_selection.unfixable,
1269 vec![RuleSelector::Rule(Rule::UnusedAssignment)]
1270 );
1271 assert_eq!(
1272 command.rule_selection.extend_fixable,
1273 vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
1274 );
1275 }
1276
1277 #[test]
1278 fn parses_named_rule_selection_flags() {
1279 let command = parse_check([
1280 "shuck",
1281 "check",
1282 "--select",
1283 "google",
1284 "--extend-select",
1285 "google",
1286 "--fixable",
1287 "google",
1288 ]);
1289
1290 assert_eq!(
1291 command.rule_selection.select,
1292 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1293 );
1294 assert_eq!(
1295 command.rule_selection.extend_select,
1296 vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)]
1297 );
1298 assert_eq!(
1299 command.rule_selection.fixable,
1300 Some(vec![RuleSelector::Named(shuck_linter::NamedGroup::Google)])
1301 );
1302 }
1303
1304 #[test]
1305 fn parses_per_file_ignore_pairs() {
1306 let command = parse_check([
1307 "shuck",
1308 "check",
1309 "--per-file-ignores",
1310 "tests/*.sh:C001",
1311 "--extend-per-file-ignores",
1312 "!src/*.sh:S",
1313 ]);
1314
1315 assert_eq!(
1316 command.rule_selection.per_file_ignores,
1317 Some(vec![PatternRuleSelectorPair {
1318 pattern: "tests/*.sh".to_owned(),
1319 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1320 }])
1321 );
1322 assert_eq!(
1323 command.rule_selection.extend_per_file_ignores,
1324 vec![PatternRuleSelectorPair {
1325 pattern: "!src/*.sh".to_owned(),
1326 selector: RuleSelector::Category(shuck_linter::Category::Style),
1327 }]
1328 );
1329 }
1330
1331 #[test]
1332 fn parses_named_per_file_ignore_pairs() {
1333 let command = parse_check(["shuck", "check", "--per-file-ignores", "tests/*.sh:google"]);
1334
1335 assert_eq!(
1336 command.rule_selection.per_file_ignores,
1337 Some(vec![PatternRuleSelectorPair {
1338 pattern: "tests/*.sh".to_owned(),
1339 selector: RuleSelector::Named(shuck_linter::NamedGroup::Google),
1340 }])
1341 );
1342 }
1343
1344 #[test]
1345 fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
1346 let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
1347
1348 assert_eq!(
1349 command.rule_selection.per_file_ignores,
1350 Some(vec![PatternRuleSelectorPair {
1351 pattern: r"C:\repo\*.sh".to_owned(),
1352 selector: RuleSelector::Rule(Rule::UnusedAssignment),
1353 }])
1354 );
1355 }
1356
1357 #[test]
1358 fn parses_per_file_shell_pairs() {
1359 let command = parse_check([
1360 "shuck",
1361 "check",
1362 "--per-file-shell",
1363 "tests/*.sh:bash",
1364 "--extend-per-file-shell",
1365 "!src/*.sh:zsh",
1366 ]);
1367
1368 assert_eq!(
1369 command.rule_selection.per_file_shell,
1370 Some(vec![PatternShellPair {
1371 pattern: "tests/*.sh".to_owned(),
1372 shell: shuck_linter::ShellDialect::Bash,
1373 }])
1374 );
1375 assert_eq!(
1376 command.rule_selection.extend_per_file_shell,
1377 vec![PatternShellPair {
1378 pattern: "!src/*.sh".to_owned(),
1379 shell: shuck_linter::ShellDialect::Zsh,
1380 }]
1381 );
1382 }
1383
1384 #[test]
1385 fn rejects_empty_cli_rule_selectors() {
1386 let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
1387
1388 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1389 }
1390
1391 #[test]
1392 fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
1393 let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
1394
1395 assert_eq!(error.kind(), ErrorKind::ValueValidation);
1396 }
1397
1398 #[test]
1399 fn rejects_add_noqa_alias() {
1400 let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
1401
1402 assert_eq!(error.kind(), ErrorKind::UnknownArgument);
1403 }
1404
1405 #[test]
1406 fn rejects_add_ignore_with_fix_flags() {
1407 let error =
1408 StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
1409
1410 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1411 }
1412
1413 #[test]
1414 fn rejects_watch_with_add_ignore() {
1415 let error =
1416 StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
1417
1418 assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
1419 }
1420
1421 #[test]
1422 fn check_file_selection_negative_flags_override_positive_flags() {
1423 let args = Args::try_parse_from([
1424 "shuck",
1425 "check",
1426 "--respect-gitignore",
1427 "--no-respect-gitignore",
1428 "--force-exclude",
1429 "--no-force-exclude",
1430 ])
1431 .unwrap();
1432
1433 let Command::Check(command) = args.command else {
1434 panic!("expected check command");
1435 };
1436
1437 assert!(!command.respect_gitignore());
1438 assert!(!command.force_exclude());
1439 }
1440
1441 #[test]
1442 fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
1443 let args = Args::try_parse_from([
1444 "shuck",
1445 "check",
1446 "--exclude",
1447 "base.sh",
1448 "--extend-exclude",
1449 "extra.sh",
1450 ])
1451 .unwrap();
1452
1453 let Command::Check(command) = args.command else {
1454 panic!("expected check command");
1455 };
1456
1457 assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
1458 assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
1459 }
1460}