use colored::{control::set_override, Colorize};
use comfy_table::{presets, Attribute, Cell, Color, ContentArrangement, Table};
use std::cmp::min;
use crate::audit::{AuditReport, BatchReport, BudgetSeverity, PerformanceResults};
use crate::cli::WcagLevel;
use crate::mobile::MobileFriendliness;
use crate::security::SecurityAnalysis;
use crate::seo::SeoAnalysis;
use crate::util::truncate_url;
use crate::wcag::{Severity, Violation};
pub fn print_report(report: &AuditReport, level: WcagLevel) {
println!();
print_dashboard(report, level);
if !report.wcag_results.violations.is_empty() {
print_violations_table(&report.wcag_results.violations);
}
if let Some(ref perf) = report.performance {
print_performance_section(perf);
}
if let Some(ref seo) = report.seo {
print_seo_section(seo);
}
if let Some(ref sec) = report.security {
print_security_section(sec);
}
if let Some(ref mobile) = report.mobile {
print_mobile_section(mobile);
}
if !report.budget_violations.is_empty() {
print_budget_violations_section(&report.budget_violations);
}
if let Some(ref dm) = report.dark_mode {
print_dark_mode_section(dm);
}
if let Some(ref cv) = report.content_visibility {
print_content_visibility_section(cv);
}
print_footer(report);
}
fn print_dashboard(report: &AuditReport, level: WcagLevel) {
println!("{}", "auditmysite".truecolor(140, 154, 181).bold());
println!("{}", format!("$ auditmysite {}", report.url).white().bold());
println!();
for line in dashboard_rows(report) {
println!("{line}");
}
println!();
let viewport_info = if let Some(ref vs) = report.viewport_scores {
format!(
" Desktop {} Mobile {} Weighted {}",
colorize_score(vs.desktop.overall, &vs.desktop.overall.to_string()),
colorize_score(vs.mobile.overall, &vs.mobile.overall.to_string()),
colorize_score(vs.weighted_overall, &vs.weighted_overall.to_string()),
)
} else {
String::new()
};
println!(
"{}",
format!(
" WCAG {} Nodes {} Duration {:.1}s Overall {} Certificate {}",
level,
report.nodes_analyzed,
report.duration_ms as f64 / 1000.0,
crate::audit::normalize(report).overall_score,
report.certificate
)
.dimmed()
);
if !viewport_info.is_empty() {
println!("{}", viewport_info);
}
let critical = report
.wcag_results
.violations
.iter()
.filter(|v| v.severity == Severity::Critical)
.count();
let high = report
.wcag_results
.violations
.iter()
.filter(|v| v.severity == Severity::High)
.count();
let risk_label = if critical >= 3 {
"CRITICAL"
} else if critical >= 1 && high >= 2 {
"HIGH"
} else if high >= 3 {
"MEDIUM"
} else {
"LOW"
};
let risk_colored = match risk_label {
"CRITICAL" => format!("Risk: {risk_label}").red().bold(),
"HIGH" => format!("Risk: {risk_label}").truecolor(255, 165, 0).bold(),
"MEDIUM" => format!("Risk: {risk_label}").yellow().bold(),
_ => format!("Risk: {risk_label}").green().bold(),
};
println!(" {risk_colored}");
println!();
}
fn dashboard_rows(report: &AuditReport) -> Vec<String> {
let normalized = crate::audit::normalize(report);
let mut rows = vec![render_dashboard_row(
"Accessibility",
normalized.score,
&normalized.grade,
)];
if let Some(module) = normalized.module_scores.iter().find(|m| m.name == "SEO") {
rows.push(render_dashboard_row("SEO", module.score, &module.grade));
}
if let Some(module) = normalized
.module_scores
.iter()
.find(|m| m.name == "Performance")
{
rows.push(render_dashboard_row(
"Performance",
module.score,
&module.grade,
));
}
if let Some(module) = normalized
.module_scores
.iter()
.find(|m| m.name == "Security")
{
rows.push(render_dashboard_row(
"Security",
module.score,
&module.grade,
));
}
if let Some(module) = normalized.module_scores.iter().find(|m| m.name == "Mobile") {
rows.push(render_dashboard_row("Mobile", module.score, &module.grade));
}
if let Some(module) = normalized.module_scores.iter().find(|m| m.name == "UX") {
rows.push(render_dashboard_row("UX", module.score, &module.grade));
}
if let Some(module) = normalized
.module_scores
.iter()
.find(|m| m.name == "Journey")
{
rows.push(render_dashboard_row("Journey", module.score, &module.grade));
}
rows.push(String::new());
rows.push(format!(
" {}",
render_issue_summary(
report
.wcag_results
.violations
.iter()
.filter(|v| v.severity == Severity::Critical)
.count(),
report
.wcag_results
.violations
.iter()
.filter(|v| v.severity == Severity::High)
.count(),
report
.wcag_results
.violations
.iter()
.filter(|v| v.severity == Severity::Medium)
.count(),
report
.wcag_results
.violations
.iter()
.filter(|v| v.severity == Severity::Low)
.count(),
)
));
rows
}
fn render_dashboard_row(label: &str, score: u32, grade: &str) -> String {
let bar = render_score_bar(score);
let score_text = colorize_score(score, &format!("{score}/100")).bold();
let grade_text = colorize_grade(grade, grade).bold();
format!("{label:<16} {bar} {score_text} {grade_text}")
}
fn render_score_bar(score: u32) -> String {
let slots = 18usize;
let filled = min((score as usize * slots).div_ceil(100), slots);
let filled_text = "█".repeat(filled);
let empty_text = "░".repeat(slots - filled).truecolor(55, 68, 92);
format!("{}{}", bar_color(score, &filled_text), empty_text)
}
fn render_issue_summary(critical: usize, high: usize, medium: usize, low: usize) -> String {
let total = critical + high + medium + low;
format!(
"{} {} {} {} {}",
format!("Issues {total}").white().bold(),
format!("Critical {critical}").red().bold(),
format!("High {high}").truecolor(255, 165, 0).bold(),
format!("Medium {medium}").yellow().bold(),
format!("Low {low}").dimmed(),
)
}
fn colorize_score(score: u32, text: &str) -> colored::ColoredString {
bar_color(score, text)
}
fn colorize_grade(grade: &str, text: &str) -> colored::ColoredString {
match grade {
"A+" | "A" => text.green(),
"B" => text.yellow(),
"C" => text.truecolor(255, 165, 0),
"D" | "E" | "F" => text.red(),
_ => text.white(),
}
}
fn bar_color(score: u32, text: &str) -> colored::ColoredString {
if score >= 90 {
text.green()
} else if score >= 80 {
text.truecolor(120, 214, 75)
} else if score >= 70 {
text.yellow()
} else if score >= 50 {
text.truecolor(255, 165, 0)
} else {
text.red()
}
}
fn print_violations_table(violations: &[Violation]) {
println!("{}", "Violations".bold().underline());
println!();
let mut table = Table::new();
table
.load_preset(presets::UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("Rule")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Level")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Severity")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Description")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
]);
for violation in violations {
let severity_cell = match violation.severity {
Severity::Critical => Cell::new("Critical")
.fg(Color::Red)
.add_attribute(Attribute::Bold),
Severity::High => Cell::new("High")
.fg(Color::Yellow)
.add_attribute(Attribute::Bold),
Severity::Medium => Cell::new("Medium").fg(Color::White),
Severity::Low => Cell::new("Low").fg(Color::DarkGrey),
};
let message = if violation.message.len() > 60 {
let b = violation
.message
.char_indices()
.take_while(|(i, _)| *i <= 57)
.last()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}…", &violation.message[..b])
} else {
violation.message.clone()
};
table.add_row(vec![
Cell::new(&violation.rule),
Cell::new(violation.level.to_string()),
severity_cell,
Cell::new(message),
]);
}
println!("{table}");
println!();
if !violations.is_empty() {
print_fix_suggestions(violations);
}
}
fn print_fix_suggestions(violations: &[Violation]) {
println!("{}", "Suggested Fixes".bold().underline());
println!();
let mut seen_rules = std::collections::HashSet::new();
for violation in violations {
if seen_rules.contains(&violation.rule) {
continue;
}
seen_rules.insert(violation.rule.clone());
println!(
" {} {} - {}",
"•".cyan(),
violation.rule.bold(),
violation.rule_name
);
if let Some(fix) = &violation.fix_suggestion {
println!(" {}", fix.dimmed());
}
if let Some(url) = &violation.help_url {
println!(" {} {}", "Learn more:".dimmed(), url.blue().underline());
}
println!();
}
}
fn print_footer(report: &AuditReport) {
let normalized = crate::audit::normalize(report);
let pass_fail = if normalized.score >= 70 && normalized.severity_counts.critical == 0 {
"PASS".green().bold()
} else {
"NEEDS IMPROVEMENT".red().bold()
};
println!("{}", "═".repeat(70).cyan());
println!("{} {}", "Overall:".bold(), pass_fail);
println!("{}", "═".repeat(70).cyan());
println!();
}
fn print_performance_section(perf: &PerformanceResults) {
println!("{}", "Performance".bold().underline());
println!();
println!(
" {} {}/100 ({})",
"Score:".bold(),
perf.score.overall,
perf.score.grade.label()
);
if let Some(ref lcp) = perf.vitals.lcp {
println!(" {} {:.0}ms ({})", "LCP:".bold(), lcp.value, lcp.rating);
}
if let Some(ref fcp) = perf.vitals.fcp {
println!(" {} {:.0}ms ({})", "FCP:".bold(), fcp.value, fcp.rating);
}
if let Some(ref cls) = perf.vitals.cls {
println!(" {} {:.3} ({})", "CLS:".bold(), cls.value, cls.rating);
}
if let Some(ref ttfb) = perf.vitals.ttfb {
println!(" {} {:.0}ms ({})", "TTFB:".bold(), ttfb.value, ttfb.rating);
}
println!();
}
fn print_seo_section(seo: &SeoAnalysis) {
println!("{}", "SEO".bold().underline());
println!();
println!(" {} {}/100", "Score:".bold(), seo.score);
if let Some(ref title) = seo.meta.title {
println!(" {} {}", "Title:".bold(), truncate_url(title, 60));
}
if seo.headings.h1_count == 0 {
println!(" {} {}", "H1:".bold(), "Missing!".red());
} else {
println!(" {} {} found", "H1:".bold(), seo.headings.h1_count);
}
if !seo.meta_issues.is_empty() {
println!(" {} {} issues", "Meta:".bold(), seo.meta_issues.len());
}
if seo.structured_data.has_structured_data {
println!(
" {} {} schemas detected",
"Schema.org:".bold(),
seo.structured_data.types.len()
);
}
println!();
}
fn print_security_section(sec: &SecurityAnalysis) {
println!("{}", "Security".bold().underline());
println!();
println!(" {} {}/100 ({})", "Score:".bold(), sec.score, sec.grade);
println!(" {} {}/9 present", "Headers:".bold(), sec.headers.count());
if sec.ssl.https {
println!(" {} Yes", "HTTPS:".bold());
} else {
println!(" {} {}", "HTTPS:".bold(), "No!".red());
}
if !sec.issues.is_empty() {
println!(" {} {} issues", "Issues:".bold(), sec.issues.len());
}
println!();
}
fn print_mobile_section(mobile: &MobileFriendliness) {
println!("{}", "Mobile Friendliness".bold().underline());
println!();
println!(" {} {}/100", "Score:".bold(), mobile.score);
println!(
" {} {}",
"Viewport:".bold(),
if mobile.viewport.has_viewport {
"Configured".green().to_string()
} else {
"Missing!".red().to_string()
}
);
if mobile.touch_targets.small_targets > 0 {
println!(
" {} {} too small",
"Touch Targets:".bold(),
mobile.touch_targets.small_targets
);
}
if !mobile.issues.is_empty() {
println!(" {} {} issues", "Issues:".bold(), mobile.issues.len());
}
println!();
}
fn print_dark_mode_section(dm: &crate::dark_mode::DarkModeAnalysis) {
println!("{}", "Dark Mode".bold().underline());
println!();
let support_label = if dm.supported {
"Supported".green().bold().to_string()
} else {
"Not supported".yellow().to_string()
};
println!(" {} {}", "Status:".bold(), support_label);
println!(" {} {}/100", "Score:".bold(), dm.score);
if !dm.detection_methods.is_empty() {
println!(
" {} {}",
"Methods:".bold(),
dm.detection_methods.join(", ").dimmed()
);
}
if dm.css_custom_properties > 0 {
println!(
" {} {}",
"CSS Custom Properties:".bold(),
dm.css_custom_properties
);
}
if dm.supported {
if dm.dark_only_violations > 0 {
println!(
" {} {} (dark mode only)",
"Contrast issues:".bold(),
dm.dark_only_violations.to_string().red()
);
} else if dm.dark_contrast_violations == 0 {
println!(" {} {}", "Contrast (dark):".bold(), "No issues".green());
}
if dm.light_only_violations > 0 {
println!(
" {} {} (light mode only)",
"Fixed in dark:".bold(),
dm.light_only_violations.to_string().cyan()
);
}
}
if !dm.issues.is_empty() {
println!();
for issue in &dm.issues {
let icon = match issue.severity.as_str() {
"high" => "✗".red().bold().to_string(),
"medium" => "⚠".yellow().bold().to_string(),
_ => "·".dimmed().to_string(),
};
println!(" {} {}", icon, issue.description.dimmed());
}
}
println!();
}
fn print_content_visibility_section(cv: &crate::content_visibility::ContentVisibilityAnalysis) {
use crate::assessment::AssessmentLevel;
println!("{}", "Content Visibility & Trust".bold().underline());
println!();
println!(
" {} signals analyzed, {} with optimization potential",
cv.signal_count, cv.problem_count
);
println!();
let areas: &[(&str, &[crate::assessment::ContentSignal])] = &[
("Organic Visibility", &cv.organic_visibility),
("Local Business & Trust Data", &cv.local_business),
("E-E-A-T Indicators", &cv.eeat),
("Content Depth & Localization", &cv.content_depth),
("Topical Authority (heuristic)", &cv.topical_authority),
];
for (area_name, signals) in areas {
let visible: Vec<_> = signals
.iter()
.filter(|s| s.level != AssessmentLevel::NotTestable)
.collect();
if visible.is_empty() {
continue;
}
println!(" {}", area_name.bold());
let mut table = Table::new();
table
.load_preset(presets::UTF8_BORDERS_ONLY)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("Level").add_attribute(Attribute::Bold),
Cell::new("Conf").add_attribute(Attribute::Bold),
Cell::new("Signal").add_attribute(Attribute::Bold),
]);
for signal in visible {
let level_cell = match signal.level {
AssessmentLevel::Pass => Cell::new("PASS").fg(Color::Green),
AssessmentLevel::Positive => Cell::new("POSITIVE").fg(Color::Cyan),
AssessmentLevel::Warning => Cell::new("WARN").fg(Color::Yellow),
AssessmentLevel::Violation => Cell::new("VIOLATION").fg(Color::Red),
AssessmentLevel::NotTestable => Cell::new("N/T").fg(Color::DarkGrey),
};
let conf_cell = match signal.confidence {
crate::assessment::EvidenceConfidence::High => Cell::new("●"),
crate::assessment::EvidenceConfidence::Medium => Cell::new("◐"),
crate::assessment::EvidenceConfidence::Low => Cell::new("○"),
};
let detail_truncated = if signal.detail.len() > 60 {
format!("{}…", &signal.detail[..60])
} else {
signal.detail.clone()
};
let text = format!("{} — {}", signal.title, detail_truncated);
table.add_row(vec![level_cell, conf_cell, Cell::new(text)]);
}
println!("{table}");
println!();
}
}
fn print_budget_violations_section(violations: &[crate::audit::BudgetViolation]) {
println!("{}", "Performance Budgets".bold().underline());
println!();
for v in violations {
let (icon, label) = match v.severity {
BudgetSeverity::Error => ("✗".red().bold(), v.severity.label().red().bold()),
BudgetSeverity::Warning => ("⚠".yellow().bold(), v.severity.label().yellow().bold()),
};
println!(
" {} {} {}: {} (Budget: {}, +{:.0}%)",
icon,
label,
v.metric.bold(),
v.actual_label,
v.budget_label,
v.exceeded_by_pct,
);
}
println!();
}
pub fn print_batch_table(batch_report: &BatchReport, level: WcagLevel) {
print!("{}", format_batch_table(batch_report, level, true));
}
pub fn format_batch_table(batch_report: &BatchReport, level: WcagLevel, use_color: bool) -> String {
set_override(use_color);
let presentation = crate::output::builder::build_batch_presentation(batch_report);
let mut output = String::new();
output.push('\n');
output.push_str(&format!(
"{} WCAG {} Batch Audit Results\n\n",
"═══".cyan(),
level
));
output.push_str(&format!(
" {} {} {} {:.1}/100 {} {}/100 {} {} {} {}ms\n\n",
"URLs:".bold(),
batch_report.summary.total_urls,
"Average accessibility:".bold(),
presentation.portfolio_summary.average_score,
"Average overall:".bold(),
presentation.portfolio_summary.average_overall_score,
"Violations:".bold(),
batch_report.summary.total_violations,
"Duration:".bold(),
batch_report.total_duration_ms,
));
let mut table = Table::new();
table
.load_preset(presets::UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("URL")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Score")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Overall")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Violations")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
Cell::new("Status")
.add_attribute(Attribute::Bold)
.fg(Color::Cyan),
]);
for report in &presentation.url_ranking {
let (status_cell, score_color) = if report.passed {
(
Cell::new("PASS")
.fg(Color::Green)
.add_attribute(Attribute::Bold),
Color::Green,
)
} else {
(
Cell::new("FAIL")
.fg(Color::Red)
.add_attribute(Attribute::Bold),
if report.score >= 70.0 {
Color::Yellow
} else {
Color::Red
},
)
};
table.add_row(vec![
Cell::new(truncate_url(&report.url, 55)),
Cell::new(format!("{:.0}/100", report.score)).fg(score_color),
Cell::new(format!("{}/100", report.overall_score)),
Cell::new(report.total_violations.to_string()),
status_cell,
]);
}
output.push_str(&table.to_string());
output.push('\n');
if !batch_report.errors.is_empty() {
output.push('\n');
output.push_str(&format!("{}\n", "Errors:".red().bold()));
for err in &batch_report.errors {
output.push_str(&format!(" {} {}: {}\n", "✗".red(), err.url, err.error));
}
}
set_override(false);
output
}
pub fn format_violations_list(violations: &[Violation]) -> String {
let mut output = String::new();
for (i, violation) in violations.iter().enumerate() {
output.push_str(&format!(
"{}. [{}] {} - {}\n",
i + 1,
violation.rule,
violation.severity,
violation.message
));
if let Some(fix) = &violation.fix_suggestion {
output.push_str(&format!(" Fix: {}\n", fix));
}
output.push('\n');
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_violations_list() {
let violations = vec![Violation::new(
"1.1.1",
"Non-text Content",
WcagLevel::A,
Severity::High,
"Image missing alt text",
"node-1",
)
.with_fix("Add alt attribute")];
let output = format_violations_list(&violations);
assert!(output.contains("1.1.1"));
assert!(output.contains("Image missing"));
assert!(output.contains("Add alt attribute"));
}
#[test]
fn test_render_dashboard_row_contains_score_and_grade() {
let row = render_dashboard_row("Security", 98, "A+");
assert!(row.contains("Security"));
assert!(row.contains("98/100"));
assert!(row.contains("A+"));
}
#[test]
fn test_format_batch_table_contains_summary_and_urls() {
let report = AuditReport::new(
"https://example.com".to_string(),
WcagLevel::AA,
crate::wcag::WcagResults::new(),
42,
);
let batch = BatchReport::from_reports(vec![report], vec![], 1500);
let text = format_batch_table(&batch, WcagLevel::AA, false);
assert!(text.contains("WCAG AA Batch Audit Results"));
assert!(text.contains("https://example.com"));
assert!(text.contains("Violations:"));
}
}