use crate::context::ReportContext;
use crate::reporter::{Reporter, ReporterError, ReporterOptions};
use cpd_core::health::Health;
use cpd_core::models::CpdClone;
use std::{fs, path::Path};
pub struct BadgeReporter;
impl BadgeReporter {
pub fn new(_opts: &ReporterOptions) -> Self {
Self
}
}
impl Reporter for BadgeReporter {
fn name(&self) -> &str {
"badge"
}
fn report(
&self,
_clones: &[CpdClone],
ctx: &ReportContext,
output_dir: &Path,
) -> Result<(), ReporterError> {
fs::create_dir_all(output_dir)?;
let pct = format!("{:.1}%", ctx.stats.total.percentage);
let color = duplication_color(ctx.stats.total.percentage);
let badge_svg = make_badge("duplication", &pct, color);
fs::write(output_dir.join("jscpd-badge.svg"), badge_svg)?;
let lines_str = ctx.stats.total.duplicated_lines.to_string();
let lines_badge = make_badge("dup lines", &lines_str, "#3498db");
fs::write(output_dir.join("jscpd-lines-badge.svg"), lines_badge)?;
println!(
"\x1b[32mBadge saved to {}\x1b[39m",
output_dir.join("jscpd-badge.svg").display()
);
Ok(())
}
}
pub fn duplication_color(percentage: f64) -> &'static str {
if percentage > 20.0 {
"#e74c3c"
} else if percentage > 10.0 {
"#f39c12"
} else {
"#27ae60"
}
}
fn health_color(grade: Option<char>) -> &'static str {
match grade {
Some('A') => "#27ae60",
Some('B') => "#7cb342",
Some('C') => "#f1c40f",
Some('D') => "#f39c12",
Some(_) => "#e74c3c",
None => "#9f9f9f",
}
}
pub fn health_badge(health: &Health) -> String {
let value = match (health.score, health.grade) {
(Some(score), Some(grade)) => format!("{grade} {score:.0}"),
_ => "n/a".to_string(),
};
make_badge("health", &value, health_color(health.grade))
}
pub(crate) fn make_badge(label: &str, value: &str, color: &str) -> String {
let label_width = (label.len() * 7 + 10).max(40);
let value_width = (value.len() * 7 + 10).max(30);
let total_width = label_width + value_width;
let lx = label_width / 2;
let vx = label_width + value_width / 2;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"20\">\n",
total_width
));
svg.push_str(" <linearGradient id=\"s\" x2=\"0\" y2=\"100%\">\n");
svg.push_str(" <stop offset=\"0\" stop-color=\"#bbb\" stop-opacity=\".1\"/>\n");
svg.push_str(" <stop offset=\"1\" stop-opacity=\".1\"/>\n");
svg.push_str(" </linearGradient>\n");
svg.push_str(&format!(
" <rect rx=\"3\" width=\"{}\" height=\"20\" fill=\"#555\"/>\n",
total_width
));
svg.push_str(&format!(
" <rect rx=\"3\" x=\"{}\" width=\"{}\" height=\"20\" fill=\"{}\"/>\n",
label_width, value_width, color
));
svg.push_str(&format!(
" <rect rx=\"3\" width=\"{}\" height=\"20\" fill=\"url(#s)\"/>\n",
total_width
));
svg.push_str(" <g fill=\"#fff\" text-anchor=\"middle\" font-family=\"DejaVu Sans,sans-serif\" font-size=\"11\">\n");
svg.push_str(&format!(
" <text x=\"{}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{}</text>\n",
lx, label
));
svg.push_str(&format!(
" <text x=\"{}\" y=\"14\">{}</text>\n",
lx, label
));
svg.push_str(&format!(
" <text x=\"{}\" y=\"15\" fill=\"#010101\" fill-opacity=\".3\">{}</text>\n",
vx, value
));
svg.push_str(&format!(
" <text x=\"{}\" y=\"14\">{}</text>\n",
vx, value
));
svg.push_str(" </g>\n</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::ReportContext;
use crate::reporter::ReporterOptions;
use crate::shared::fixtures::{stats_with_pct, tmp_dir};
use std::path::PathBuf;
use std::time::Duration;
#[test]
fn badge_svg_is_well_formed_xml() {
let dir = tmp_dir("badge");
let opts = ReporterOptions::new(dir.clone());
let reporter = BadgeReporter::new(&opts);
let ctx = ReportContext {
stats: &stats_with_pct(5.0, 10),
duration: Duration::ZERO,
summary: None,
history: None,
};
reporter.report(&[], &ctx, &dir).unwrap();
let content = std::fs::read_to_string(dir.join("jscpd-badge.svg")).unwrap();
assert!(content.contains("<svg"), "badge must be SVG");
assert!(
content.contains("</svg>") || content.ends_with("/>"),
"badge SVG must be closed"
);
}
fn run_badge_report(pct: f64, duplicated_lines: u64) -> (PathBuf, PathBuf) {
let dir = tmp_dir("badge");
let opts = ReporterOptions::new(dir.clone());
let reporter = BadgeReporter::new(&opts);
let ctx = ReportContext {
stats: &stats_with_pct(pct, duplicated_lines),
duration: Duration::ZERO,
summary: None,
history: None,
};
reporter.report(&[], &ctx, &dir).unwrap();
(dir.clone(), dir.join("jscpd-badge.svg"))
}
#[test]
fn badge_contains_percentage() {
let (_dir, path) = run_badge_report(15.5, 50);
let content = std::fs::read_to_string(path).unwrap();
assert!(
content.contains("15.5"),
"badge must contain percentage value"
);
}
#[test]
fn both_badge_files_created() {
let (dir, _path) = run_badge_report(5.0, 10);
assert!(dir.join("jscpd-badge.svg").exists());
assert!(dir.join("jscpd-lines-badge.svg").exists());
}
#[test]
fn badge_color_red_for_high_duplication() {
let svg = make_badge("duplication", "25.0%", "#e74c3c");
assert!(svg.contains("#e74c3c"));
}
fn sample_health(score: Option<f64>, grade: Option<char>) -> Health {
use cpd_core::health::Size;
Health {
score,
grade,
size: Size {
lines: 43_390,
files: 105,
class: "M",
},
dimensions: vec![],
skipped: vec![],
}
}
#[test]
fn health_svg_carries_grade_and_score() {
let svg = health_badge(&sample_health(Some(73.4), Some('B')));
assert!(svg.contains(">B 73<") && svg.contains("#7cb342"), "{svg}");
}
#[test]
fn an_unscored_project_gets_a_grey_health_badge() {
let svg = health_badge(&sample_health(None, None));
assert!(svg.contains(">n/a<") && svg.contains("#9f9f9f"), "{svg}");
}
}