use std::ffi::OsString;
use std::path::PathBuf;
use clap::builder::Styles;
use clap::builder::styling::{AnsiColor, Effects};
use clap::error::ErrorKind;
use clap::{
Args as ClapArgs, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
};
use shuck_formatter::{IndentStyle, ShellDialect};
use shuck_linter::RuleSelector;
use crate::config::{ConfigArgumentParser, ConfigArguments, SingleConfigArgument};
use crate::format_settings::FormatSettingsPatch;
const STYLES: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().effects(Effects::BOLD))
.usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
.literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
.placeholder(AnsiColor::Cyan.on_default());
const EXPERIMENTAL_ENV_VAR: &str = "SHUCK_EXPERIMENTAL";
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum FormatDialectArg {
Auto,
Bash,
Posix,
Mksh,
Zsh,
}
impl From<FormatDialectArg> for ShellDialect {
fn from(value: FormatDialectArg) -> Self {
match value {
FormatDialectArg::Auto => Self::Auto,
FormatDialectArg::Bash => Self::Bash,
FormatDialectArg::Posix => Self::Posix,
FormatDialectArg::Mksh => Self::Mksh,
FormatDialectArg::Zsh => Self::Zsh,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum FormatIndentStyleArg {
Tab,
Space,
}
impl From<FormatIndentStyleArg> for IndentStyle {
fn from(value: FormatIndentStyleArg) -> Self {
match value {
FormatIndentStyleArg::Tab => Self::Tab,
FormatIndentStyleArg::Space => Self::Space,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum CheckOutputFormatArg {
Concise,
Full,
Json,
JsonLines,
Junit,
Grouped,
Github,
Gitlab,
Rdjson,
Sarif,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TerminalColor {
Auto,
Always,
Never,
}
#[derive(Debug, Parser)]
#[command(name = "shuck")]
#[command(about = "Shell checker CLI for shuck")]
#[command(styles = STYLES)]
struct StableCli {
#[command(flatten)]
global: GlobalArgs,
#[command(subcommand)]
command: StableCommand,
}
#[derive(Debug, Parser)]
#[command(name = "shuck")]
#[command(about = "Shell checker CLI for shuck")]
#[command(styles = STYLES)]
struct ExperimentalCli {
#[command(flatten)]
global: GlobalArgs,
#[command(subcommand)]
command: ExperimentalCommand,
}
#[derive(Debug, Clone, ClapArgs)]
struct GlobalArgs {
#[arg(
long,
action = clap::ArgAction::Append,
value_name = "CONFIG_OPTION",
value_parser = ConfigArgumentParser,
global = true,
help_heading = "Global options"
)]
config: Vec<SingleConfigArgument>,
#[arg(long, global = true, help_heading = "Global options")]
isolated: bool,
#[arg(
long,
value_enum,
value_name = "WHEN",
global = true,
help_heading = "Global options"
)]
color: Option<TerminalColor>,
#[arg(
long,
env = "SHUCK_CACHE_DIR",
global = true,
value_name = "PATH",
help_heading = "Miscellaneous"
)]
cache_dir: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
enum StableCommand {
Check(CheckCommand),
#[command(hide = true)]
Format(FormatCommand),
Clean(CleanCommand),
}
#[derive(Debug, Subcommand)]
enum ExperimentalCommand {
Check(CheckCommand),
Format(FormatCommand),
Clean(CleanCommand),
}
#[derive(Debug, Clone)]
pub struct Args {
pub cache_dir: Option<PathBuf>,
pub(crate) config: ConfigArguments,
pub(crate) color: Option<TerminalColor>,
pub command: Command,
}
impl Args {
pub fn parse() -> Self {
Self::try_parse().unwrap_or_else(|err| err.exit())
}
pub fn try_parse() -> Result<Self, clap::Error> {
Self::try_parse_from(std::env::args_os())
}
pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
if experimental_enabled() {
let parsed = parse_with_color::<ExperimentalCli, _, _>(itr)?;
Self::from_experimental(parsed)
} else {
let parsed = parse_with_color::<StableCli, _, _>(itr)?;
Self::from_stable(parsed)
}
}
}
impl Args {
fn from_stable(value: StableCli) -> Result<Self, clap::Error> {
let StableCli { global, command } = value;
let GlobalArgs {
cache_dir,
config,
isolated,
color,
} = global;
let command = match command {
StableCommand::Check(command) => Command::Check(command),
StableCommand::Format(_) => {
return Err(clap::Error::raw(
ErrorKind::InvalidSubcommand,
format!(
"the `format` subcommand is experimental; set {EXPERIMENTAL_ENV_VAR}=1 to enable it"
),
));
}
StableCommand::Clean(command) => Command::Clean(command),
};
Ok(Self {
cache_dir,
config: ConfigArguments::from_cli(config, isolated)?,
color,
command,
})
}
fn from_experimental(value: ExperimentalCli) -> Result<Self, clap::Error> {
let ExperimentalCli { global, command } = value;
let GlobalArgs {
cache_dir,
config,
isolated,
color,
} = global;
let command = match command {
ExperimentalCommand::Check(command) => Command::Check(command),
ExperimentalCommand::Format(command) => Command::Format(command),
ExperimentalCommand::Clean(command) => Command::Clean(command),
};
Ok(Self {
cache_dir,
config: ConfigArguments::from_cli(config, isolated)?,
color,
command,
})
}
}
#[derive(Debug, Clone, Subcommand)]
pub enum Command {
Check(CheckCommand),
Format(FormatCommand),
Clean(CleanCommand),
}
fn experimental_enabled() -> bool {
std::env::var_os(EXPERIMENTAL_ENV_VAR).is_some_and(|value| {
!matches!(
value.to_string_lossy().trim().to_ascii_lowercase().as_str(),
"" | "0" | "false" | "no" | "off"
)
})
}
#[derive(Debug, Clone, ClapArgs)]
pub struct CheckCommand {
#[arg(long)]
pub fix: bool,
#[arg(long = "unsafe-fixes")]
pub unsafe_fixes: bool,
#[arg(
long = "add-ignore",
value_name = "REASON",
default_missing_value = "",
num_args = 0..=1,
require_equals = true,
conflicts_with = "fix",
conflicts_with = "unsafe_fixes",
)]
pub add_ignore: Option<String>,
#[arg(
long = "output-format",
value_enum,
env = "SHUCK_OUTPUT_FORMAT",
default_value_t = CheckOutputFormatArg::Full
)]
pub output_format: CheckOutputFormatArg,
#[arg(short = 'w', long, conflicts_with = "add_ignore")]
pub watch: bool,
pub paths: Vec<PathBuf>,
#[command(flatten)]
pub rule_selection: RuleSelectionArgs,
#[command(flatten)]
pub file_selection: FileSelectionArgs,
#[arg(long = "no-cache", help_heading = "Miscellaneous")]
pub no_cache: bool,
#[arg(short = 'e', long = "exit-zero", help_heading = "Miscellaneous")]
pub exit_zero: bool,
#[arg(long = "exit-non-zero-on-fix", help_heading = "Miscellaneous")]
pub exit_non_zero_on_fix: bool,
}
impl CheckCommand {
pub fn respect_gitignore(&self) -> bool {
self.file_selection.respect_gitignore()
}
pub fn force_exclude(&self) -> bool {
self.file_selection.force_exclude()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatternRuleSelectorPair {
pub pattern: String,
pub selector: RuleSelector,
}
impl std::str::FromStr for PatternRuleSelectorPair {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (pattern, selector) = value
.rsplit_once(':')
.ok_or_else(|| "expected <FilePattern>:<RuleCode>".to_owned())?;
let pattern = pattern.trim();
let selector = selector.trim();
if pattern.is_empty() || selector.is_empty() {
return Err("expected <FilePattern>:<RuleCode>".to_owned());
}
Ok(Self {
pattern: pattern.to_owned(),
selector: parse_cli_rule_selector(selector)?,
})
}
}
fn parse_cli_rule_selector(value: &str) -> Result<RuleSelector, String> {
let value = value.trim();
if value.is_empty() {
return Err("rule selector cannot be empty".to_owned());
}
value.parse::<RuleSelector>().map_err(|err| err.to_string())
}
#[derive(Debug, Clone, Default, ClapArgs)]
pub struct RuleSelectionArgs {
#[arg(
long,
value_delimiter = ',',
value_parser = parse_cli_rule_selector,
value_name = "RULE_CODE",
help_heading = "Rule selection",
hide_possible_values = true
)]
pub select: Option<Vec<RuleSelector>>,
#[arg(
long,
value_delimiter = ',',
value_parser = parse_cli_rule_selector,
value_name = "RULE_CODE",
help_heading = "Rule selection",
hide_possible_values = true
)]
pub ignore: Vec<RuleSelector>,
#[arg(
long,
value_delimiter = ',',
value_parser = parse_cli_rule_selector,
value_name = "RULE_CODE",
help_heading = "Rule selection",
hide_possible_values = true
)]
pub extend_select: Vec<RuleSelector>,
#[arg(
long,
value_delimiter = ',',
value_name = "PER_FILE_IGNORES",
help_heading = "Rule selection"
)]
pub per_file_ignores: Option<Vec<PatternRuleSelectorPair>>,
#[arg(
long,
value_delimiter = ',',
value_name = "EXTEND_PER_FILE_IGNORES",
help_heading = "Rule selection"
)]
pub extend_per_file_ignores: Vec<PatternRuleSelectorPair>,
#[arg(
long,
value_delimiter = ',',
value_parser = parse_cli_rule_selector,
value_name = "RULE_CODE",
help_heading = "Rule selection",
hide_possible_values = true
)]
pub fixable: Option<Vec<RuleSelector>>,
#[arg(
long,
value_delimiter = ',',
value_parser = parse_cli_rule_selector,
value_name = "RULE_CODE",
help_heading = "Rule selection",
hide_possible_values = true
)]
pub unfixable: Vec<RuleSelector>,
#[arg(
long,
value_delimiter = ',',
value_parser = parse_cli_rule_selector,
value_name = "RULE_CODE",
help_heading = "Rule selection",
hide_possible_values = true
)]
pub extend_fixable: Vec<RuleSelector>,
}
fn parse_with_color<Cli, I, T>(itr: I) -> Result<Cli, clap::Error>
where
Cli: CommandFactory + FromArgMatches,
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let args = itr.into_iter().map(Into::into).collect::<Vec<_>>();
let mut command = Cli::command().color(command_color_choice(&args));
let matches = command.try_get_matches_from_mut(args)?;
Cli::from_arg_matches(&matches)
}
fn command_color_choice(args: &[OsString]) -> ColorChoice {
match preparse_color(args) {
Some(ColorChoice::Always) => ColorChoice::Always,
Some(ColorChoice::Never) => ColorChoice::Never,
Some(ColorChoice::Auto) | None => {
if std::env::var_os("FORCE_COLOR").is_some_and(|value| !value.is_empty()) {
ColorChoice::Always
} else {
ColorChoice::Auto
}
}
}
}
fn preparse_color(args: &[OsString]) -> Option<ColorChoice> {
let mut expect_value = false;
let mut color = None;
for argument in args.iter().skip(1) {
if expect_value {
let value = argument.to_string_lossy();
color = value.parse().ok();
expect_value = false;
continue;
}
let argument = argument.to_string_lossy();
if argument == "--" {
break;
}
if argument == "--color" {
expect_value = true;
continue;
}
if let Some(value) = argument.strip_prefix("--color=") {
color = value.parse().ok();
}
}
color
}
#[derive(Debug, Clone, Default, ClapArgs)]
pub struct FileSelectionArgs {
#[arg(
long,
value_delimiter = ',',
value_name = "FILE_PATTERN",
help_heading = "File selection"
)]
pub exclude: Vec<String>,
#[arg(
long,
value_delimiter = ',',
value_name = "FILE_PATTERN",
help_heading = "File selection"
)]
pub extend_exclude: Vec<String>,
#[arg(
long,
overrides_with = "no_respect_gitignore",
help_heading = "File selection"
)]
pub(crate) respect_gitignore: bool,
#[arg(long, overrides_with = "respect_gitignore", hide = true)]
pub(crate) no_respect_gitignore: bool,
#[arg(
long,
overrides_with = "no_force_exclude",
help_heading = "File selection"
)]
pub(crate) force_exclude: bool,
#[arg(long, overrides_with = "force_exclude", hide = true)]
pub(crate) no_force_exclude: bool,
}
impl FileSelectionArgs {
pub fn respect_gitignore(&self) -> bool {
resolve_bool_flag(self.respect_gitignore, self.no_respect_gitignore, true)
}
pub fn force_exclude(&self) -> bool {
resolve_bool_flag(self.force_exclude, self.no_force_exclude, false)
}
}
#[derive(Debug, Clone, ClapArgs)]
pub struct FormatCommand {
pub files: Vec<PathBuf>,
#[arg(long)]
pub check: bool,
#[arg(long)]
pub diff: bool,
#[arg(long = "no-cache")]
pub no_cache: bool,
#[arg(long)]
pub stdin_filename: Option<PathBuf>,
#[command(flatten)]
pub file_selection: FileSelectionArgs,
#[arg(long, value_enum)]
pub dialect: Option<FormatDialectArg>,
#[arg(long, value_enum)]
pub indent_style: Option<FormatIndentStyleArg>,
#[arg(long, value_name = "WIDTH")]
pub indent_width: Option<u8>,
#[arg(long, overrides_with = "no_binary_next_line")]
pub(crate) binary_next_line: bool,
#[arg(
long = "no-binary-next-line",
overrides_with = "binary_next_line",
hide = true
)]
pub(crate) no_binary_next_line: bool,
#[arg(long, overrides_with = "no_switch_case_indent")]
pub(crate) switch_case_indent: bool,
#[arg(
long = "no-switch-case-indent",
overrides_with = "switch_case_indent",
hide = true
)]
pub(crate) no_switch_case_indent: bool,
#[arg(long, overrides_with = "no_space_redirects")]
pub(crate) space_redirects: bool,
#[arg(
long = "no-space-redirects",
overrides_with = "space_redirects",
hide = true
)]
pub(crate) no_space_redirects: bool,
#[arg(long, overrides_with = "no_keep_padding")]
pub(crate) keep_padding: bool,
#[arg(long = "no-keep-padding", overrides_with = "keep_padding", hide = true)]
pub(crate) no_keep_padding: bool,
#[arg(long, overrides_with = "no_function_next_line")]
pub(crate) function_next_line: bool,
#[arg(
long = "no-function-next-line",
overrides_with = "function_next_line",
hide = true
)]
pub(crate) no_function_next_line: bool,
#[arg(long, overrides_with = "no_never_split")]
pub(crate) never_split: bool,
#[arg(long = "no-never-split", overrides_with = "never_split", hide = true)]
pub(crate) no_never_split: bool,
#[arg(long)]
pub simplify: bool,
#[arg(long)]
pub minify: bool,
}
impl FormatCommand {
pub(crate) fn format_settings_patch(&self) -> FormatSettingsPatch {
FormatSettingsPatch {
dialect: self.dialect.map(Into::into),
indent_style: self.indent_style.map(Into::into),
indent_width: self.indent_width,
binary_next_line: self.binary_next_line(),
switch_case_indent: self.switch_case_indent(),
space_redirects: self.space_redirects(),
keep_padding: self.keep_padding(),
function_next_line: self.function_next_line(),
never_split: self.never_split(),
simplify: self.simplify.then_some(true),
minify: self.minify.then_some(true),
}
}
pub fn binary_next_line(&self) -> Option<bool> {
tri_state_bool(self.binary_next_line, self.no_binary_next_line)
}
pub fn switch_case_indent(&self) -> Option<bool> {
tri_state_bool(self.switch_case_indent, self.no_switch_case_indent)
}
pub fn space_redirects(&self) -> Option<bool> {
tri_state_bool(self.space_redirects, self.no_space_redirects)
}
pub fn keep_padding(&self) -> Option<bool> {
tri_state_bool(self.keep_padding, self.no_keep_padding)
}
pub fn function_next_line(&self) -> Option<bool> {
tri_state_bool(self.function_next_line, self.no_function_next_line)
}
pub fn never_split(&self) -> Option<bool> {
tri_state_bool(self.never_split, self.no_never_split)
}
pub fn respect_gitignore(&self) -> bool {
self.file_selection.respect_gitignore()
}
pub fn force_exclude(&self) -> bool {
self.file_selection.force_exclude()
}
}
fn tri_state_bool(positive: bool, negative: bool) -> Option<bool> {
match (positive, negative) {
(false, false) => None,
(true, false) => Some(true),
(false, true) => Some(false),
(true, true) => unreachable!("clap should make this impossible"),
}
}
fn resolve_bool_flag(positive: bool, negative: bool, default: bool) -> bool {
match (positive, negative) {
(false, false) => default,
(true, false) => true,
(false, true) => false,
(true, true) => unreachable!("clap should make this impossible"),
}
}
#[derive(Debug, Clone, ClapArgs)]
pub struct CleanCommand {
pub paths: Vec<PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::builder::TypedValueParser;
use shuck_linter::Rule;
#[test]
fn global_config_override_is_available_after_subcommand() {
let command = StableCli::command();
let override_argument = crate::config::ConfigArgumentParser
.parse_ref(
&command,
None,
std::ffi::OsStr::new("format.indent-width = 2"),
)
.unwrap();
let args = Args::try_parse_from(["shuck", "check", "--config", "format.indent-width = 2"])
.unwrap();
assert_eq!(
args.config,
ConfigArguments::from_cli(vec![override_argument], false).unwrap()
);
}
#[test]
fn explicit_config_file_and_inline_override_both_parse_globally() {
let tempdir = tempfile::tempdir().unwrap();
let config_path = tempdir.path().join("shuck.toml");
std::fs::write(&config_path, "[format]\nfunction-next-line = false\n").unwrap();
let command = StableCli::command();
let override_argument = crate::config::ConfigArgumentParser
.parse_ref(
&command,
None,
std::ffi::OsStr::new("format.function-next-line = true"),
)
.unwrap();
let args = Args::try_parse_from([
"shuck",
"--config",
config_path.to_str().unwrap(),
"--config",
"format.function-next-line = true",
"check",
])
.unwrap();
assert_eq!(
args.config,
ConfigArguments::from_cli(
vec![
SingleConfigArgument::FilePath(config_path),
override_argument
],
false,
)
.unwrap()
);
}
#[test]
fn global_color_can_be_parsed_before_subcommand() {
let args = Args::try_parse_from(["shuck", "--color", "never", "check"]).unwrap();
assert_eq!(args.color, Some(TerminalColor::Never));
}
#[test]
fn preparse_color_uses_last_value() {
assert_eq!(
preparse_color(&[
OsString::from("shuck"),
OsString::from("--color=always"),
OsString::from("--color"),
OsString::from("never"),
]),
Some(ColorChoice::Never)
);
}
fn parse_check<I, T>(args: I) -> CheckCommand
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let parsed = StableCli::try_parse_from(args).unwrap();
match Args::from_stable(parsed).unwrap().command {
Command::Check(command) => command,
command => panic!("expected check command, got {command:?}"),
}
}
#[test]
fn parses_add_ignore_without_reason() {
let command = parse_check(["shuck", "check", "--add-ignore"]);
assert_eq!(command.add_ignore, Some(String::new()));
}
#[test]
fn parses_add_ignore_with_reason() {
let command = parse_check(["shuck", "check", "--add-ignore=legacy"]);
assert_eq!(command.add_ignore.as_deref(), Some("legacy"));
}
#[test]
fn parses_short_watch_flag() {
let command = parse_check(["shuck", "check", "-w"]);
assert!(command.watch);
}
#[test]
fn parses_long_watch_flag() {
let command = parse_check(["shuck", "check", "--watch"]);
assert!(command.watch);
}
#[test]
fn parses_all_check_output_formats() {
for (raw, expected) in [
("concise", CheckOutputFormatArg::Concise),
("full", CheckOutputFormatArg::Full),
("json", CheckOutputFormatArg::Json),
("json-lines", CheckOutputFormatArg::JsonLines),
("junit", CheckOutputFormatArg::Junit),
("grouped", CheckOutputFormatArg::Grouped),
("github", CheckOutputFormatArg::Github),
("gitlab", CheckOutputFormatArg::Gitlab),
("rdjson", CheckOutputFormatArg::Rdjson),
("sarif", CheckOutputFormatArg::Sarif),
] {
let command = parse_check(["shuck", "check", "--output-format", raw]);
assert_eq!(command.output_format, expected, "failed to parse {raw}");
}
}
#[test]
fn parses_rule_selection_flags() {
let command = parse_check([
"shuck",
"check",
"--select",
"C001",
"--select",
"S,C002",
"--ignore",
"C003,C004",
"--extend-select",
"X",
"--fixable",
"ALL",
"--unfixable",
"C001",
"--extend-fixable",
"S074",
]);
assert_eq!(
command.rule_selection.select,
Some(vec![
RuleSelector::Rule(Rule::UnusedAssignment),
RuleSelector::Category(shuck_linter::Category::Style),
RuleSelector::Rule(Rule::DynamicSourcePath),
])
);
assert_eq!(
command.rule_selection.ignore,
vec![
RuleSelector::Rule(Rule::UntrackedSourceFile),
RuleSelector::Rule(Rule::UncheckedDirectoryChange),
]
);
assert_eq!(
command.rule_selection.extend_select,
vec![RuleSelector::Category(shuck_linter::Category::Portability)]
);
assert_eq!(
command.rule_selection.fixable,
Some(vec![RuleSelector::All])
);
assert_eq!(
command.rule_selection.unfixable,
vec![RuleSelector::Rule(Rule::UnusedAssignment)]
);
assert_eq!(
command.rule_selection.extend_fixable,
vec![RuleSelector::Rule(Rule::AmpersandSemicolon)]
);
}
#[test]
fn parses_per_file_ignore_pairs() {
let command = parse_check([
"shuck",
"check",
"--per-file-ignores",
"tests/*.sh:C001",
"--extend-per-file-ignores",
"!src/*.sh:S",
]);
assert_eq!(
command.rule_selection.per_file_ignores,
Some(vec![PatternRuleSelectorPair {
pattern: "tests/*.sh".to_owned(),
selector: RuleSelector::Rule(Rule::UnusedAssignment),
}])
);
assert_eq!(
command.rule_selection.extend_per_file_ignores,
vec![PatternRuleSelectorPair {
pattern: "!src/*.sh".to_owned(),
selector: RuleSelector::Category(shuck_linter::Category::Style),
}]
);
}
#[test]
fn parses_per_file_ignore_pairs_with_colons_in_pattern() {
let command = parse_check(["shuck", "check", "--per-file-ignores", r"C:\repo\*.sh:C001"]);
assert_eq!(
command.rule_selection.per_file_ignores,
Some(vec![PatternRuleSelectorPair {
pattern: r"C:\repo\*.sh".to_owned(),
selector: RuleSelector::Rule(Rule::UnusedAssignment),
}])
);
}
#[test]
fn rejects_empty_cli_rule_selectors() {
let error = StableCli::try_parse_from(["shuck", "check", "--select", ""]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::ValueValidation);
}
#[test]
fn rejects_empty_cli_rule_selectors_after_value_delimiter() {
let error = StableCli::try_parse_from(["shuck", "check", "--select", "C001,"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::ValueValidation);
}
#[test]
fn rejects_add_noqa_alias() {
let error = StableCli::try_parse_from(["shuck", "check", "--add-noqa=legacy"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::UnknownArgument);
}
#[test]
fn rejects_add_ignore_with_fix_flags() {
let error =
StableCli::try_parse_from(["shuck", "check", "--add-ignore", "--fix"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn rejects_watch_with_add_ignore() {
let error =
StableCli::try_parse_from(["shuck", "check", "--watch", "--add-ignore"]).unwrap_err();
assert_eq!(error.kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn check_file_selection_negative_flags_override_positive_flags() {
let args = Args::try_parse_from([
"shuck",
"check",
"--respect-gitignore",
"--no-respect-gitignore",
"--force-exclude",
"--no-force-exclude",
])
.unwrap();
let Command::Check(command) = args.command else {
panic!("expected check command");
};
assert!(!command.respect_gitignore());
assert!(!command.force_exclude());
}
#[test]
fn check_file_selection_collects_exclude_and_extend_exclude_patterns() {
let args = Args::try_parse_from([
"shuck",
"check",
"--exclude",
"base.sh",
"--extend-exclude",
"extra.sh",
])
.unwrap();
let Command::Check(command) = args.command else {
panic!("expected check command");
};
assert_eq!(command.file_selection.exclude, vec!["base.sh"]);
assert_eq!(command.file_selection.extend_exclude, vec!["extra.sh"]);
}
}