cpd-reporter 0.1.2

Output format reporters for cpd
Documentation
use crate::context::ReportContext;
use crate::reporter::{Reporter, ReporterError, ReporterOptions};
use cpd_core::models::CpdClone;
use serde_json::json;
use std::{fs, path::Path};

pub struct JsonReporter {
    blame: bool,
    no_colors: bool,
}

impl JsonReporter {
    pub fn new(opts: &ReporterOptions) -> Self {
        Self {
            blame: opts.blame,
            no_colors: opts.no_colors,
        }
    }
}

fn clone_to_dup(clone: &CpdClone, include_blame: bool) -> serde_json::Value {
    let lines = clone
        .fragment_a
        .end
        .line
        .saturating_sub(clone.fragment_a.start.line)
        + 1;

    let mut first_file = json!({
        "name": clone.fragment_a.source_id,
        "start": clone.fragment_a.start.line,
        "end": clone.fragment_a.end.line,
    });
    let mut second_file = json!({
        "name": clone.fragment_b.source_id,
        "start": clone.fragment_b.start.line,
        "end": clone.fragment_b.end.line,
    });

    if include_blame {
        if let Some(ref blame) = clone.fragment_a.blame {
            first_file["blame"] = json!({
                "commitSha": blame.commit_sha,
                "author": blame.author,
            });
        }
        if let Some(ref blame) = clone.fragment_b.blame {
            second_file["blame"] = json!({
                "commitSha": blame.commit_sha,
                "author": blame.author,
            });
        }
    }

    json!({
        "format": clone.format,
        "lines": lines,
        "tokens": clone.token_count,
        "firstFile": first_file,
        "secondFile": second_file,
    })
}

impl Reporter for JsonReporter {
    fn name(&self) -> &str {
        "json"
    }

    fn report(
        &self,
        clones: &[CpdClone],
        ctx: &ReportContext,
        output_dir: &Path,
    ) -> Result<(), ReporterError> {
        fs::create_dir_all(output_dir)?;
        let path = output_dir.join("jscpd-report.json");

        let duplicates: Vec<serde_json::Value> =
            clones.iter().map(|c| clone_to_dup(c, self.blame)).collect();

        let value = json!({
            "statistics": ctx.stats,
            "duplicates": duplicates,
        });

        let content = serde_json::to_string_pretty(&value)
            .map_err(|e| ReporterError::Format(e.to_string()))?;
        fs::write(&path, content)?;

        let path_display = path.display();
        if self.no_colors {
            println!("JSON report saved to {}", path_display);
        } else {
            println!("\x1b[32mJSON report saved to {}\x1b[39m", path_display);
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::ReportContext;
    use crate::reporter::ReporterOptions;
    use cpd_core::models::{BlameEntry, CpdClone, Fragment, Location, StatRow, Statistics};
    use std::collections::HashMap;
    use std::path::PathBuf;
    use std::time::Duration;

    fn tmp_dir() -> PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "cpd-json-test-{}",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.subsec_nanos())
                .unwrap_or(0)
        ));
        std::fs::create_dir_all(&dir).ok();
        dir
    }

    fn empty_stats() -> Statistics {
        Statistics {
            total: StatRow {
                lines: 0,
                tokens: 0,
                sources: 0,
                clones: 0,
                duplicated_lines: 0,
                duplicated_tokens: 0,
                percentage: 0.0,
                percentage_tokens: 0.0,
            },
            formats: HashMap::new(),
            detection_date: "2026-01-01T00:00:00Z".to_string(),
        }
    }

    #[test]
    fn json_output_is_valid_json() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = JsonReporter::new(&opts);
        let ctx = ReportContext {
            stats: &empty_stats(),
            duration: Duration::ZERO,
        };
        reporter.report(&[], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.json")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert!(parsed.get("statistics").is_some());
        assert!(parsed.get("duplicates").is_some());
    }

    #[test]
    fn json_uses_first_file_second_file_keys() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = JsonReporter::new(&opts);
        let loc = Location {
            line: 5,
            column: 0,
            offset: 0,
        };
        let end = Location {
            line: 15,
            column: 0,
            offset: 50,
        };
        let clone = CpdClone {
            format: "javascript".to_string(),
            fragment_a: Fragment {
                source_id: "src/a.js".to_string(),
                start: loc.clone(),
                end: end.clone(),
                range: [0, 50],
                blame: None,
            },
            fragment_b: Fragment {
                source_id: "src/b.js".to_string(),
                start: loc,
                end,
                range: [0, 50],
                blame: None,
            },
            token_count: 42,
        };
        let ctx = ReportContext {
            stats: &empty_stats(),
            duration: Duration::ZERO,
        };
        reporter.report(&[clone], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.json")).unwrap();
        assert!(content.contains("\"firstFile\""), "must use firstFile key");
        assert!(
            content.contains("\"secondFile\""),
            "must use secondFile key"
        );
        assert!(content.contains("\"lines\""), "must include lines field");
        assert!(content.contains("\"tokens\""), "must include tokens field");
    }

    #[test]
    fn json_with_blame_includes_sha() {
        let dir = tmp_dir();
        let mut opts = ReporterOptions::new(dir.clone());
        opts.blame = true;
        let reporter = JsonReporter::new(&opts);
        let blame = BlameEntry {
            commit_sha: "abc123".to_string(),
            author: "Alice".to_string(),
            timestamp: 1_700_000_000,
        };
        let loc = Location {
            line: 1,
            column: 0,
            offset: 0,
        };
        let clone = CpdClone {
            format: "javascript".to_string(),
            fragment_a: Fragment {
                source_id: "a.js".to_string(),
                start: loc.clone(),
                end: loc.clone(),
                range: [0, 10],
                blame: Some(blame),
            },
            fragment_b: Fragment {
                source_id: "b.js".to_string(),
                start: loc.clone(),
                end: loc,
                range: [0, 10],
                blame: None,
            },
            token_count: 50,
        };
        let ctx = ReportContext {
            stats: &empty_stats(),
            duration: Duration::ZERO,
        };
        reporter.report(&[clone], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.json")).unwrap();
        assert!(
            content.contains("abc123"),
            "JSON output must contain blame SHA"
        );
    }

    #[test]
    fn json_without_blame_omits_field() {
        let dir = tmp_dir();
        let opts = ReporterOptions::new(dir.clone());
        let reporter = JsonReporter::new(&opts);
        let loc = Location {
            line: 1,
            column: 0,
            offset: 0,
        };
        let clone = CpdClone {
            format: "js".to_string(),
            fragment_a: Fragment {
                source_id: "a.js".to_string(),
                start: loc.clone(),
                end: loc.clone(),
                range: [0, 5],
                blame: None,
            },
            fragment_b: Fragment {
                source_id: "b.js".to_string(),
                start: loc.clone(),
                end: loc,
                range: [0, 5],
                blame: None,
            },
            token_count: 10,
        };
        let ctx = ReportContext {
            stats: &empty_stats(),
            duration: Duration::ZERO,
        };
        reporter.report(&[clone], &ctx, &dir).unwrap();
        let content = std::fs::read_to_string(dir.join("jscpd-report.json")).unwrap();
        assert!(
            !content.contains("commitSha"),
            "without blame, should not contain blame fields"
        );
    }

    #[test]
    fn json_reporter_name_is_json() {
        let opts = ReporterOptions::new(std::path::PathBuf::from("/tmp"));
        assert_eq!(JsonReporter::new(&opts).name(), "json");
    }
}