use clap::{Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Parser, Debug, Clone)]
#[command(
name = "auditmysite",
version,
author,
about = "Resource-efficient WCAG 2.1 AA (+ select 2.2 criteria) Accessibility Checker in Rust",
long_about = "AuditMySite analyzes web pages for WCAG 2.1 AA accessibility violations, plus select WCAG 2.2 criteria.\n\n\
It uses Chrome's Accessibility Tree via CDP for accurate detection of:\n\
- Missing alt text on images (1.1.1)\n\
- Heading hierarchy issues (2.4.6)\n\
- Unlabeled form controls (4.1.2)\n\
- Insufficient color contrast (1.4.3)\n\n\
Supported output formats: json, table, pdf, ai.\n\
Supported inputs: a single URL, --sitemap, or --url-file.\n\n\
Default single-URL behavior: generate a PDF report in the current directory."
)]
pub struct Args {
#[command(subcommand)]
pub command: Option<Command>,
#[arg(value_name = "URL")]
pub url: Option<String>,
#[arg(short = 's', long, value_name = "SITEMAP_URL", global = true)]
pub sitemap: Option<String>,
#[arg(short = 'u', long, value_name = "FILE", global = true)]
pub url_file: Option<PathBuf>,
#[arg(long, global = true)]
pub crawl: bool,
#[arg(short = 'l', long, default_value = "aa", value_enum)]
pub level: WcagLevel,
#[arg(short = 'f', long, value_enum, global = true)]
pub format: Option<OutputFormat>,
#[arg(short = 'o', long, value_name = "FILE", global = true)]
pub output: Option<PathBuf>,
#[arg(
long = "browser-path",
alias = "chrome-path",
value_name = "PATH",
env = "AUDITMYSITE_BROWSER"
)]
pub chrome_path: Option<String>,
#[arg(long, value_name = "PORT")]
pub remote_debugging_port: Option<u16>,
#[arg(
short = 'm',
long,
default_value = "0",
value_name = "NUM",
global = true
)]
pub max_pages: usize,
#[arg(long, default_value = "2", value_name = "NUM")]
pub crawl_depth: usize,
#[arg(short = 'c', long, value_name = "NUM")]
pub concurrency: Option<usize>,
#[arg(short = 't', long, value_name = "SECS")]
pub timeout: Option<u64>,
#[arg(long, default_value = "1500", value_name = "MS", global = true)]
pub stability_budget_ms: u64,
#[arg(long)]
pub no_sandbox: bool,
#[arg(long)]
pub disable_images: bool,
#[arg(short = 'v', long)]
pub verbose: bool,
#[arg(short = 'q', long, global = true)]
pub quiet: bool,
#[arg(long, default_value = "auto")]
pub color: ColorPolicy,
#[arg(long, default_value = "auto")]
pub progress: ProgressPolicy,
#[arg(long)]
pub detect_chrome: bool,
#[arg(long, global = true)]
pub full: bool,
#[arg(long)]
pub performance: bool,
#[arg(long)]
pub skip_performance: bool,
#[arg(long)]
pub seo: bool,
#[arg(long)]
pub security: bool,
#[arg(long)]
pub mobile: bool,
#[arg(long)]
pub skip_mobile: bool,
#[arg(long)]
pub design_quality: bool,
#[arg(long)]
pub ai_transparency: bool,
#[arg(long)]
pub dns_check: bool,
#[arg(long)]
pub isolate_third_party_impact: bool,
#[arg(long)]
pub check_ssr_content: bool,
#[arg(long)]
pub dismiss_consent: bool,
#[arg(long, value_enum, default_value = "full")]
pub interactive: InteractiveMode,
#[arg(long)]
pub stack: bool,
#[arg(long)]
pub reuse_cache: bool,
#[arg(long)]
pub force_refresh: bool,
#[arg(long)]
pub no_sitemap_suggest: bool,
#[arg(long)]
pub prefer_sitemap: bool,
#[arg(long)]
pub per_page_reports: bool,
#[arg(long, default_value = "standard", value_enum, global = true)]
pub report_level: ReportLevel,
#[arg(long, default_value = "de", value_parser = ["de", "en"], global = true)]
pub lang: String,
#[arg(long, global = true)]
pub also_json: bool,
#[arg(long, value_name = "PATH")]
pub logo: Option<PathBuf>,
#[arg(long, hide = true, global = true)]
pub debug_typ: bool,
#[arg(long, value_name = "PATH")]
pub export_snapshot: Option<PathBuf>,
#[arg(long, value_enum)]
pub annex: Option<AnnexKind>,
#[arg(long, default_value = "browser", value_enum)]
pub request_mode: RequestMode,
#[arg(long, global = true)]
pub report_mode: bool,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Command {
Browser {
#[command(subcommand)]
action: BrowserAction,
},
Doctor,
Plan {
url: Option<String>,
},
AccnameDiff {
url: String,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long, default_value_t = auditmysite_default_max_samples())]
max_samples: usize,
},
ReportLint {
input: PathBuf,
#[arg(long, value_enum)]
fail_on: Option<ReportLintFailOn>,
#[arg(long)]
typst_source: Option<PathBuf>,
},
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
#[value(rename_all = "lowercase")]
pub enum ReportLintFailOn {
Low,
Medium,
High,
Critical,
}
#[derive(Subcommand, Debug, Clone)]
pub enum BrowserAction {
Detect,
Install {
#[arg(long)]
headless_shell: bool,
#[arg(long)]
version: Option<String>,
#[arg(long)]
force: bool,
},
Remove {
#[arg(long)]
all: bool,
},
Path,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum WcagLevel {
#[value(name = "a", alias = "A")]
A,
#[value(name = "aa", alias = "AA")]
AA,
#[value(name = "aaa", alias = "AAA")]
AAA,
}
#[derive(ValueEnum, Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InteractiveMode {
#[value(name = "off")]
Off,
#[value(name = "basic")]
Basic,
#[default]
#[value(name = "full")]
Full,
}
impl InteractiveMode {
pub fn is_enabled(self) -> bool {
!matches!(self, InteractiveMode::Off)
}
}
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnnexKind {
#[value(name = "en301549")]
En301549,
#[value(name = "bik")]
Bik,
}
impl std::fmt::Display for WcagLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WcagLevel::A => write!(f, "A"),
WcagLevel::AA => write!(f, "AA"),
WcagLevel::AAA => write!(f, "AAA"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
#[value(name = "json")]
Json,
#[value(name = "table")]
Table,
#[value(name = "pdf")]
Pdf,
#[value(name = "ai")]
Ai,
#[value(name = "summary")]
Summary,
#[value(name = "sarif")]
Sarif,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum ReportLevel {
#[value(name = "executive")]
Executive,
#[default]
#[value(name = "standard")]
Standard,
#[value(name = "technical")]
Technical,
}
impl std::fmt::Display for ReportLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReportLevel::Executive => write!(f, "executive"),
ReportLevel::Standard => write!(f, "standard"),
ReportLevel::Technical => write!(f, "technical"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum ColorPolicy {
#[default]
#[value(name = "auto")]
Auto,
#[value(name = "always")]
Always,
#[value(name = "never")]
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
pub enum ProgressPolicy {
#[default]
#[value(name = "auto")]
Auto,
#[value(name = "always")]
Always,
#[value(name = "never")]
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RequestMode {
#[default]
Browser,
Bot,
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
OutputFormat::Json => write!(f, "json"),
OutputFormat::Table => write!(f, "table"),
OutputFormat::Pdf => write!(f, "pdf"),
OutputFormat::Ai => write!(f, "ai"),
OutputFormat::Summary => write!(f, "summary"),
OutputFormat::Sarif => write!(f, "sarif"),
}
}
}
impl Args {
pub fn effective_timeout(&self) -> u64 {
self.timeout.unwrap_or(30)
}
pub fn effective_concurrency(&self) -> usize {
self.concurrency.unwrap_or(3)
}
pub fn effective_format(&self) -> OutputFormat {
match self.format {
Some(format) => format,
None if self.per_page_reports => OutputFormat::Pdf,
None if self.url.is_some() => OutputFormat::Pdf,
None => OutputFormat::Table,
}
}
pub fn full_audit_enabled(&self) -> bool {
self.full
|| (!self.performance
&& !self.seo
&& !self.security
&& !self.mobile
&& !self.skip_performance
&& !self.skip_mobile)
}
pub fn validate(&self) -> Result<(), String> {
if self.command.is_some() {
return Ok(());
}
if !self.detect_chrome
&& self.url.is_none()
&& self.sitemap.is_none()
&& self.url_file.is_none()
{
return Err("No input specified. Provide a URL, --sitemap, or --url-file.".to_string());
}
let input_count = [
self.url.is_some(),
self.sitemap.is_some(),
self.url_file.is_some(),
]
.iter()
.filter(|&&x| x)
.count();
if input_count > 1 {
return Err(
"Only one input source allowed. Use URL, --sitemap, OR --url-file.".to_string(),
);
}
if self.crawl && self.url.is_none() {
return Err("--crawl requires a base URL input".to_string());
}
if self.crawl && (self.sitemap.is_some() || self.url_file.is_some()) {
return Err("--crawl can only be combined with a single base URL".to_string());
}
if let Some(ref url) = self.url {
url::Url::parse(url).map_err(|e| format!("Invalid URL '{}': {}", url, e))?;
}
if let Some(ref sitemap) = self.sitemap {
url::Url::parse(sitemap)
.map_err(|e| format!("Invalid sitemap URL '{}': {}", sitemap, e))?;
}
if let Some(ref file) = self.url_file {
if !file.exists() {
return Err(format!("URL file not found: {:?}", file));
}
}
let concurrency = self.effective_concurrency();
if concurrency == 0 {
return Err("Concurrency must be at least 1".to_string());
}
if concurrency > 10 {
return Err("Concurrency cannot exceed 10".to_string());
}
if self.crawl_depth == 0 {
return Err("Crawl depth must be at least 1".to_string());
}
if self.verbose && self.quiet {
return Err("Cannot use --verbose and --quiet together".to_string());
}
if self.reuse_cache && self.force_refresh {
return Err("Cannot use --reuse-cache and --force-refresh together".to_string());
}
if self.no_sitemap_suggest && self.prefer_sitemap {
return Err(
"Cannot use --no-sitemap-suggest and --prefer-sitemap together".to_string(),
);
}
Ok(())
}
}
fn auditmysite_default_max_samples() -> usize {
crate::accessibility::DEFAULT_MAX_SAMPLES
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn test_wcag_level_display() {
assert_eq!(WcagLevel::A.to_string(), "A");
assert_eq!(WcagLevel::AA.to_string(), "AA");
assert_eq!(WcagLevel::AAA.to_string(), "AAA");
}
#[test]
fn test_output_format_display() {
assert_eq!(OutputFormat::Json.to_string(), "json");
assert_eq!(OutputFormat::Table.to_string(), "table");
assert_eq!(OutputFormat::Pdf.to_string(), "pdf");
}
#[test]
fn test_interactive_defaults_to_full() {
let args = Args::parse_from(["auditmysite", "https://example.com"]);
assert_eq!(args.interactive, InteractiveMode::Full);
}
#[test]
fn test_interactive_can_be_disabled_explicitly() {
let args = Args::parse_from(["auditmysite", "https://example.com", "--interactive", "off"]);
assert_eq!(args.interactive, InteractiveMode::Off);
}
#[test]
fn test_report_mode_defaults_to_false_and_can_be_enabled() {
let args = Args::parse_from(["auditmysite", "https://example.com"]);
assert!(!args.report_mode);
let args = Args::parse_from(["auditmysite", "https://example.com", "--report-mode"]);
assert!(args.report_mode);
}
#[test]
fn test_help_does_not_reference_removed_formats_or_flags() {
let mut command = Args::command();
let help = command.render_long_help().to_string();
assert!(help.contains("--browser-path"));
assert!(help.contains("--url-file"));
assert!(help.contains("json"));
assert!(help.contains("table"));
assert!(help.contains("pdf"));
assert!(help.contains(
"Default single-URL behavior: generate a PDF report in the current directory."
));
assert!(!help.contains("html"));
assert!(!help.contains("markdown"));
assert!(!help.contains("--urls"));
}
fn test_args(url: Option<&str>) -> Args {
Args {
command: None,
url: url.map(|s| s.to_string()),
sitemap: None,
url_file: None,
crawl: false,
level: WcagLevel::AA,
format: None,
output: None,
chrome_path: None,
remote_debugging_port: None,
max_pages: 0,
crawl_depth: 2,
concurrency: None,
timeout: None,
stability_budget_ms: 1500,
no_sandbox: false,
disable_images: false,
verbose: false,
quiet: false,
color: ColorPolicy::Auto,
progress: ProgressPolicy::Auto,
detect_chrome: false,
full: false,
performance: false,
skip_performance: false,
seo: false,
security: false,
mobile: false,
skip_mobile: false,
design_quality: false,
ai_transparency: false,
dns_check: false,
isolate_third_party_impact: false,
check_ssr_content: false,
stack: false,
reuse_cache: false,
force_refresh: false,
no_sitemap_suggest: false,
prefer_sitemap: false,
per_page_reports: false,
dismiss_consent: false,
interactive: InteractiveMode::Off,
report_level: ReportLevel::Standard,
lang: "de".to_string(),
also_json: false,
logo: None,
debug_typ: false,
export_snapshot: None,
annex: None,
request_mode: RequestMode::Browser,
report_mode: false,
}
}
#[test]
fn test_validate_no_input() {
assert!(test_args(None).validate().is_err());
}
#[test]
fn test_validate_with_url() {
assert!(test_args(Some("https://example.com")).validate().is_ok());
}
#[test]
fn test_validate_invalid_url() {
assert!(test_args(Some("not-a-valid-url")).validate().is_err());
}
#[test]
fn test_validate_verbose_and_quiet() {
let mut args = test_args(Some("https://example.com"));
args.verbose = true;
args.quiet = true;
assert!(args.validate().is_err());
}
#[test]
fn test_validate_reuse_and_force_refresh_conflict() {
let mut args = test_args(Some("https://example.com"));
args.reuse_cache = true;
args.force_refresh = true;
assert!(args.validate().is_err());
}
#[test]
fn test_validate_sitemap_suggest_flags_conflict() {
let mut args = test_args(Some("https://example.com"));
args.no_sitemap_suggest = true;
args.prefer_sitemap = true;
assert!(args.validate().is_err());
}
#[test]
fn test_validate_crawl_requires_url() {
let mut args = test_args(None);
args.crawl = true;
assert!(args.validate().is_err());
}
#[test]
fn test_validate_crawl_conflicts_with_sitemap() {
let mut args = test_args(Some("https://example.com"));
args.crawl = true;
args.sitemap = Some("https://example.com/sitemap.xml".to_string());
assert!(args.validate().is_err());
}
#[test]
fn test_effective_format_defaults_to_pdf_for_single_url() {
let args = test_args(Some("https://example.com"));
assert_eq!(args.effective_format(), OutputFormat::Pdf);
}
#[test]
fn test_effective_format_defaults_to_table_for_batch() {
let mut args = test_args(None);
args.sitemap = Some("https://example.com/sitemap.xml".to_string());
assert_eq!(args.effective_format(), OutputFormat::Table);
}
#[test]
fn test_effective_format_defaults_to_pdf_for_per_page_batch_reports() {
let mut args = test_args(None);
args.sitemap = Some("https://example.com/sitemap.xml".to_string());
args.per_page_reports = true;
assert_eq!(args.effective_format(), OutputFormat::Pdf);
}
#[test]
fn test_full_audit_enabled_by_default() {
let args = test_args(Some("https://example.com"));
assert!(args.full_audit_enabled());
}
#[test]
fn test_full_audit_disabled_when_only_skip_flags_are_used() {
let mut args = test_args(Some("https://example.com"));
args.skip_mobile = true;
assert!(!args.full_audit_enabled());
}
}