jscpd-rs 0.1.6

50x+ faster duplicate-code detector for CI/CD; jscpd-compatible CLI, SARIF, JSON, HTML reports
Documentation
use std::path::PathBuf;

use anyhow::Result;
use serde_json::{Map, Value};

use super::escape::escape_xml;
use super::file_output::{ensure_output_dir, write_path};
use crate::cli::Options;
use crate::detector::DetectionResult;

pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
    ensure_output_dir(options)?;
    let path = badge_output_path(options);
    let badge = BadgeReport::from_detection(result, options).to_string();
    write_path(&path, "Badge", badge)
}

struct BadgeReport {
    subject: String,
    status: String,
    color: String,
}

impl BadgeReport {
    fn from_detection(result: &DetectionResult, options: &Options) -> Self {
        let badge_options = badge_reporter_options(options);
        Self {
            subject: badge_option_str(badge_options, "subject")
                .unwrap_or("Copy/Paste")
                .to_string(),
            status: badge_option_str(badge_options, "status")
                .map(str::to_string)
                .unwrap_or_else(|| format!("{}%", result.statistics.total.percentage)),
            color: badge_option_color(badge_options)
                .unwrap_or_else(|| badge_color(result, options).to_string()),
        }
    }
}

impl std::fmt::Display for BadgeReport {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let subject_width = text_width(&self.subject);
        let status_width = text_width(&self.status);
        let subject_rect_width = subject_width + 100;
        let status_rect_width = status_width + 100;
        let total_width = subject_rect_width + status_rect_width;
        let display_width = total_width as f64 / 10.0;
        let subject_text_x = 50;
        let status_text_x = subject_rect_width + 45;
        let subject_shadow_x = subject_text_x + 10;
        let status_shadow_x = status_text_x + 10;
        let subject = escape_xml(&self.subject);
        let status = escape_xml(&self.status);
        let color = escape_xml(&self.color);

        write!(
            f,
            "<svg width=\"{display_width:.1}\" height=\"20\" viewBox=\"0 0 {total_width} 200\" xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" aria-label=\"{subject}: {status}\">\n  <title>{subject}: {status}</title>\n  <linearGradient id=\"g\" x2=\"0\" y2=\"100%\">\n    <stop offset=\"0\" stop-opacity=\".1\" stop-color=\"#EEE\"/>\n    <stop offset=\"1\" stop-opacity=\".1\"/>\n  </linearGradient>\n  <mask id=\"m\"><rect width=\"{total_width}\" height=\"200\" rx=\"30\" fill=\"#FFF\"/></mask>\n  <g mask=\"url(#m)\">\n    <rect width=\"{subject_rect_width}\" height=\"200\" fill=\"#555\"/>\n    <rect width=\"{status_rect_width}\" height=\"200\" fill=\"{}\" x=\"{subject_rect_width}\"/>\n    <rect width=\"{total_width}\" height=\"200\" fill=\"url(#g)\"/>\n  </g>\n  <g aria-hidden=\"true\" fill=\"#fff\" text-anchor=\"start\" font-family=\"Verdana,DejaVu Sans,sans-serif\" font-size=\"110\">\n    <text x=\"{subject_shadow_x}\" y=\"148\" textLength=\"{subject_width}\" fill=\"#000\" opacity=\"0.25\">{subject}</text>\n    <text x=\"{subject_text_x}\" y=\"138\" textLength=\"{subject_width}\">{subject}</text>\n    <text x=\"{status_shadow_x}\" y=\"148\" textLength=\"{status_width}\" fill=\"#000\" opacity=\"0.25\">{status}</text>\n    <text x=\"{status_text_x}\" y=\"138\" textLength=\"{status_width}\">{status}</text>\n  </g>\n  \n</svg>",
            color
        )
    }
}

fn badge_output_path(options: &Options) -> PathBuf {
    badge_option_str(badge_reporter_options(options), "path")
        .map(PathBuf::from)
        .unwrap_or_else(|| options.output.join("jscpd-badge.svg"))
}

fn badge_reporter_options(options: &Options) -> Option<&Map<String, Value>> {
    options
        .reporters_options
        .get("badge")
        .and_then(Value::as_object)
}

fn badge_option_str<'a>(
    badge_options: Option<&'a Map<String, Value>>,
    key: &str,
) -> Option<&'a str> {
    badge_options?.get(key)?.as_str()
}

