use crate::diagnostics::{Category, Diagnostic, DimensionScores, ScanResult, ScoreLabel, Severity};
use owo_colors::{OwoColorize, Stream};
use std::collections::{HashMap, HashSet};
const ERROR_RULE_PENALTY: f64 = 1.5;
const WARNING_RULE_PENALTY: f64 = 0.75;
const INFO_RULE_PENALTY: f64 = 0.25;
const SCORE_GOOD_THRESHOLD: u32 = 75;
const SCORE_OK_THRESHOLD: u32 = 50;
const SCORE_BAR_WIDTH: usize = 40;
const WEIGHT_SECURITY: f64 = 2.0;
const WEIGHT_RELIABILITY: f64 = 1.5;
const WEIGHT_MAINTAINABILITY: f64 = 1.0;
const WEIGHT_PERFORMANCE: f64 = 1.0;
const WEIGHT_DEPENDENCIES: f64 = 1.0;
const fn category_dimension(category: &Category) -> Dimension {
match category {
Category::Security => Dimension::Security,
Category::Correctness | Category::ErrorHandling | Category::Async | Category::Framework => {
Dimension::Reliability
}
Category::Architecture | Category::Style => Dimension::Maintainability,
Category::Performance => Dimension::Performance,
Category::Cargo | Category::Dependencies => Dimension::Dependencies,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Dimension {
Security,
Reliability,
Maintainability,
Performance,
Dependencies,
}
fn dimension_score(error_count: usize, warning_count: usize, info_count: usize) -> u32 {
let penalty = (info_count as f64).mul_add(
INFO_RULE_PENALTY,
(error_count as f64).mul_add(
ERROR_RULE_PENALTY,
warning_count as f64 * WARNING_RULE_PENALTY,
),
);
(100.0 - penalty).round().clamp(0.0, 100.0) as u32
}
pub fn calculate_score(diagnostics: &[Diagnostic]) -> (u32, ScoreLabel, DimensionScores) {
let mut dim_errors: HashMap<Dimension, HashSet<&str>> = HashMap::new();
let mut dim_warnings: HashMap<Dimension, HashSet<&str>> = HashMap::new();
let mut dim_infos: HashMap<Dimension, HashSet<&str>> = HashMap::new();
for d in diagnostics {
let dim = category_dimension(&d.category);
match d.severity {
Severity::Error => {
dim_errors.entry(dim).or_default().insert(d.rule.as_str());
}
Severity::Warning => {
dim_warnings.entry(dim).or_default().insert(d.rule.as_str());
}
Severity::Info => {
dim_infos.entry(dim).or_default().insert(d.rule.as_str());
}
}
}
let score_for = |dim: Dimension| -> u32 {
dimension_score(
dim_errors.get(&dim).map_or(0, HashSet::len),
dim_warnings.get(&dim).map_or(0, HashSet::len),
dim_infos.get(&dim).map_or(0, HashSet::len),
)
};
let security = score_for(Dimension::Security);
let reliability = score_for(Dimension::Reliability);
let maintainability = score_for(Dimension::Maintainability);
let performance = score_for(Dimension::Performance);
let dependencies = score_for(Dimension::Dependencies);
let dimensions = DimensionScores {
security,
reliability,
maintainability,
performance,
dependencies,
};
let total_weight = WEIGHT_SECURITY
+ WEIGHT_RELIABILITY
+ WEIGHT_MAINTAINABILITY
+ WEIGHT_PERFORMANCE
+ WEIGHT_DEPENDENCIES;
let weighted_sum = f64::from(security).mul_add(
WEIGHT_SECURITY,
f64::from(reliability).mul_add(
WEIGHT_RELIABILITY,
f64::from(maintainability).mul_add(
WEIGHT_MAINTAINABILITY,
f64::from(performance).mul_add(
WEIGHT_PERFORMANCE,
f64::from(dependencies) * WEIGHT_DEPENDENCIES,
),
),
),
);
let score = (weighted_sum / total_weight).round().clamp(0.0, 100.0) as u32;
let label = score_label(score);
(score, label, dimensions)
}
const fn score_label(score: u32) -> ScoreLabel {
if score >= SCORE_GOOD_THRESHOLD {
ScoreLabel::Great
} else if score >= SCORE_OK_THRESHOLD {
ScoreLabel::NeedsWork
} else {
ScoreLabel::Critical
}
}
pub fn render_terminal(result: &ScanResult, verbose: bool) {
if result.source_file_count == 0 && result.diagnostics.is_empty() {
eprintln!(
"{}",
"No Rust source files found".if_supports_color(Stream::Stderr, |t| t.yellow())
);
return;
}
if !result.diagnostics.is_empty() {
print_diagnostics(&result.diagnostics, verbose);
eprintln!();
}
print_score_box(result);
}
fn print_score_box(result: &ScanResult) {
let score = result.score;
let label = &result.score_label;
let (eyes, mouth) = if score >= SCORE_GOOD_THRESHOLD {
("◠ ◠", " ▽ ")
} else if score >= SCORE_OK_THRESHOLD {
("• •", " ─ ")
} else {
("x x", " △ ")
};
let score_text = format!("{score} / 100 {label}");
let bar = build_score_bar(score);
let ds = &result.dimension_scores;
let dim_text = format!(
"Security: {} Reliability: {} Maintainability: {} Performance: {} Dependencies: {}",
ds.security, ds.reliability, ds.maintainability, ds.performance, ds.dependencies
);
let info_part = if result.info_count > 0 {
format!(" ℹ {} info(s)", result.info_count)
} else {
String::new()
};
let stats = format!(
"{} {} error(s) {} {} warning(s){info_part} {} files {:.1}s",
if result.error_count > 0 { "✗" } else { "✓" },
result.error_count,
if result.warning_count > 0 {
"⚠"
} else {
"✓"
},
result.warning_count,
result.source_file_count,
result.elapsed.as_secs_f64(),
);
let max_width = [
7, score_text.chars().count(),
bar.plain.chars().count(),
dim_text.chars().count(),
stats.chars().count(),
]
.into_iter()
.max()
.unwrap_or(40)
.max(40);
let inner_width = max_width + 2;
let dim =
|s: &str| -> String { format!("{}", s.if_supports_color(Stream::Stdout, |t| t.dimmed())) };
let top = format!(
" {}{}{}",
dim("┌"),
dim(&"─".repeat(inner_width)),
dim("┐"),
);
let bottom = format!(
" {}{}{}",
dim("└"),
dim(&"─".repeat(inner_width)),
dim("┘"),
);
let pad_line = |content: &str, plain_len: usize| -> String {
let padding = inner_width.saturating_sub(plain_len + 2);
format!(
" {} {} {}{}",
dim("│"),
content,
" ".repeat(padding),
dim("│")
)
};
let empty_line =
|| -> String { format!(" {} {}{}", dim("│"), " ".repeat(inner_width - 2), dim("│")) };
println!("{top}");
println!("{}", pad_line("┌─────┐", 7));
println!(
"{}",
pad_line(&format!("│ {} │", colorize_by_score(eyes, score)), 7)
);
println!(
"{}",
pad_line(&format!("│ {} │", colorize_by_score(mouth, score)), 7)
);
println!("{}", pad_line("└─────┘", 7));
println!(
"{}",
pad_line(
&format!(
"{}",
"rust-doctor".if_supports_color(Stream::Stdout, |t| t.bold())
),
11,
)
);
println!("{}", empty_line());
let colored_score = colorize_by_score(&score_text, score);
println!("{}", pad_line(&colored_score, score_text.len()));
println!("{}", empty_line());
println!("{}", pad_line(&bar.colored, bar.plain.chars().count()));
println!("{}", empty_line());
let colored_dim = format!(
"{}: {} {}: {} {}: {} {}: {} {}: {}",
"Security".if_supports_color(Stream::Stdout, |t| t.dimmed()),
colorize_by_score(&ds.security.to_string(), ds.security),
"Reliability".if_supports_color(Stream::Stdout, |t| t.dimmed()),
colorize_by_score(&ds.reliability.to_string(), ds.reliability),
"Maintainability".if_supports_color(Stream::Stdout, |t| t.dimmed()),
colorize_by_score(&ds.maintainability.to_string(), ds.maintainability),
"Performance".if_supports_color(Stream::Stdout, |t| t.dimmed()),
colorize_by_score(&ds.performance.to_string(), ds.performance),
"Dependencies".if_supports_color(Stream::Stdout, |t| t.dimmed()),
colorize_by_score(&ds.dependencies.to_string(), ds.dependencies),
);
println!("{}", pad_line(&colored_dim, dim_text.len()));
println!("{}", empty_line());
let colored_info_part = if result.info_count > 0 {
format!(
" {} {} info(s)",
"ℹ".if_supports_color(Stream::Stdout, |t| t.cyan()),
result.info_count
)
} else {
String::new()
};
let colored_stats = format!(
"{} {} error(s) {} {} warning(s){colored_info_part} {} files {:.1}s",
colorize_by_score(
if result.error_count > 0 { "✗" } else { "✓" },
if result.error_count > 0 { 0 } else { 100 }
),
result.error_count,
colorize_by_score(
if result.warning_count > 0 {
"⚠"
} else {
"✓"
},
if result.warning_count > 0 { 49 } else { 100 }
),
result.warning_count,
result.source_file_count,
result.elapsed.as_secs_f64(),
);
println!("{}", pad_line(&colored_stats, stats.len()));
println!("{bottom}");
}
struct ScoreBar {
plain: String,
colored: String,
}
fn build_score_bar(score: u32) -> ScoreBar {
let filled = ((f64::from(score) / 100.0) * SCORE_BAR_WIDTH as f64).round() as usize;
let empty = SCORE_BAR_WIDTH - filled;
let filled_str = "█".repeat(filled);
let empty_str = "░".repeat(empty);
let plain = format!("{filled_str}{empty_str}");
let dimmed_empty = empty_str.if_supports_color(Stream::Stdout, |t| t.dimmed());
let colored = format!("{}{}", colorize_by_score(&filled_str, score), dimmed_empty,);
ScoreBar { plain, colored }
}
fn colorize_by_score(text: &str, score: u32) -> String {
if score >= SCORE_GOOD_THRESHOLD {
format!("{}", text.if_supports_color(Stream::Stdout, |t| t.green()))
} else if score >= SCORE_OK_THRESHOLD {
format!("{}", text.if_supports_color(Stream::Stdout, |t| t.yellow()))
} else {
format!("{}", text.if_supports_color(Stream::Stdout, |t| t.red()))
}
}
struct DiagGroup<'a> {
rule: &'a str,
severity: Severity,
message: &'a str,
help: Option<&'a str>,
count: usize,
occurrences: Vec<DiagOccurrence<'a>>,
}
struct DiagOccurrence<'a> {
file_path: std::borrow::Cow<'a, str>,
line: Option<u32>,
column: Option<u32>,
}
fn print_diagnostics(diagnostics: &[Diagnostic], verbose: bool) {
let mut groups: HashMap<&str, DiagGroup<'_>> = HashMap::new();
for d in diagnostics {
let entry = groups.entry(&d.rule).or_insert_with(|| DiagGroup {
rule: &d.rule,
severity: d.severity,
message: &d.message,
help: d.help.as_deref(),
count: 0,
occurrences: vec![],
});
entry.count += 1;
entry.occurrences.push(DiagOccurrence {
file_path: d.file_path.to_string_lossy(),
line: d.line,
column: d.column,
});
}
let mut sorted: Vec<_> = groups.into_values().collect();
sorted.sort_by(|a, b| {
let severity_ord = |s: &Severity| match s {
Severity::Error => 0,
Severity::Warning => 1,
Severity::Info => 2,
};
severity_ord(&a.severity)
.cmp(&severity_ord(&b.severity))
.then(a.rule.cmp(b.rule))
});
for group in &sorted {
let symbol = match group.severity {
Severity::Error => format!("{}", "✗".if_supports_color(Stream::Stderr, |t| t.red())),
Severity::Warning => {
format!("{}", "⚠".if_supports_color(Stream::Stderr, |t| t.yellow()))
}
Severity::Info => {
format!("{}", "ℹ".if_supports_color(Stream::Stderr, |t| t.cyan()))
}
};
eprint!(" {symbol} {}", group.message);
if group.count > 1 {
eprint!(
" {}",
format!("({})", group.count).if_supports_color(Stream::Stderr, |t| t.dimmed())
);
}
eprintln!();
if let Some(ref help) = group.help {
eprintln!(
" {}",
help.if_supports_color(Stream::Stderr, |t| t.dimmed())
);
}
if verbose {
for occ in &group.occurrences {
let location: std::borrow::Cow<'_, str> = match (occ.line, occ.column) {
(Some(l), Some(c)) => format!("{}:{}:{}", occ.file_path, l, c).into(),
(Some(l), None) => format!("{}:{}", occ.file_path, l).into(),
_ => std::borrow::Cow::Borrowed(occ.file_path.as_ref()),
};
eprintln!(
" {}",
location.if_supports_color(Stream::Stderr, |t| t.dimmed())
);
}
}
eprintln!();
}
}
pub fn render_score(result: &ScanResult) {
if result.source_file_count == 0 {
eprintln!(
"{}",
"No Rust source files found".if_supports_color(Stream::Stderr, |t| t.yellow())
);
}
println!("{}", result.score);
}
pub fn render_json(result: &ScanResult) -> Result<(), serde_json::Error> {
let json = serde_json::to_string_pretty(result)?;
println!("{json}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostics::Category;
use std::path::PathBuf;
fn make_diag(rule: &str, severity: Severity) -> Diagnostic {
make_diag_with_category(rule, severity, Category::ErrorHandling)
}
fn make_diag_with_category(rule: &str, severity: Severity, category: Category) -> Diagnostic {
Diagnostic {
file_path: PathBuf::from("src/main.rs"),
rule: rule.to_string(),
category,
severity,
message: format!("Issue: {rule}"),
help: None,
line: Some(1),
column: None,
fix: None,
}
}
#[test]
fn test_perfect_score() {
let (score, label, dims) = calculate_score(&[]);
assert_eq!(score, 100);
assert_eq!(label, ScoreLabel::Great);
assert_eq!(dims.security, 100);
assert_eq!(dims.reliability, 100);
assert_eq!(dims.maintainability, 100);
assert_eq!(dims.performance, 100);
assert_eq!(dims.dependencies, 100);
}
#[test]
fn test_score_with_errors_in_reliability() {
let diags = vec![
make_diag("rule1", Severity::Error),
make_diag("rule2", Severity::Error),
];
let (score, label, dims) = calculate_score(&diags);
assert_eq!(dims.reliability, 97);
assert_eq!(dims.security, 100);
assert_eq!(score, 99);
assert_eq!(label, ScoreLabel::Great);
}
#[test]
fn test_score_with_warnings_in_reliability() {
let diags = vec![
make_diag("w1", Severity::Warning),
make_diag("w2", Severity::Warning),
make_diag("w3", Severity::Warning),
make_diag("w4", Severity::Warning),
];
let (score, label, dims) = calculate_score(&diags);
assert_eq!(dims.reliability, 97);
assert_eq!(score, 99);
assert_eq!(label, ScoreLabel::Great);
}
#[test]
fn test_score_duplicate_rules_counted_once() {
let diags = vec![
make_diag("rule1", Severity::Error),
make_diag("rule1", Severity::Error),
make_diag("rule1", Severity::Error),
make_diag("rule1", Severity::Error),
make_diag("rule1", Severity::Error),
];
let (score, _, dims) = calculate_score(&diags);
assert_eq!(dims.reliability, 99);
assert_eq!(score, 100);
}
#[test]
fn test_score_mixed_single_dimension() {
let mut diags = Vec::new();
for i in 0..10 {
diags.push(make_diag(&format!("err{i}"), Severity::Error));
}
for i in 0..20 {
diags.push(make_diag(&format!("warn{i}"), Severity::Warning));
}
let (score, label, dims) = calculate_score(&diags);
assert_eq!(dims.reliability, 70);
assert_eq!(score, 93);
assert_eq!(label, ScoreLabel::Great);
}
#[test]
fn test_dimension_clamped_to_zero() {
let mut diags = Vec::new();
for i in 0..100 {
diags.push(make_diag(&format!("err{i}"), Severity::Error));
}
let (score, label, dims) = calculate_score(&diags);
assert_eq!(dims.reliability, 0);
assert_eq!(score, 77);
assert_eq!(label, ScoreLabel::Great);
}
#[test]
fn test_all_dimensions_severely_degraded() {
let mut diags = Vec::new();
for i in 0..100 {
diags.push(make_diag_with_category(
&format!("sec{i}"),
Severity::Error,
Category::Security,
));
diags.push(make_diag_with_category(
&format!("err{i}"),
Severity::Error,
Category::ErrorHandling,
));
diags.push(make_diag_with_category(
&format!("arch{i}"),
Severity::Error,
Category::Architecture,
));
diags.push(make_diag_with_category(
&format!("perf{i}"),
Severity::Error,
Category::Performance,
));
diags.push(make_diag_with_category(
&format!("dep{i}"),
Severity::Error,
Category::Dependencies,
));
}
let (score, label, dims) = calculate_score(&diags);
assert_eq!(dims.security, 0);
assert_eq!(dims.reliability, 0);
assert_eq!(dims.maintainability, 0);
assert_eq!(dims.performance, 0);
assert_eq!(dims.dependencies, 0);
assert_eq!(score, 0);
assert_eq!(label, ScoreLabel::Critical);
}
#[test]
fn test_score_label_thresholds() {
assert_eq!(score_label(100), ScoreLabel::Great);
assert_eq!(score_label(75), ScoreLabel::Great);
assert_eq!(score_label(74), ScoreLabel::NeedsWork);
assert_eq!(score_label(50), ScoreLabel::NeedsWork);
assert_eq!(score_label(49), ScoreLabel::Critical);
assert_eq!(score_label(0), ScoreLabel::Critical);
}
#[test]
fn test_score_bar_full() {
let bar = build_score_bar(100);
assert_eq!(bar.plain.chars().count(), SCORE_BAR_WIDTH);
assert!(bar.plain.contains('█'));
assert!(!bar.plain.contains('░'));
}
#[test]
fn test_score_bar_empty() {
let bar = build_score_bar(0);
assert_eq!(bar.plain.chars().count(), SCORE_BAR_WIDTH);
assert!(!bar.plain.contains('█'));
assert!(bar.plain.contains('░'));
}
#[test]
fn test_score_bar_half() {
let bar = build_score_bar(50);
assert_eq!(bar.plain.chars().count(), SCORE_BAR_WIDTH);
let filled: usize = bar.plain.chars().filter(|&c| c == '█').count();
let empty: usize = bar.plain.chars().filter(|&c| c == '░').count();
assert_eq!(filled, 20);
assert_eq!(empty, 20);
}
#[test]
fn test_security_category_only_affects_security_dimension() {
let diags = vec![
make_diag_with_category("sec1", Severity::Error, Category::Security),
make_diag_with_category("sec2", Severity::Error, Category::Security),
];
let (_, _, dims) = calculate_score(&diags);
assert_eq!(dims.security, 97);
assert_eq!(dims.reliability, 100);
assert_eq!(dims.maintainability, 100);
assert_eq!(dims.performance, 100);
assert_eq!(dims.dependencies, 100);
}
#[test]
fn test_overall_is_weighted_average() {
let diags = vec![
make_diag_with_category("sec1", Severity::Error, Category::Security),
make_diag_with_category("sec2", Severity::Error, Category::Security),
];
let (score, _, _) = calculate_score(&diags);
assert_eq!(score, 99);
}
#[test]
fn test_empty_diagnostics_all_dimensions_100() {
let (score, label, dims) = calculate_score(&[]);
assert_eq!(score, 100);
assert_eq!(label, ScoreLabel::Great);
assert_eq!(dims.security, 100);
assert_eq!(dims.reliability, 100);
assert_eq!(dims.maintainability, 100);
assert_eq!(dims.performance, 100);
assert_eq!(dims.dependencies, 100);
}
#[test]
fn test_multiple_dimensions_affected() {
let diags = vec![
make_diag_with_category("sec1", Severity::Error, Category::Security),
make_diag_with_category("perf1", Severity::Warning, Category::Performance),
make_diag_with_category("style1", Severity::Info, Category::Style),
];
let (_, _, dims) = calculate_score(&diags);
assert_eq!(dims.security, 99);
assert_eq!(dims.performance, 99);
assert_eq!(dims.maintainability, 100);
assert_eq!(dims.reliability, 100);
assert_eq!(dims.dependencies, 100);
}
#[test]
fn test_dependencies_category_maps_to_dependencies_dimension() {
let diags = vec![
make_diag_with_category("dep1", Severity::Warning, Category::Dependencies),
make_diag_with_category("cargo1", Severity::Warning, Category::Cargo),
];
let (_, _, dims) = calculate_score(&diags);
assert_eq!(dims.dependencies, 99);
assert_eq!(dims.security, 100);
}
}