jscpd-rs 0.1.6

50x+ faster duplicate-code detector for CI/CD; jscpd-compatible CLI, SARIF, JSON, HTML reports
Documentation
use anyhow::Result;
use serde::Serialize;

use super::file_output::write_file_report;
use super::source::slice_range;
use crate::cli::Options;
use crate::detector::{BlamedLines, CloneMatch, DetectionResult, Statistics, clone_lines};

pub(super) fn write(result: &DetectionResult, options: &Options) -> Result<()> {
    let json = to_pretty_json(result)?;
    write_file_report(options, "jscpd-report.json", "JSON report", json)
}

pub(super) fn to_pretty_json(result: &DetectionResult) -> Result<String> {
    Ok(serde_json::to_string_pretty(&JsonReport::from_detection(
        result,
    ))?)
}

#[derive(Serialize)]
struct JsonReport {
    duplicates: Vec<JsonDuplicate>,
    statistics: Statistics,
}

#[derive(Serialize)]
struct JsonDuplicate {
    format: String,
    lines: usize,
    tokens: usize,
    #[serde(rename = "firstFile")]
    first_file: JsonFile,
    #[serde(rename = "secondFile")]
    second_file: JsonFile,
    fragment: String,
}

#[derive(Serialize)]
struct JsonFile {
    name: String,
    start: usize,
    end: usize,
    #[serde(rename = "startLoc")]
    start_loc: crate::tokenizer::Location,
    #[serde(rename = "endLoc")]
    end_loc: crate::tokenizer::Location,
    #[serde(skip_serializing_if = "Option::is_none")]
    blame: Option<BlamedLines>,
}

impl JsonReport {
    fn from_detection(result: &DetectionResult) -> Self {
        Self {
            duplicates: result
                .clones
                .iter()
                .map(|clone| JsonDuplicate::from_clone(clone, result))
                .collect(),
            statistics: result.statistics.clone(),
        }
    }
}

impl JsonDuplicate {
    fn from_clone(clone: &CloneMatch, result: &DetectionResult) -> Self {
        let fragment = result
            .source_contents
            .get(&clone.duplication_a.source_id)
            .map(|content| slice_range(content, clone.duplication_a.range))
            .unwrap_or_default();

        Self {
            format: clone.format.clone(),
            lines: clone_lines(clone),
            tokens: 0,
            first_file: JsonFile {
                name: clone.duplication_a.source_id.clone(),
                start: clone.duplication_a.start.line,
                end: clone.duplication_a.end.line,
                start_loc: clone.duplication_a.start.clone(),
                end_loc: clone.duplication_a.end.clone(),
                blame: clone.duplication_a.blame.clone(),
            },
            second_file: JsonFile {
                name: clone.duplication_b.source_id.clone(),
                start: clone.duplication_b.start.line,
                end: clone.duplication_b.end.line,
                start_loc: clone.duplication_b.start.clone(),
                end_loc: clone.duplication_b.end.clone(),
                blame: clone.duplication_b.blame.clone(),
            },
            fragment,
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::report::test_support::{make_test_result_with_clone, single_line_blame};

    use super::to_pretty_json;

    #[test]
    fn json_report_includes_blame_when_present() {
        let mut result = make_test_result_with_clone("src/a.js", "src/b.js");
        result.clones[0].duplication_a.blame = Some(single_line_blame(
            "2",
            "abc123",
            "Alice",
            "2024-01-01 00:00:00 +0000",
        ));

        let json = to_pretty_json(&result).unwrap();

        assert!(json.contains(r#""blame""#));
        assert!(json.contains(r#""author": "Alice""#));
        assert!(json.contains(r#""rev": "abc123""#));
    }
}