use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use slopguard_core::preset::{parse_preset, Preset};
#[derive(Parser)]
#[command(
name = "slopguard",
about = "Catch AI-generated code patterns and common issues"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, ValueEnum)]
pub enum Format {
Text,
Json,
Sarif,
Html,
}
#[derive(Clone, ValueEnum)]
pub enum SeverityThreshold {
Error,
Warning,
}
#[derive(Clone, ValueEnum)]
pub enum CategoryFilter {
Slop,
Security,
Correctness,
}
#[derive(Clone, ValueEnum)]
pub enum LanguageFilter {
Rust,
Typescript,
}
#[derive(Subcommand)]
pub enum Command {
Scan {
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
#[arg(long)]
format: Option<Format>,
#[arg(long, short = 'o', value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long, default_value = "warning")]
severity_threshold: SeverityThreshold,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
no_colors: bool,
#[arg(long = "disable", value_name = "RULE_ID")]
cli_disable: Vec<String>,
#[arg(long = "enable", value_name = "RULE_ID")]
cli_enable: Vec<String>,
#[arg(long = "test-path", value_name = "GLOB")]
cli_test_paths: Vec<String>,
#[arg(long = "rule", value_name = "RULE_ID")]
rule_filter: Option<String>,
#[arg(long)]
no_cache: bool,
#[arg(long, value_name = "PATH")]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_ai: bool,
#[arg(long, conflicts_with = "baseline_path")]
no_baseline: bool,
#[arg(long = "baseline", value_name = "PATH")]
baseline_path: Option<PathBuf>,
#[arg(long)]
no_escalation: bool,
#[arg(long)]
diff: bool,
#[arg(long, value_name = "REF", requires = "diff")]
base: Option<String>,
#[arg(long)]
fix: bool,
#[arg(long, requires = "fix")]
dry_run: bool,
#[arg(long, requires = "fix")]
allow_dirty: bool,
#[arg(long)]
offline: bool,
},
Stats {
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
#[arg(long)]
format: Option<Format>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
no_colors: bool,
#[arg(long = "disable", value_name = "RULE_ID")]
cli_disable: Vec<String>,
#[arg(long = "enable", value_name = "RULE_ID")]
cli_enable: Vec<String>,
#[arg(long = "rule", value_name = "RULE_ID")]
rule_filter: Option<String>,
#[arg(long)]
no_cache: bool,
#[arg(long, value_name = "PATH")]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_ai: bool,
#[arg(long, conflicts_with = "baseline_path")]
no_baseline: bool,
#[arg(long = "baseline", value_name = "PATH")]
baseline_path: Option<PathBuf>,
#[arg(long)]
no_escalation: bool,
#[arg(long)]
offline: bool,
},
Baseline {
#[arg(default_value = ".")]
paths: Vec<PathBuf>,
#[arg(long, short = 'o', value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long = "disable", value_name = "RULE_ID")]
cli_disable: Vec<String>,
#[arg(long = "enable", value_name = "RULE_ID")]
cli_enable: Vec<String>,
#[arg(long)]
no_cache: bool,
#[arg(long, value_name = "PATH")]
cache_dir: Option<PathBuf>,
#[arg(long)]
no_ai: bool,
#[arg(long)]
offline: bool,
},
Init {
#[arg(long)]
force: bool,
#[arg(long, value_name = "NAME", num_args = 0..=1, value_parser = parse_preset)]
preset: Option<Option<Preset>>,
},
Test {
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
offline: bool,
},
Explain {
rule_id: String,
#[arg(long)]
format: Option<Format>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
offline: bool,
},
List {
#[arg(long)]
all: bool,
#[arg(long)]
format: Option<Format>,
#[arg(long)]
category: Option<CategoryFilter>,
#[arg(long)]
language: Option<LanguageFilter>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
offline: bool,
},
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_parses_scan_subcommand() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "."])
.expect("scan subcommand should parse");
}
#[test]
fn cli_parses_scan_subcommand_default_path() {
Cli::command()
.try_get_matches_from(["slopguard", "scan"])
.expect("scan subcommand should parse without explicit path");
}
#[test]
fn cli_parses_scan_with_format() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--format", "json", "."])
.expect("scan with --format json should parse");
}
#[test]
fn cli_parses_scan_with_format_html() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--format", "html", "."])
.expect("scan with --format html should parse");
}
#[test]
fn cli_parses_scan_with_output() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "-o", "report.html", "."])
.expect("scan with -o should parse");
}
#[test]
fn cli_parses_scan_with_long_output() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--output", "report.html", "."])
.expect("scan with --output should parse");
}
#[test]
fn cli_parses_scan_html_with_output() {
Cli::command()
.try_get_matches_from([
"slopguard",
"scan",
"--format",
"html",
"--output",
"report.html",
".",
])
.expect("scan with --format html and --output should parse");
}
#[test]
fn cli_parses_scan_with_severity_threshold() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--severity-threshold", "error", "."])
.expect("scan with --severity-threshold should parse");
}
#[test]
fn cli_parses_scan_with_config() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--config", "my.toml", "."])
.expect("scan with --config should parse");
}
#[test]
fn cli_parses_scan_with_no_colors() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--no-colors", "."])
.expect("scan with --no-colors should parse");
}
#[test]
fn cli_parses_init_subcommand() {
Cli::command()
.try_get_matches_from(["slopguard", "init"])
.expect("init subcommand should parse");
}
#[test]
fn cli_parses_init_with_preset() {
Cli::command()
.try_get_matches_from(["slopguard", "init", "--preset", "strict"])
.expect("init with --preset strict should parse");
}
#[test]
fn cli_parses_init_with_bare_preset() {
Cli::command()
.try_get_matches_from(["slopguard", "init", "--preset"])
.expect("init with a valueless --preset should parse");
}
#[test]
fn cli_parses_init_with_preset_and_force() {
Cli::command()
.try_get_matches_from(["slopguard", "init", "--preset", "relaxed", "--force"])
.expect("init with --preset and --force should parse");
}
#[test]
fn cli_rejects_unknown_preset() {
let result =
Cli::command().try_get_matches_from(["slopguard", "init", "--preset", "bogus"]);
assert!(result.is_err(), "unknown preset should be rejected");
}
#[test]
fn cli_parses_test_subcommand() {
Cli::command()
.try_get_matches_from(["slopguard", "test"])
.expect("test subcommand should parse");
}
#[test]
fn cli_parses_baseline_subcommand() {
Cli::command()
.try_get_matches_from(["slopguard", "baseline", "."])
.expect("baseline subcommand should parse");
}
#[test]
fn cli_parses_baseline_with_output() {
Cli::command()
.try_get_matches_from(["slopguard", "baseline", "-o", "custom.json", "."])
.expect("baseline with --output should parse");
}
#[test]
fn cli_parses_scan_with_no_baseline() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--no-baseline", "."])
.expect("scan with --no-baseline should parse");
}
#[test]
fn cli_parses_scan_with_baseline_path() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--baseline", "custom.json", "."])
.expect("scan with --baseline should parse");
}
#[test]
fn cli_rejects_no_baseline_with_baseline_path() {
let result = Cli::command().try_get_matches_from([
"slopguard",
"scan",
"--no-baseline",
"--baseline",
"custom.json",
".",
]);
assert!(
result.is_err(),
"--no-baseline and --baseline should conflict"
);
}
#[test]
fn cli_parses_stats_subcommand() {
Cli::command()
.try_get_matches_from(["slopguard", "stats", "."])
.expect("stats subcommand should parse");
}
#[test]
fn cli_parses_stats_default_path() {
Cli::command()
.try_get_matches_from(["slopguard", "stats"])
.expect("stats subcommand should parse without explicit path");
}
#[test]
fn cli_parses_stats_with_format() {
Cli::command()
.try_get_matches_from(["slopguard", "stats", "--format", "json", "."])
.expect("stats with --format json should parse");
}
#[test]
fn cli_parses_stats_with_baseline_flags() {
Cli::command()
.try_get_matches_from(["slopguard", "stats", "--no-baseline", "."])
.expect("stats with --no-baseline should parse");
Cli::command()
.try_get_matches_from(["slopguard", "stats", "--baseline", "b.json", "."])
.expect("stats with --baseline should parse");
}
#[test]
fn cli_rejects_stats_no_baseline_with_baseline_path() {
let result = Cli::command().try_get_matches_from([
"slopguard",
"stats",
"--no-baseline",
"--baseline",
"b.json",
".",
]);
assert!(
result.is_err(),
"--no-baseline and --baseline should conflict"
);
}
#[test]
fn cli_parses_scan_with_diff() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--diff", "."])
.expect("scan with --diff should parse");
}
#[test]
fn cli_parses_scan_with_diff_and_base() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--diff", "--base", "main", "."])
.expect("scan with --diff --base should parse");
}
#[test]
fn cli_rejects_base_without_diff() {
let result =
Cli::command().try_get_matches_from(["slopguard", "scan", "--base", "main", "."]);
assert!(result.is_err(), "--base should require --diff");
}
#[test]
fn cli_parses_scan_with_fix() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--fix", "."])
.expect("scan with --fix should parse");
}
#[test]
fn cli_parses_scan_with_fix_dry_run() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--fix", "--dry-run", "."])
.expect("scan with --fix --dry-run should parse");
}
#[test]
fn cli_parses_scan_with_fix_allow_dirty() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--fix", "--allow-dirty", "."])
.expect("scan with --fix --allow-dirty should parse");
}
#[test]
fn cli_rejects_dry_run_without_fix() {
let result = Cli::command().try_get_matches_from(["slopguard", "scan", "--dry-run", "."]);
assert!(result.is_err(), "--dry-run should require --fix");
}
#[test]
fn cli_rejects_allow_dirty_without_fix() {
let result =
Cli::command().try_get_matches_from(["slopguard", "scan", "--allow-dirty", "."]);
assert!(result.is_err(), "--allow-dirty should require --fix");
}
#[test]
fn cli_parses_scan_with_no_escalation() {
Cli::command()
.try_get_matches_from(["slopguard", "scan", "--no-escalation", "."])
.expect("scan with --no-escalation should parse");
}
#[test]
fn cli_parses_stats_with_no_escalation() {
Cli::command()
.try_get_matches_from(["slopguard", "stats", "--no-escalation", "."])
.expect("stats with --no-escalation should parse");
}
#[test]
fn cli_parses_list_subcommand() {
Cli::command()
.try_get_matches_from(["slopguard", "list"])
.expect("list subcommand should parse");
}
#[test]
fn cli_parses_list_with_format_json() {
Cli::command()
.try_get_matches_from(["slopguard", "list", "--format", "json"])
.expect("list with --format json should parse");
}
#[test]
fn cli_parses_list_with_format_and_filters() {
Cli::command()
.try_get_matches_from([
"slopguard",
"list",
"--format",
"json",
"--category",
"slop",
])
.expect("list with --format and --category should parse");
}
}