diff --git a/Cargo.lock b/Cargo.lock index 4c18be6..04c86c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "lexical-core" version = "1.0.5" @@ -646,6 +652,8 @@ dependencies = [ "noodles-csi", "noodles-sam", "regex", + "serde", + "serde_json", "smallvec", "thiserror", "tokio", @@ -671,6 +679,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.217" @@ -691,6 +705,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 4a6d444..f3fe42c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,8 @@ smallvec = "1.7.0" vec_map = "0.8.2" flate2 = { version = "1.0.24", optional = true} lexical-core = { version = "1.0.5", features = ["std", "parse-integers"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" [profile.release] debug = 1 diff --git a/src/app.rs b/src/app.rs index dacc672..f192f3b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -36,6 +36,10 @@ pub struct Config { #[clap(short = 'm', long, value_name = "FILE", display_order = 3)] pub metrics: Option, + /// Sample name to include in JSON metrics output. + #[clap(short = 's', long, value_name = "NAME", display_order = 4)] + pub sample_name: Option, + /// Ignore previous duplicate marking applied to BAM file. This information is extracted from /// the header. Use --force to redo duplicate marking. #[clap(short, long, display_order = 50)] @@ -402,7 +406,8 @@ impl App { .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("json")) { - self.metrics.write_json(mout)?; + self.metrics + .write_json(&mut mout, self.config.sample_name.as_deref())?; } else { use std::io::Write; write!(mout, "{}", self.metrics)?; diff --git a/src/bktree.rs b/src/bktree.rs index 2812882..9767b21 100644 --- a/src/bktree.rs +++ b/src/bktree.rs @@ -308,7 +308,7 @@ mod tests { let mut from_root = tree.get_children(0); from_root.sort(); assert_eq!(from_root, vec![1, 2, 3]); - assert_eq!(tree.get_children(2), vec![]); + assert_eq!(tree.get_children(2), Vec::::new()); assert_eq!(tree.get_children(1), vec![3]); } diff --git a/src/metrics.rs b/src/metrics.rs index ae07a17..fc15589 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,8 +1,30 @@ use std::fmt; use std::io::{Error as IoError, Write}; +use serde::Serialize; + use crate::record::Flags; +/// Serializable metrics report. Produced from [`Metrics`] for JSON output. +/// Field names match the existing hand-rolled JSON and Picard-style TSV headers. +#[derive(Debug, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub struct MetricsReport<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_name: Option<&'a str>, + pub unpaired_reads_examined: usize, + pub paired_reads_examined: usize, + pub secondary_or_supplementary_rds: usize, + pub unmapped_reads: usize, + pub unpaired_read_duplicates: usize, + pub unpaired_read_optical_duplicates: usize, + pub read_pair_duplicates: usize, + pub read_pair_optical_duplicates: usize, + pub corrected_umis: usize, + pub fraction_duplication: Option, + pub estimated_library_size: u64, +} + /// Duplication metrics. #[derive(Debug, Default)] pub struct Metrics { @@ -159,7 +181,32 @@ impl Metrics { } } - pub fn write_json(&self, mut w: W) -> Result<(), IoError> { + pub fn to_report<'a>(&self, sample_name: Option<&'a str>) -> MetricsReport<'a> { + let frac = self.fraction_duplication(); + MetricsReport { + sample_name, + unpaired_reads_examined: self.unpaired_reads_examined, + paired_reads_examined: self.paired_reads_examined, + secondary_or_supplementary_rds: self.secondary_or_supplementary_rds, + unmapped_reads: self.unmapped_reads, + unpaired_read_duplicates: self.unpaired_read_duplicates, + unpaired_read_optical_duplicates: self.unpaired_read_optical_duplicates, + read_pair_duplicates: self.read_pair_duplicates, + read_pair_optical_duplicates: self.read_pair_optical_duplicates, + corrected_umis: self.corrected_umis, + fraction_duplication: if frac.is_finite() { Some(frac) } else { None }, + estimated_library_size: self.estimated_library_size(), + } + } + + pub fn write_json(&self, w: W, sample_name: Option<&str>) -> Result<(), IoError> { + serde_json::to_writer(w, &self.to_report(sample_name)) + .map_err(|e| IoError::new(std::io::ErrorKind::Other, e)) + } + + /// Legacy hand-rolled JSON, kept for testing equivalence with serde output. + #[cfg(test)] + fn write_json_legacy(&self, mut w: W) -> Result<(), IoError> { writeln!(w, "{{\"UNPAIRED_READS_EXAMINED\":{}, \"PAIRED_READS_EXAMINED\":{}, \"SECONDARY_OR_SUPPLEMENTARY_RDS\":{}, \"UNMAPPED_READS\":{}, \"UNPAIRED_READ_DUPLICATES\":{}, \"UNPAIRED_READ_OPTICAL_DUPLICATES\":{}, \"READ_PAIR_DUPLICATES\":{}, \"READ_PAIR_OPTICAL_DUPLICATES\":{}, \"CORRECTED_UMIS\":{}, \"FRACTION_DUPLICATION\":{}, \"ESTIMATED_LIBRARY_SIZE\":{}}}", self.unpaired_reads_examined, self.paired_reads_examined, @@ -174,3 +221,84 @@ impl Metrics { self.estimated_library_size()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_metrics() -> Metrics { + let mut m = Metrics::default(); + m.unpaired_reads_examined = 1000; + m.paired_reads_examined = 5000; + m.secondary_or_supplementary_rds = 50; + m.unmapped_reads = 30; + m.unpaired_read_duplicates = 100; + m.unpaired_read_optical_duplicates = 10; + m.read_pair_duplicates = 400; + m.read_pair_optical_duplicates = 40; + m.corrected_umis = 25; + m + } + + #[test] + fn serde_matches_legacy_json_values() { + let m = make_metrics(); + + // Legacy output (may contain NaN for zero-read case, so test with real data) + let mut legacy_buf = Vec::new(); + m.write_json_legacy(&mut legacy_buf).unwrap(); + let legacy: serde_json::Value = serde_json::from_slice(&legacy_buf).unwrap(); + + // New serde output (without sample_name, to match legacy fields) + let mut serde_buf = Vec::new(); + m.write_json(&mut serde_buf, None).unwrap(); + let new: serde_json::Value = serde_json::from_slice(&serde_buf).unwrap(); + + // Compare every field that exists in the legacy output + let legacy_obj = legacy.as_object().unwrap(); + let new_obj = new.as_object().unwrap(); + assert_eq!( + legacy_obj.len(), + new_obj.len(), + "field count mismatch: legacy has {}, new has {}", + legacy_obj.len(), + new_obj.len() + ); + for (key, legacy_val) in legacy_obj { + let new_val = new_obj + .get(key) + .unwrap_or_else(|| panic!("missing key: {key}")); + assert_eq!( + legacy_val, new_val, + "mismatch for {key}: legacy={legacy_val}, new={new_val}" + ); + } + } + + #[test] + fn zero_reads_emits_null_fraction() { + let m = Metrics::default(); + let mut buf = Vec::new(); + m.write_json(&mut buf, None).unwrap(); + let val: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert!(val["FRACTION_DUPLICATION"].is_null()); + } + + #[test] + fn json_includes_sample_name_when_provided() { + let m = make_metrics(); + let mut buf = Vec::new(); + m.write_json(&mut buf, Some("sample_42")).unwrap(); + let val: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert_eq!(val["SAMPLE_NAME"], "sample_42"); + } + + #[test] + fn json_omits_sample_name_when_none() { + let m = make_metrics(); + let mut buf = Vec::new(); + m.write_json(&mut buf, None).unwrap(); + let val: serde_json::Value = serde_json::from_slice(&buf).unwrap(); + assert!(val.get("SAMPLE_NAME").is_none()); + } +}