use crate::context::ReportContext;
use crate::reporter::{Reporter, ReporterError, ReporterOptions};
use cpd_core::models::CpdClone;
use quick_xml::Writer;
use quick_xml::events::{BytesCData, BytesDecl, BytesEnd, BytesStart, Event};
use std::collections::HashMap;
use std::{fs, io::Cursor, path::Path};
fn escape_xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'\'' => out.push_str("'"),
'"' => out.push_str("""),
_ => out.push(ch),
}
}
out
}
fn extract_lines(content: &str, start_line: u32, end_line: u32) -> String {
content
.lines()
.skip(start_line.saturating_sub(1) as usize)
.take(end_line.saturating_sub(start_line.saturating_sub(1)) as usize)
.collect::<Vec<_>>()
.join("\n")
}
fn escape_cdata(s: &str) -> String {
s.replace("]]>", "]]]]><![CDATA[>")
}
fn write_codefragment<W: std::io::Write>(
writer: &mut Writer<W>,
text: &str,
) -> Result<(), ReporterError> {
writer
.write_event(Event::Start(BytesStart::new("codefragment")))
.map_err(|e| ReporterError::Format(e.to_string()))?;
writer
.write_event(Event::CData(BytesCData::new(&escape_cdata(text))))
.map_err(|e| ReporterError::Format(e.to_string()))?;
writer
.write_event(Event::End(BytesEnd::new("codefragment")))
.map_err(|e| ReporterError::Format(e.to_string()))?;
Ok(())
}
pub struct XmlReporter;
impl XmlReporter {
pub fn new(_opts: &ReporterOptions) -> Self {
Self
}
}
impl Reporter for XmlReporter {
fn name(&self) -> &str {
"xml"
}
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.xml");
let mut file_cache: HashMap<String, String> = HashMap::new();
let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2);
writer
.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))
.map_err(|e| ReporterError::Format(e.to_string()))?;
let root_start = BytesStart::new("pmd-cpd");
writer
.write_event(Event::Start(root_start))
.map_err(|e| ReporterError::Format(e.to_string()))?;
for clone in clones {
let lines = clone
.fragment_a
.end
.line
.saturating_sub(clone.fragment_a.start.line);
let mut dup = BytesStart::new("duplication");
dup.push_attribute(("lines", lines.to_string().as_str()));
writer
.write_event(Event::Start(dup))
.map_err(|e| ReporterError::Format(e.to_string()))?;
let frag_a = file_cache
.entry(clone.fragment_a.source_id.clone())
.or_insert_with(|| {
fs::read_to_string(&clone.fragment_a.source_id).unwrap_or_default()
});
let frag_text_a = extract_lines(
frag_a,
clone.fragment_a.start.line,
clone.fragment_a.end.line,
);
let path_a = escape_xml(&clone.fragment_a.source_id);
let line_a = clone.fragment_a.start.line.to_string();
let mut file_a = BytesStart::new("file");
file_a.push_attribute(("path", path_a.as_str()));
file_a.push_attribute(("line", line_a.as_str()));
writer
.write_event(Event::Start(file_a))
.map_err(|e| ReporterError::Format(e.to_string()))?;
write_codefragment(&mut writer, &frag_text_a)?;
writer
.write_event(Event::End(BytesEnd::new("file")))
.map_err(|e| ReporterError::Format(e.to_string()))?;
let frag_b = file_cache
.entry(clone.fragment_b.source_id.clone())
.or_insert_with(|| {
fs::read_to_string(&clone.fragment_b.source_id).unwrap_or_default()
});
let frag_text_b = extract_lines(
frag_b,
clone.fragment_b.start.line,
clone.fragment_b.end.line,
);
let path_b = escape_xml(&clone.fragment_b.source_id);
let line_b = clone.fragment_b.start.line.to_string();
let mut file_b = BytesStart::new("file");
file_b.push_attribute(("path", path_b.as_str()));
file_b.push_attribute(("line", line_b.as_str()));
writer
.write_event(Event::Start(file_b))
.map_err(|e| ReporterError::Format(e.to_string()))?;
write_codefragment(&mut writer, &frag_text_b)?;
writer
.write_event(Event::End(BytesEnd::new("file")))
.map_err(|e| ReporterError::Format(e.to_string()))?;
write_codefragment(&mut writer, &frag_text_a)?;
writer
.write_event(Event::End(BytesEnd::new("duplication")))
.map_err(|e| ReporterError::Format(e.to_string()))?;
}
writer
.write_event(Event::End(BytesEnd::new("pmd-cpd")))
.map_err(|e| ReporterError::Format(e.to_string()))?;
let xml_bytes = writer.into_inner().into_inner();
fs::write(&path, xml_bytes)?;
println!("\x1b[32mXML report saved to {}\x1b[39m", path.display());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
use crate::context::ReportContext;
use crate::reporter::ReporterOptions;
use cpd_core::models::{CpdClone, Fragment, Location, StatRow, Statistics};
use std::collections::HashMap;
fn tmp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"cpd-xml-{}",
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 empty_clones_produces_valid_xml() {
let dir = tmp_dir();
let opts = ReporterOptions::new(dir.clone());
let reporter = XmlReporter::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.xml")).unwrap();
assert!(
content.contains("<pmd-cpd"),
"XML must contain root element"
);
assert!(
content.contains("</pmd-cpd>") || content.contains("<pmd-cpd/>"),
"XML must be well-formed"
);
}
#[test]
fn one_clone_produces_duplication_element() {
let dir = tmp_dir();
let file_a = dir.join("a.js");
std::fs::write(&file_a, "hello\nworld\nfoo\nbar\n").unwrap();
let file_a_str = file_a.to_string_lossy().into_owned();
let loc_start = Location {
line: 1,
column: 0,
offset: 0,
};
let loc_end = Location {
line: 3,
column: 0,
offset: 0,
};
let frag_a = Fragment {
source_id: file_a_str.clone(),
start: loc_start,
end: loc_end,
range: [0, 15],
blame: None,
};
let file_b = dir.join("b.js");
std::fs::write(&file_b, "hello\nworld\nbaz\nqux\n").unwrap();
let file_b_str = file_b.to_string_lossy().into_owned();
let loc_b_start = Location {
line: 1,
column: 0,
offset: 0,
};
let loc_b_end = Location {
line: 3,
column: 0,
offset: 0,
};
let frag_b = Fragment {
source_id: file_b_str,
start: loc_b_start,
end: loc_b_end,
range: [0, 15],
blame: None,
};
let clone = CpdClone {
format: "javascript".to_string(),
fragment_a: frag_a,
fragment_b: frag_b,
token_count: 50,
};
let opts = ReporterOptions::new(dir.clone());
let reporter = XmlReporter::new(&opts);
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.xml")).unwrap();
assert!(
content.contains("<duplication"),
"XML must contain duplication element"
);
assert!(content.contains("a.js"), "XML must contain file path");
assert!(
content.contains("<codefragment>"),
"XML must contain codefragment element"
);
assert!(
content.contains("<![CDATA["),
"XML must contain CDATA section"
);
assert!(
!content.contains("tokens="),
"XML must not contain tokens attribute (TS compat)"
);
assert!(
!content.contains("endline="),
"XML must not contain endline attribute (TS compat)"
);
}
#[test]
fn xml_reporter_name() {
let opts = ReporterOptions::new(std::env::temp_dir());
assert_eq!(XmlReporter::new(&opts).name(), "xml");
}
}