fn badge_option_color(badge_options: Option<&Map<String, Value>>) -> Option<String> {
    let color = badge_option_str(badge_options, "color")?;
    Some(
        match color {
            "green" => "#3C1",
            "blue" => "#08C",
            "red" => "#E43",
            "yellow" => "#DB1",
            "orange" => "#F73",
            "purple" => "#94E",
            "pink" => "#E5B",
            "grey" | "gray" => "#999",
            "cyan" => "#1BC",
            "black" => "#2A2A2A",
            _ => return Some(format!("#{color}")),
        }
        .to_string(),
    )
}

fn badge_color(result: &DetectionResult, options: &Options) -> &'static str {
    match options.threshold {
        Some(threshold) if result.statistics.total.percentage < threshold => "#3C1",
        Some(_) => "#E43",
        None => "#999",
    }
}

fn text_width(value: &str) -> usize {
    value.chars().map(char_width).sum::<usize>() + 26
}

fn char_width(value: char) -> usize {
    match value {
        'A'..='Z' => 73,
        'a'..='z' => 63,
        '0'..='9' => 61,
        '/' => 38,
        '.' => 31,
        '%' => 88,
        ':' => 28,
        ' ' => 35,
        _ => 63,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::report::test_support::{make_test_result_with_clone, write_test_report};
    use crate::report::write_reports;

    #[test]
    fn badge_color_matches_upstream_threshold_rules() {
        let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
        result.statistics.total.percentage = 25.0;

        assert_eq!(badge_color(&result, &Options::default()), "#999");
        assert_eq!(
            badge_color(
                &result,
                &Options {
                    threshold: Some(25.1),
                    ..Options::default()
                }
            ),
            "#3C1"
        );
        assert_eq!(
            badge_color(
                &result,
                &Options {
                    threshold: Some(25.0),
                    ..Options::default()
                }
            ),
            "#E43"
        );
    }

    #[test]
    fn badge_report_matches_upstream_default_shape() {
        let result = make_test_result_with_clone("src/a.js", "src/b.js");
        let badge = BadgeReport::from_detection(&result, &Options::default()).to_string();

        assert!(badge.starts_with("<svg "));
        assert!(badge.contains(r#"role="img" aria-label="Copy/Paste: 25%""#));
        assert!(badge.contains("<title>Copy/Paste: 25%</title>"));
        assert!(badge.contains(r##"fill="#999""##));
        assert!(badge.contains(">Copy/Paste</text>"));
        assert!(badge.contains(">25%</text>"));
    }

    #[test]
    fn badge_report_uses_reporter_options_like_upstream() {
        let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
        result.statistics.total.percentage = 25.0;
        let options = Options {
            reporters_options: serde_json::json!({
                "badge": {
                    "subject": "Duplicates",
                    "status": "blocked",
                    "color": "purple"
                }
            })
            .as_object()
            .unwrap()
            .clone(),
            ..Options::default()
        };

        let badge = BadgeReport::from_detection(&result, &options).to_string();

        assert!(badge.contains(r#"aria-label="Duplicates: blocked""#));
        assert!(badge.contains(r##"fill="#94E""##));
        assert!(badge.contains(">Duplicates</text>"));
        assert!(badge.contains(">blocked</text>"));
    }

    #[test]
    fn write_reports_writes_badge_report() {
        let svg = write_test_report("badge", "badge-report", &["jscpd-badge.svg"]);

        assert!(svg.contains("Copy/Paste"));
        assert!(svg.contains("25%"));
    }

    #[test]
    fn write_reports_uses_badge_reporter_path_option() {
        let output = crate::report::test_support::temp_output("badge-path");
        let badge_path = output.join("custom-badge.svg");
        let options = Options {
            output: output.clone(),
            reporters: vec!["badge".to_string()],
            reporters_options: serde_json::json!({
                "badge": {
                    "path": badge_path
                }
            })
            .as_object()
            .unwrap()
            .clone(),
            silent: true,
            ..Options::default()
        };
        let result = make_test_result_with_clone("src/a.js", "src/b.js");

        write_reports(&result, &options).unwrap();
        let svg = std::fs::read_to_string(&badge_path).unwrap();
        let _ = std::fs::remove_dir_all(output);

        assert!(svg.contains("Copy/Paste"));
        assert!(svg.contains("25%"));
    }
}