Skip to main content

ciri_toolkit/
summary.rs

1//! Summary module aligned to Java `Summary.java` logic.
2//!
3//! This pass merges nearby circRNA sites, applies Java-compatible stringency
4//! filters, and writes the final circRNA report.
5
6use std::cmp::Ordering;
7use std::collections::{HashMap, HashSet};
8use std::fs::File;
9use std::io::{BufRead, BufReader, BufWriter, Write};
10
11use anyhow::Result;
12
13use crate::annotation::Annotation;
14use crate::is_bsj_hg2::SmithWaterman;
15use crate::utils::{bsj_is_summary_priority, bsj_payload_start};
16
17/// Final clustering and reporting stage.
18///
19/// `Summary` deliberately keeps Java-shaped merge and filtering rules, because
20/// even small changes here alter the final circRNA set despite identical Scan1
21/// and Scan2 intermediates.
22pub struct Summary {
23    pub stringency: i32,
24    /// Number of circRNA rows written in the most recent run.
25    pub circ_count: usize,
26    /// Number of unique BSJ-supporting read IDs retained in the most recent final report.
27    pub final_bsj_reads: usize,
28}
29
30/// One sortable final-result row for a chromosome.
31///
32/// Results are grouped by chromosome and then sorted by start coordinate to
33/// preserve the stable output order expected by existing comparisons.
34struct CircSortItem {
35    start_site: i32,
36    line: String,
37}
38
39impl Summary {
40    /// Compares chromosome names using a "main chromosomes first" order.
41    ///
42    /// The old `BTreeMap` output order was pure lexicographic, which placed
43    /// scaffolds such as `GL*` and `KI*` before or between the expected main
44    /// chromosome blocks on hg38 outputs. For downstream inspection we want all
45    /// `chr*` contigs first, with canonical chromosomes ordered naturally
46    /// (`chr1..chr22, chrX, chrY, chrM/chrMT`), then the remaining `chr*`
47    /// contigs, and only after that non-`chr` scaffolds.
48    fn compare_chr_names(a: &str, b: &str) -> Ordering {
49        fn chr_rank(chr: &str) -> (u8, u8, i32, &str) {
50            if let Some(rest) = chr.strip_prefix("chr") {
51                if let Ok(n) = rest.parse::<i32>() {
52                    return (0, 0, n, "");
53                }
54                return match rest {
55                    "X" => (0, 1, 23, ""),
56                    "Y" => (0, 1, 24, ""),
57                    "M" | "MT" => (0, 1, 25, ""),
58                    _ => (0, 2, 0, rest),
59                };
60            }
61            (1, 0, 0, chr)
62        }
63
64        let ka = chr_rank(a);
65        let kb = chr_rank(b);
66        ka.0.cmp(&kb.0)
67            .then_with(|| ka.1.cmp(&kb.1))
68            .then_with(|| ka.2.cmp(&kb.2))
69            .then_with(|| ka.3.cmp(kb.3))
70    }
71
72    /// Mirrors Java Summary's fallback annotation pass for non-exact exon
73    /// boundary matches.
74    ///
75    /// Java first checks whether both circRNA ends hit the same exon boundary
76    /// pair exactly. If not, it falls back to scanning gene spans on the same
77    /// chromosome and labels the circRNA as `exon`, `intron`, or
78    /// `intergenic_region` based on whether both ends fall inside exon intervals.
79    fn annotate_circ(annotation: &Annotation, chr: &str, start: i32, end: i32) -> (String, String) {
80        if let (Some(v1), Some(v2)) = (
81            annotation
82                .chr_exon_start_map
83                .get(&format!("{}\t{}", chr, start)),
84            annotation
85                .chr_exon_end_map
86                .get(&format!("{}\t{}", chr, end)),
87        ) {
88            if let (Some((g1, _)), Some((g2, _))) = (v1.split_once('\t'), v2.split_once('\t')) {
89                if g1 == g2 {
90                    return ("exon".to_string(), g1.to_string());
91                }
92            }
93        }
94
95        if let Some(genes) = annotation.chr_gene_map.get(chr) {
96            for gene in genes {
97                if start < gene.start {
98                    break;
99                }
100                if end > gene.end {
101                    continue;
102                }
103                if let Some(exons) = annotation.gene_exon_map.get(&gene.gene_id) {
104                    let start_in_exon = exons
105                        .iter()
106                        .any(|&(exon_start, exon_end)| exon_start <= start && exon_end >= start);
107                    let end_in_exon = exons
108                        .iter()
109                        .any(|&(exon_start, exon_end)| exon_start <= end && exon_end >= end);
110                    if start_in_exon && end_in_exon {
111                        return ("exon".to_string(), gene.gene_id.clone());
112                    }
113                    return ("intron".to_string(), gene.gene_id.clone());
114                }
115            }
116        }
117
118        ("intergenic_region".to_string(), "NA".to_string())
119    }
120
121    #[inline]
122    /// Mirrors Java `String.hashCode()` for deterministic bucket ordering parity.
123    fn java_string_hash(s: &str) -> i32 {
124        let mut h: i32 = 0;
125        for ch in s.chars() {
126            h = h.wrapping_mul(31).wrapping_add(ch as i32);
127        }
128        h
129    }
130
131    #[inline]
132    /// Mirrors Java HashMap hash spreading (`h ^ (h >>> 16)`).
133    fn java_hash_spread(h: i32) -> u32 {
134        let x = h as u32;
135        x ^ (x >> 16)
136    }
137
138    #[inline]
139    /// Mirrors Java HashSet capacity growth (initial 16, load factor 0.75).
140    fn java_hashset_capacity(n: usize) -> usize {
141        let mut cap = 16usize;
142        while n > ((cap as f64) * 0.75f64) as usize {
143            cap = cap.saturating_mul(2);
144        }
145        cap.max(1)
146    }
147
148    /// Creates the Summary stage with a Java-compatible stringency level.
149    pub fn new(stringency: i32) -> Self {
150        Self {
151            stringency,
152            circ_count: 0,
153            final_bsj_reads: 0,
154        }
155    }
156
157    /// Extracts the total genomic span covered between the outermost `M`
158    /// operations of a split CIGAR fragment.
159    fn cigar_len_between_ms(cigar: &str) -> i32 {
160        let ops: Vec<char> = cigar.chars().filter(|c| c.is_ascii_alphabetic()).collect();
161        let nums: Vec<i32> = {
162            let mut out = Vec::new();
163            let mut num = 0i32;
164            let mut seen_digit = false;
165            for ch in cigar.chars() {
166                if ch.is_ascii_digit() {
167                    num = num * 10 + (ch as i32 - '0' as i32);
168                    seen_digit = true;
169                } else if ch.is_ascii_alphabetic() {
170                    if seen_digit {
171                        out.push(num);
172                    }
173                    num = 0;
174                    seen_digit = false;
175                }
176            }
177            out
178        };
179        if ops.is_empty() || nums.is_empty() || ops.len() != nums.len() {
180            return 0;
181        }
182        let first_m = ops.iter().position(|&c| c == 'M');
183        let last_m = ops.iter().rposition(|&c| c == 'M');
184        if first_m.is_none() || last_m.is_none() {
185            return 0;
186        }
187        let first = first_m.unwrap();
188        let last = last_m.unwrap();
189        let mut sum = 0i32;
190        for i in first..=last {
191            if ops[i] == 'M' || ops[i] == 'D' {
192                sum += nums[i];
193            }
194        }
195        sum
196    }
197
198    /// Updates the `[SM, MS, SMS]`-style CIGAR pattern counters used in the final
199    /// report.
200    fn update_cigar_counts(cigar: &str, counts: &mut [i32; 3]) {
201        let t: String = cigar.chars().filter(|x| x.is_ascii_alphabetic()).collect();
202        if t == "M" {
203            return;
204        } else if t.find('M') > Some(0) && t.rfind('M') < Some(t.len() - 1) {
205            counts[2] += 1;
206        } else if t.find('M') == Some(0) {
207            counts[1] += 1;
208        } else {
209            counts[0] += 1;
210        }
211    }
212
213    /// Merges circRNA groups that share the same start site when sequence support
214    /// indicates they are equivalent.
215    ///
216    /// The Java-style HashSet bucket reconstruction is intentional: the merge
217    /// winner depends on traversal order, so parity requires deterministic
218    /// emulation of Java iteration.
219    fn merge_same_start(
220        circ_map: &mut HashMap<String, HashSet<String>>,
221        chr_tcga_map: &HashMap<String, String>,
222        circ_start_insertion: &HashMap<String, Vec<String>>,
223    ) {
224        let mut aligner = SmithWaterman::new(1, -1, -3);
225        for (chr_start, insertion_list) in circ_start_insertion {
226            if insertion_list.len() <= 1 {
227                continue;
228            }
229            // Java parity: circStartMap value is HashSet<String>. Emulate HashSet iteration
230            // order using Java hash bucket order and insertion order within bucket.
231            let cap = Self::java_hashset_capacity(insertion_list.len());
232            let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); cap];
233            for (idx, s) in insertion_list.iter().enumerate() {
234                let spread = Self::java_hash_spread(Self::java_string_hash(s));
235                let bucket_idx = (spread as usize) & (cap - 1);
236                buckets[bucket_idx].push(idx);
237            }
238            let mut circ_names: Vec<String> = Vec::with_capacity(insertion_list.len());
239            for bucket in buckets {
240                for idx in bucket {
241                    let k = &insertion_list[idx];
242                    if circ_map.contains_key(k) {
243                        circ_names.push(k.clone());
244                    }
245                }
246            }
247            if circ_names.len() <= 1 {
248                continue;
249            }
250            let chr = chr_start.split('\t').next().unwrap_or_default();
251            let chr_seq = match chr_tcga_map.get(chr) {
252                Some(v) => v,
253                None => continue,
254            };
255            let mut score = vec![0i32; circ_names.len()];
256            let mut end_site = vec![0i32; circ_names.len()];
257            let mut cigars = vec![0i32; circ_names.len()];
258
259            for (idx, name) in circ_names.iter().enumerate() {
260                if let Some(lines) = circ_map.get(name) {
261                    let mut len = 0i32;
262                    for line in lines {
263                        let p: Vec<&str> = line.split('\t').collect();
264                        if p.len() < 10 || !p[1].contains(';') {
265                            continue;
266                        }
267                        end_site[idx] = p[5].parse().unwrap_or(0);
268                        if p[2] == "1" && p[9] == "1" {
269                            score[idx] += 1;
270                        }
271                        let c_arr: Vec<&str> = p[1].split(';').collect();
272                        if c_arr.len() >= 2 {
273                            let l = Self::cigar_len_between_ms(c_arr[1]);
274                            if l > len {
275                                len = l;
276                            }
277                        }
278                    }
279                    cigars[idx] = len;
280                }
281            }
282
283            let up: Vec<usize> = score
284                .iter()
285                .enumerate()
286                .filter_map(|(i, &v)| if v > 0 { Some(i) } else { None })
287                .collect();
288            let mut zero: Vec<usize> = score
289                .iter()
290                .enumerate()
291                .filter_map(|(i, &v)| if v == 0 { Some(i) } else { None })
292                .collect();
293
294            for up_idx in up {
295                let mut remove_list = Vec::new();
296                for &z_idx in &zero {
297                    let len2 = cigars[z_idx];
298                    if len2 <= 0 {
299                        continue;
300                    }
301                    let e1 = end_site[up_idx];
302                    let e2 = end_site[z_idx];
303                    if e1 <= len2 || e2 <= len2 {
304                        continue;
305                    }
306                    let s1 = (e1 - len2) as usize;
307                    let s2 = (e2 - len2) as usize;
308                    let e1u = e1 as usize;
309                    let e2u = e2 as usize;
310                    if e1u > chr_seq.len() || e2u > chr_seq.len() || s1 >= e1u || s2 >= e2u {
311                        continue;
312                    }
313                    let seq1 = &chr_seq[s1..e1u];
314                    let seq2 = &chr_seq[s2..e2u];
315                    aligner.set_seq(seq1, seq2);
316                    aligner.align();
317                    let pass_score =
318                        aligner.score >= seq1.len() as i32 - 2 - ((seq1.len() as i32 - 1) / 10) * 2;
319                    let pass_len = aligner.aligned_len > seq1.len() as i32 - 2;
320                    if pass_score && pass_len {
321                        let up_name = &circ_names[up_idx];
322                        let z_name = &circ_names[z_idx];
323                        if let (Some(set_up), Some(set_z)) = (
324                            circ_map.get(up_name).cloned(),
325                            circ_map.get(z_name).cloned(),
326                        ) {
327                            let mut merged = set_up;
328                            merged.extend(set_z);
329                            circ_map.insert(up_name.clone(), merged);
330                            circ_map.remove(z_name);
331                            remove_list.push(z_idx);
332                        }
333                    }
334                }
335                zero.retain(|v| !remove_list.contains(v));
336            }
337        }
338    }
339
340    /// Merges circRNA groups that share the same end site under the same sequence
341    /// similarity rule used for `merge_same_start`.
342    fn merge_same_end(
343        circ_map: &mut HashMap<String, HashSet<String>>,
344        chr_tcga_map: &HashMap<String, String>,
345        circ_insertion: &[String],
346        circ_map_capacity: usize,
347    ) {
348        let mut circ_end_seen: HashMap<String, HashSet<String>> = HashMap::new();
349        let mut circ_end_insertion: HashMap<String, Vec<String>> = HashMap::new();
350        let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); circ_map_capacity.max(1)];
351        for (idx, key) in circ_insertion.iter().enumerate() {
352            let spread = Self::java_hash_spread(Self::java_string_hash(key));
353            let bucket_idx = (spread as usize) & (circ_map_capacity.max(1) - 1);
354            buckets[bucket_idx].push(idx);
355        }
356        for bucket in buckets {
357            for idx in bucket {
358                let k = &circ_insertion[idx];
359                if !circ_map.contains_key(k) {
360                    continue;
361                }
362                let p: Vec<&str> = k.split('\t').collect();
363                if p.len() != 3 {
364                    continue;
365                }
366                let key = format!("{}\t{}", p[0], p[2]);
367                let seen = circ_end_seen.entry(key.clone()).or_default();
368                if seen.insert(k.clone()) {
369                    circ_end_insertion.entry(key).or_default().push(k.clone());
370                }
371            }
372        }
373
374        let mut aligner = SmithWaterman::new(1, -1, -3);
375        for (chr_end, insertion_list) in circ_end_insertion {
376            if insertion_list.len() <= 1 {
377                continue;
378            }
379            // Java parity: circEndMap stores HashSet<String>. Reconstruct the
380            // same bucket-order iteration that Java's HashSet would expose for
381            // this same-end group, because the first positive-score circ that
382            // encounters a zero-score circ wins the merge.
383            let cap = Self::java_hashset_capacity(insertion_list.len());
384            let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); cap];
385            for (idx, s) in insertion_list.iter().enumerate() {
386                let spread = Self::java_hash_spread(Self::java_string_hash(s));
387                let bucket_idx = (spread as usize) & (cap - 1);
388                buckets[bucket_idx].push(idx);
389            }
390            let circ_names: Vec<String> = buckets
391                .into_iter()
392                .flat_map(|bucket| bucket.into_iter())
393                .map(|idx| insertion_list[idx].clone())
394                .filter(|k| circ_map.contains_key(k))
395                .collect();
396            if circ_names.len() <= 1 {
397                continue;
398            }
399            let chr = chr_end.split('\t').next().unwrap_or_default();
400            let chr_seq = match chr_tcga_map.get(chr) {
401                Some(v) => v,
402                None => continue,
403            };
404            let mut score = vec![0i32; circ_names.len()];
405            let mut start_site = vec![0i32; circ_names.len()];
406            let mut cigars = vec![0i32; circ_names.len()];
407
408            for (idx, name) in circ_names.iter().enumerate() {
409                if let Some(lines) = circ_map.get(name) {
410                    let mut len = 0i32;
411                    for line in lines {
412                        let p: Vec<&str> = line.split('\t').collect();
413                        if p.len() < 10 || !p[1].contains(';') {
414                            continue;
415                        }
416                        start_site[idx] = p[4].parse().unwrap_or(0);
417                        if p[2] == "1" && p[9] == "1" {
418                            score[idx] += 1;
419                        }
420                        let c_arr: Vec<&str> = p[1].split(';').collect();
421                        if !c_arr.is_empty() {
422                            let l = Self::cigar_len_between_ms(c_arr[0]);
423                            if l > len {
424                                len = l;
425                            }
426                        }
427                    }
428                    cigars[idx] = len;
429                }
430            }
431
432            let up: Vec<usize> = score
433                .iter()
434                .enumerate()
435                .filter_map(|(i, &v)| if v > 0 { Some(i) } else { None })
436                .collect();
437            let mut zero: Vec<usize> = score
438                .iter()
439                .enumerate()
440                .filter_map(|(i, &v)| if v == 0 { Some(i) } else { None })
441                .collect();
442
443            for up_idx in up {
444                let mut remove_list = Vec::new();
445                for &z_idx in &zero {
446                    let len2 = cigars[z_idx];
447                    if len2 <= 0 {
448                        continue;
449                    }
450                    let s1 = start_site[up_idx];
451                    let s2 = start_site[z_idx];
452                    if s1 < 0 || s2 < 0 {
453                        continue;
454                    }
455                    let e1 = s1 + len2;
456                    let e2 = s2 + len2;
457                    if e1 as usize > chr_seq.len() || e2 as usize > chr_seq.len() {
458                        continue;
459                    }
460                    let seq1 = &chr_seq[s1 as usize..e1 as usize];
461                    let seq2 = &chr_seq[s2 as usize..e2 as usize];
462                    aligner.set_seq(seq1, seq2);
463                    aligner.align();
464                    let pass_score =
465                        aligner.score >= seq1.len() as i32 - 2 - ((seq1.len() as i32 - 1) / 10) * 2;
466                    let pass_len = aligner.aligned_len > seq1.len() as i32 - 2;
467                    if pass_score && pass_len {
468                        let up_name = &circ_names[up_idx];
469                        let z_name = &circ_names[z_idx];
470                        if let (Some(set_up), Some(set_z)) = (
471                            circ_map.get(up_name).cloned(),
472                            circ_map.get(z_name).cloned(),
473                        ) {
474                            let mut merged = set_up;
475                            merged.extend(set_z);
476                            circ_map.insert(up_name.clone(), merged);
477                            circ_map.remove(z_name);
478                            remove_list.push(z_idx);
479                        }
480                    }
481                }
482                zero.retain(|v| !remove_list.contains(v));
483            }
484        }
485    }
486
487    /// Runs the final Summary stage and writes the requested report file.
488    ///
489    /// Inputs are assumed to already be parity-aligned with Java at the BSJ1/FSJ
490    /// level; this stage only performs Java-compatible merging, stringency
491    /// filtering, annotation labeling, and output ordering.
492    pub fn run(
493        &mut self,
494        bsj1_file: &str,
495        result_path: &str,
496        fsj_map: &HashMap<String, i32>,
497        chr_tcga_map: &HashMap<String, String>,
498        annotation: &Annotation,
499    ) -> Result<()> {
500        self.run_from_bsj_files(&[bsj1_file], result_path, fsj_map, chr_tcga_map, annotation)
501    }
502
503    /// Runs Summary over one or more BSJ protocol files in the supplied order.
504    ///
505    /// Mate-level BSJ rows carry `mate_label` and `priority`; only `priority=1`
506    /// is converted back to the legacy payload consumed by the Java-compatible
507    /// merge and stringency logic.
508    pub fn run_from_bsj_files(
509        &mut self,
510        bsj_files: &[&str],
511        result_path: &str,
512        fsj_map: &HashMap<String, i32>,
513        chr_tcga_map: &HashMap<String, String>,
514        annotation: &Annotation,
515    ) -> Result<()> {
516        self.circ_count = 0;
517        self.final_bsj_reads = 0;
518        let mut circ_map: HashMap<String, HashSet<String>> = HashMap::new();
519        let mut circ_start_seen: HashMap<String, HashSet<String>> = HashMap::new();
520        let mut circ_start_insertion: HashMap<String, Vec<String>> = HashMap::new();
521        let mut circ_seen: HashSet<String> = HashSet::new();
522        let mut circ_insertion: Vec<String> = Vec::new();
523        for bsj_file in bsj_files {
524            let file = File::open(bsj_file)?;
525            let reader = BufReader::new(file);
526            for line_res in reader.lines() {
527                let line = line_res?;
528                let p: Vec<&str> = line.split('\t').collect();
529                if p.len() < 8 || !bsj_is_summary_priority(&p) {
530                    continue;
531                }
532                let payload_start = bsj_payload_start(&p);
533                if p.len() <= payload_start + 8 {
534                    continue;
535                }
536                let legacy_line = if payload_start == 1 {
537                    line.clone()
538                } else {
539                    format!("{}\t{}", p[0], p[payload_start..].join("\t"))
540                };
541                let legacy: Vec<&str> = legacy_line.split('\t').collect();
542                let key = format!("{}\t{}\t{}", legacy[3], legacy[4], legacy[5]);
543                circ_map
544                    .entry(key.clone())
545                    .or_default()
546                    .insert(legacy_line.clone());
547                if circ_seen.insert(key.clone()) {
548                    circ_insertion.push(key.clone());
549                }
550                let start_key = format!("{}\t{}", legacy[3], legacy[4]);
551                let seen = circ_start_seen.entry(start_key.clone()).or_default();
552                if seen.insert(key.clone()) {
553                    circ_start_insertion.entry(start_key).or_default().push(key);
554                }
555            }
556        }
557
558        Self::merge_same_start(&mut circ_map, chr_tcga_map, &circ_start_insertion);
559        let circ_map_capacity = Self::java_hashset_capacity(circ_insertion.len());
560        Self::merge_same_end(
561            &mut circ_map,
562            chr_tcga_map,
563            &circ_insertion,
564            circ_map_capacity,
565        );
566
567        let mut final_results: HashMap<String, Vec<CircSortItem>> = HashMap::new();
568        let mut final_read_ids: HashSet<String> = HashSet::new();
569        for (chr_start_end, lines) in &circ_map {
570            let p_key: Vec<&str> = chr_start_end.split('\t').collect();
571            if p_key.len() != 3 {
572                continue;
573            }
574
575            let mut circ_id_set3: HashSet<String> = HashSet::new();
576            let mut cigar_set3: HashSet<String> = HashSet::new();
577            let mut false_cigar_set3: HashSet<String> = HashSet::new();
578            let mut circ_id_set: HashSet<String> = HashSet::new();
579            let mut cigar_set: HashSet<String> = HashSet::new();
580            let mut false_cigar_set: HashSet<String> = HashSet::new();
581            let mut non_reads = 0i32;
582            let mut fp_reads = 0i32;
583            let mut tag = 0i32;
584            let mut strand = " ".to_string();
585
586            for line in lines {
587                let p: Vec<&str> = line.split('\t').collect();
588                if p.len() < 10 {
589                    continue;
590                }
591                strand = p[6].to_string();
592                if p[2] == "1" {
593                    if p[9] == "3" {
594                        cigar_set3.insert(p[1].to_string());
595                        circ_id_set3.insert(p[0].to_string());
596                    } else {
597                        for c in p[1].split(';') {
598                            cigar_set.insert(c.to_string());
599                        }
600                        if p[9] == "1" {
601                            tag += 1;
602                        }
603                        circ_id_set.insert(p[0].to_string());
604                    }
605                } else if p[2] == "-1" {
606                    if p[9] == "3" {
607                        false_cigar_set3.insert(p[1].to_string());
608                    } else {
609                        for c in p[1].split(';') {
610                            false_cigar_set.insert(c.to_string());
611                        }
612                        non_reads += 1;
613                    }
614                } else if p[2] == "-2" {
615                    if p[9] == "3" {
616                        false_cigar_set3.insert(p[1].to_string());
617                    } else {
618                        for c in p[1].split(';') {
619                            false_cigar_set.insert(c.to_string());
620                        }
621                        fp_reads += 1;
622                    }
623                }
624            }
625
626            cigar_set3.extend(cigar_set.iter().cloned());
627            circ_id_set3.extend(circ_id_set.iter().cloned());
628            false_cigar_set3.extend(false_cigar_set.iter().cloned());
629
630            let mut cigar_count3 = [0, 0, 0];
631            for c in &cigar_set3 {
632                Self::update_cigar_counts(c, &mut cigar_count3);
633            }
634
635            let tp_reads = circ_id_set.len() as i32;
636            let tp_reads3 = circ_id_set3.len() as i32;
637
638            let passed = if self.stringency == 2 {
639                ((tp_reads > 19 * fp_reads || fp_reads <= 1)
640                    && tp_reads > non_reads + fp_reads
641                    && cigar_set.len() >= 3
642                    && tp_reads >= 2)
643                    || (tag > 0
644                        && false_cigar_set3.is_empty()
645                        && cigar_set3.len() >= 3
646                        && tp_reads3 >= 2)
647            } else if self.stringency == 1 {
648                ((tp_reads > 19 * fp_reads || false_cigar_set.len() <= 2)
649                    && tp_reads > non_reads + fp_reads
650                    && tp_reads >= 2)
651                    || (tag > 0 && false_cigar_set3.is_empty() && tp_reads3 >= 2)
652            } else {
653                ((tp_reads > 19 * fp_reads || false_cigar_set.len() <= 2)
654                    && tp_reads > non_reads + fp_reads)
655                    || (tag > 0 && false_cigar_set3.is_empty() && tp_reads3 >= 2)
656            };
657
658            if !passed {
659                continue;
660            }
661
662            let fsj = *fsj_map.get(chr_start_end).unwrap_or(&0);
663            let ratio = if tp_reads3 * 2 + fsj > 0 {
664                (tp_reads3 * 2) as f64 / (tp_reads3 * 2 + fsj) as f64
665            } else {
666                0.0
667            };
668            let start = p_key[1].parse::<i32>().unwrap_or(0);
669            let (circ_type, gene_id) =
670                Self::annotate_circ(annotation, p_key[0], start, p_key[2].parse().unwrap_or(0));
671            let mut ids: Vec<String> = circ_id_set3.into_iter().collect();
672            ids.sort();
673            for id in &ids {
674                final_read_ids.insert(id.clone());
675            }
676            let line = format!(
677                "{}:{}|{}\t{}\t{}\t{}\t{}\t{}_{}_{}\t{}\t{:.2}\t{}\t{}\t{}\t{}\t{}",
678                p_key[0],
679                p_key[1],
680                p_key[2],
681                p_key[0],
682                p_key[1],
683                p_key[2],
684                tp_reads3,
685                cigar_count3[0],
686                cigar_count3[1],
687                cigar_count3[2],
688                fsj,
689                ratio,
690                circ_type,
691                gene_id,
692                strand,
693                ids.join(","),
694                tag
695            );
696            final_results
697                .entry(p_key[0].to_string())
698                .or_default()
699                .push(CircSortItem {
700                    start_site: start,
701                    line,
702                });
703        }
704
705        let out_file = File::create(result_path)?;
706        let mut writer = BufWriter::with_capacity(1024 * 1024, out_file);
707        writeln!(writer, "circRNA_ID\tchr\tcircRNA_start\tcircRNA_end\t#junction_reads\tSM_MS_SMS\t#non_junction_reads\tjunction_reads_ratio\tcircRNA_type\tgene_id\tstrand\tjunction_reads_ID\tScore")?;
708        let mut chrs: Vec<String> = final_results.keys().cloned().collect();
709        chrs.sort_by(|a, b| Self::compare_chr_names(a, b));
710        for chr in chrs {
711            let items = final_results.get_mut(&chr).expect("chromosome key exists");
712            items.sort_by_key(|x| x.start_site);
713            for item in items {
714                writeln!(writer, "{}", item.line)?;
715                self.circ_count += 1;
716            }
717        }
718        writer.flush()?;
719        self.final_bsj_reads = final_read_ids.len();
720        Ok(())
721    }
722}
723
724#[cfg(test)]
725mod tests {
726    use super::Summary;
727
728    #[test]
729    fn test_compare_chr_names_main_chromosomes_first() {
730        let mut chrs = vec![
731            "GL000225.1".to_string(),
732            "chr10".to_string(),
733            "chr2".to_string(),
734            "KI270442.1".to_string(),
735            "chrX".to_string(),
736            "chr1".to_string(),
737            "chrM".to_string(),
738            "chrUn_KI270442v1".to_string(),
739        ];
740        chrs.sort_by(|a, b| Summary::compare_chr_names(a, b));
741        assert_eq!(
742            chrs,
743            vec![
744                "chr1",
745                "chr2",
746                "chr10",
747                "chrX",
748                "chrM",
749                "chrUn_KI270442v1",
750                "GL000225.1",
751                "KI270442.1",
752            ]
753        );
754    }
755}