1use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[cfg_attr(test, derive(strum::EnumIter))]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub enum RejectionReason {
18 InsufficientSupport,
20 MinorityAlignment,
22 InsufficientStrandSupport,
24 LowBaseQuality,
26 ExcessiveNBases,
28 NoValidAlignment,
30 LowMappingQuality,
32 NBasesInUmi,
34 MissingUmi,
36 NotPassingFilter,
38 LowMeanQuality,
40 InsufficientMinDepth,
42 ExcessiveErrorRate,
44 UmiTooShort,
46 SameStrandOnly,
48 NonPairedReads,
50 DuplicateUmi,
52 OrphanConsensus,
54 ZeroBasesPostTrimming,
56 NotPrimaryFrPair,
58 R1R2OverlapTooShort,
61 IndelErrorBetweenStrands,
64 HighDuplexDisagreement,
67 ClipOverlapFailed,
69}
70
71impl RejectionReason {
72 #[must_use]
74 pub fn description(&self) -> &'static str {
75 match self {
76 Self::InsufficientSupport => "Insufficient reads to generate a consensus",
77 Self::MinorityAlignment => "Reads has a different, and minority, set of indels",
79 Self::InsufficientStrandSupport => "Too few reads agreed on the strand orientation",
80 Self::LowBaseQuality => "Base quality scores were below threshold",
81 Self::ExcessiveNBases => "Read group had too many N bases",
82 Self::NoValidAlignment => "Template had no valid alignments",
83 Self::LowMappingQuality => "Reads failed mapping quality threshold",
84 Self::NBasesInUmi => "UMI contained N bases",
85 Self::MissingUmi => "Read lacks required UMI tag",
86 Self::NotPassingFilter => "Reads were marked as not passing filter",
87 Self::LowMeanQuality => "Consensus read had too low mean quality",
88 Self::InsufficientMinDepth => "Consensus read had insufficient minimum depth",
89 Self::ExcessiveErrorRate => "Consensus read had excessive error rate",
90 Self::UmiTooShort => "UMI was too short",
91 Self::SameStrandOnly => "Template had reads on same strand only",
92 Self::NonPairedReads => "Unpaired/fragment reads not supported by Duplex caller",
93 Self::DuplicateUmi => "Potential collision between independent duplex molecules",
94 Self::OrphanConsensus => "Only one of R1 or R2 consensus generated",
95 Self::ZeroBasesPostTrimming => "Read or mate had zero bases post trimming",
96 Self::NotPrimaryFrPair => "Template did not have a single primary FR pair of reads",
97 Self::R1R2OverlapTooShort => "Overlap between R1s and R2s too short for CODEC calling",
98 Self::IndelErrorBetweenStrands => "Indel error between top/bottom strands",
99 Self::HighDuplexDisagreement => "Too many errors between top/bottoms strands",
101 Self::ClipOverlapFailed => "See https://github.com/fulcrumgenomics/fgbio/issues/1090",
102 }
103 }
104
105 #[must_use]
107 pub fn tsv_key(&self) -> &'static str {
108 match self {
109 Self::InsufficientSupport => "raw_reads_rejected_for_insufficient_support",
110 Self::MinorityAlignment => "raw_reads_rejected_for_minority_alignment",
111 Self::InsufficientStrandSupport => "raw_reads_rejected_for_insufficient_strand_support",
112 Self::LowBaseQuality => "raw_reads_rejected_for_low_base_quality",
113 Self::ExcessiveNBases => "raw_reads_rejected_for_excessive_n_bases",
114 Self::NoValidAlignment => "raw_reads_rejected_for_no_valid_alignment",
115 Self::LowMappingQuality => "raw_reads_rejected_for_low_mapping_quality",
116 Self::NBasesInUmi => "raw_reads_rejected_for_n_bases_in_umi",
117 Self::MissingUmi => "raw_reads_rejected_for_missing_umi",
118 Self::NotPassingFilter => "raw_reads_rejected_for_not_passing_filter",
119 Self::LowMeanQuality => "raw_reads_rejected_for_low_mean_quality",
120 Self::InsufficientMinDepth => "raw_reads_rejected_for_insufficient_min_depth",
121 Self::ExcessiveErrorRate => "raw_reads_rejected_for_excessive_error_rate",
122 Self::UmiTooShort => "raw_reads_rejected_for_umi_too_short",
123 Self::SameStrandOnly => "raw_reads_rejected_for_single_strand_only",
124 Self::NonPairedReads => "raw_reads_rejected_for_non_paired_reads",
125 Self::DuplicateUmi => "raw_reads_rejected_for_potential_umi_collision",
126 Self::OrphanConsensus => "raw_reads_rejected_for_orphan_consensus",
127 Self::ZeroBasesPostTrimming => "raw_reads_rejected_for_zero_bases_post_trimming",
128 Self::NotPrimaryFrPair => "raw_reads_rejected_for_not_primary_fr_pair",
129 Self::R1R2OverlapTooShort => "raw_reads_rejected_for_r1_r2_overlap_too_short",
130 Self::IndelErrorBetweenStrands => "raw_reads_rejected_for_indel_error_between_strands",
131 Self::HighDuplexDisagreement => "raw_reads_rejected_for_high_duplex_disagreement",
132 Self::ClipOverlapFailed => "raw_reads_rejected_for_clip_overlap_failed",
133 }
134 }
135
136 #[must_use]
138 pub fn kv_description(&self) -> &'static str {
139 match self {
140 Self::InsufficientSupport => "Insufficient reads to generate a consensus",
141 Self::MinorityAlignment => "Reads has a different, and minority, set of indels",
142 Self::InsufficientStrandSupport => "Insufficient strand support for consensus",
143 Self::LowBaseQuality => "Low base quality",
144 Self::ExcessiveNBases => "Excessive N bases in read",
145 Self::NoValidAlignment => "No valid alignment found",
146 Self::LowMappingQuality => "Low mapping quality",
147 Self::NBasesInUmi => "N bases in UMI sequence",
148 Self::MissingUmi => "Read lacks required UMI tag",
149 Self::NotPassingFilter => "Read did not pass vendor filter",
150 Self::LowMeanQuality => "Low mean base quality",
151 Self::InsufficientMinDepth => "Insufficient minimum read depth",
152 Self::ExcessiveErrorRate => "Excessive error rate",
153 Self::UmiTooShort => "UMI sequence too short",
154 Self::SameStrandOnly => "Only Generating One Strand of Duplex Consensus",
156 Self::NonPairedReads => "Unpaired/fragment reads not supported by Duplex caller",
157 Self::DuplicateUmi => "Potential collision between independent duplex molecules",
158 Self::OrphanConsensus => "Only one of R1 or R2 consensus generated",
159 Self::ZeroBasesPostTrimming => "Read or mate had zero bases post trimming",
160 Self::NotPrimaryFrPair => "Template did not have a single primary FR pair of reads",
161 Self::R1R2OverlapTooShort => "Overlap between R1s and R2s too short for CODEC calling",
162 Self::IndelErrorBetweenStrands => "Indel error between top/bottom strands",
163 Self::HighDuplexDisagreement => "Too many errors between top/bottoms strands",
164 Self::ClipOverlapFailed => "See https://github.com/fulcrumgenomics/fgbio/issues/1090",
165 }
166 }
167}
168
169impl fmt::Display for RejectionReason {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 write!(f, "{}", self.description())
172 }
173}
174
175#[must_use]
186pub fn format_count(n: u64) -> String {
187 let s = n.to_string();
188 let bytes = s.as_bytes();
189 let len = bytes.len();
190 let num_commas = if len > 3 { (len - 1) / 3 } else { 0 };
191 let mut result = String::with_capacity(len + num_commas);
192 for (i, &byte) in bytes.iter().enumerate() {
193 if i > 0 && (len - i).is_multiple_of(3) {
194 result.push(',');
195 }
196 result.push(byte as char);
197 }
198 result
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_rejection_reason_description() {
207 assert!(RejectionReason::LowBaseQuality.description().contains("quality"));
208 assert!(RejectionReason::InsufficientSupport.description().contains("Insufficient"));
209 assert_eq!(
211 RejectionReason::MinorityAlignment.to_string(),
212 "Reads has a different, and minority, set of indels"
213 );
214 }
215
216 use strum::IntoEnumIterator;
217
218 #[rstest::rstest]
223 #[case::r1_r2_overlap_too_short(
224 RejectionReason::R1R2OverlapTooShort,
225 "Overlap between R1s and R2s too short for CODEC calling",
226 "raw_reads_rejected_for_r1_r2_overlap_too_short"
227 )]
228 #[case::indel_error_between_strands(
229 RejectionReason::IndelErrorBetweenStrands,
230 "Indel error between top/bottom strands",
231 "raw_reads_rejected_for_indel_error_between_strands"
232 )]
233 #[case::high_duplex_disagreement(
234 RejectionReason::HighDuplexDisagreement,
235 "Too many errors between top/bottoms strands",
236 "raw_reads_rejected_for_high_duplex_disagreement"
237 )]
238 #[case::clip_overlap_failed(
239 RejectionReason::ClipOverlapFailed,
240 "See https://github.com/fulcrumgenomics/fgbio/issues/1090",
241 "raw_reads_rejected_for_clip_overlap_failed"
242 )]
243 fn test_codec_reason_strings_match_fgbio(
244 #[case] reason: RejectionReason,
245 #[case] expected_description: &str,
246 #[case] expected_tsv_key: &str,
247 ) {
248 assert_eq!(reason.description(), expected_description);
250 assert_eq!(reason.kv_description(), expected_description);
251 assert_eq!(reason.tsv_key(), expected_tsv_key);
252 }
253
254 #[test]
255 fn test_tsv_key_prefix() {
256 for reason in RejectionReason::iter() {
259 assert!(
260 reason.tsv_key().starts_with("raw_reads_rejected_for_"),
261 "tsv_key for {:?} does not have expected prefix: {}",
262 reason,
263 reason.tsv_key()
264 );
265 }
266 }
267
268 #[test]
269 fn test_duplex_row_keys_match_fgbio() {
270 assert_eq!(
272 RejectionReason::NonPairedReads.tsv_key(),
273 "raw_reads_rejected_for_non_paired_reads"
274 );
275 assert_eq!(
276 RejectionReason::SameStrandOnly.tsv_key(),
277 "raw_reads_rejected_for_single_strand_only"
278 );
279 assert_eq!(
280 RejectionReason::DuplicateUmi.tsv_key(),
281 "raw_reads_rejected_for_potential_umi_collision"
282 );
283 }
284
285 #[test]
286 fn test_kv_description_non_empty() {
287 for reason in RejectionReason::iter() {
288 assert!(!reason.kv_description().is_empty(), "kv_description for {reason:?} is empty");
289 }
290 }
291
292 #[test]
296 fn test_kv_description_matches_fgbio_verbatim() {
297 assert_eq!(
298 RejectionReason::MinorityAlignment.tsv_key(),
299 "raw_reads_rejected_for_minority_alignment"
300 );
301 assert_eq!(
302 RejectionReason::MinorityAlignment.kv_description(),
303 "Reads has a different, and minority, set of indels"
304 );
305
306 assert_eq!(
307 RejectionReason::SameStrandOnly.tsv_key(),
308 "raw_reads_rejected_for_single_strand_only"
309 );
310 assert_eq!(
311 RejectionReason::SameStrandOnly.kv_description(),
312 "Only Generating One Strand of Duplex Consensus"
313 );
314 }
315
316 #[test]
317 fn test_format_count() {
318 assert_eq!(format_count(0), "0");
319 assert_eq!(format_count(123), "123");
320 assert_eq!(format_count(1234), "1,234");
321 assert_eq!(format_count(1_234_567), "1,234,567");
322 assert_eq!(format_count(1_000_000_000), "1,000,000,000");
323 }
324}