Skip to main content

ciri_toolkit/
utils.rs

1//! Utility module: Common bioinformatics and string processing functions.
2//!
3//! This module provides the `AlignmentRecord` abstraction and helper functions
4//! like reverse complementation and memory unit parsing.
5
6use std::borrow::Cow;
7use std::collections::{HashMap, HashSet};
8use std::fmt::Write as FmtWrite;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub(crate) struct ClipPlacement {
12    pub(crate) pos: i32,
13    pub(crate) clip_offset: usize,
14    pub(crate) len: usize,
15}
16
17/// Smallest compressed BAM shard size worth parallelizing.
18///
19/// BGZF streams are block-compressed, and overly small shards make the
20/// "search forward to the next block header" fallback ambiguous on tiny BAMs.
21/// That can land non-zero shards inside incomplete compressed members and yield
22/// decoder errors such as `failed to fill whole buffer`. Capping the shard count
23/// by a conservative compressed-byte minimum keeps tiny regression BAMs stable
24/// while leaving large production BAMs fully parallel.
25pub const MIN_BAM_SHARD_BYTES: usize = 4 * 1024 * 1024;
26
27/// Unified representation of one alignment record used by Scan1 and Scan2.
28///
29/// `Cow` keeps the type flexible: BAM/SAM parsers can borrow transient slices or
30/// promote data to owned strings when groups must outlive the parser buffer.
31#[derive(Debug, Clone)]
32pub struct AlignmentRecord<'a> {
33    pub flag: i32,
34    pub chrom: Cow<'a, str>,
35    pub pos: i32,
36    pub mapq: i32,
37    pub cigar: Cow<'a, str>,
38    pub seq: Cow<'a, str>,
39    /// Raw BWA `XA:Z` payload retained for post-Summary segment selection.
40    ///
41    /// Core CIRI3 parity logic ignores optional tags, but the segments sidecar
42    /// needs mapper alternatives to avoid choosing a paralogous primary
43    /// alignment when a confirmed circRNA-compatible placement is available.
44    pub xa: Cow<'a, str>,
45}
46
47/// Returns the reverse complement of a DNA sequence.
48///
49/// This helper preserves non-ACGT characters as-is because the reference and read
50/// sequences may contain `N`, and parity code expects those positions to survive.
51pub fn reverse_complement(seq: &str) -> String {
52    seq.chars()
53        .rev()
54        .map(|c| match c {
55            'A' => 'T',
56            'T' => 'A',
57            'C' => 'G',
58            'G' => 'C',
59            'a' => 't',
60            't' => 'a',
61            'c' => 'g',
62            'g' => 'c',
63            _ => c,
64        })
65        .collect()
66}
67
68/// Parses a SAM-like CIGAR into `(length, op)` pairs for sidecar formatting.
69///
70/// This helper intentionally performs only lightweight syntax parsing and keeps
71/// operation semantics with the caller. It is used by Scan1/Scan2 sidecar
72/// writers, where pulling in CIRI-AS chain reconstruction would create an
73/// unnecessary dependency from the parity-sensitive scan stages.
74pub fn parse_cigar_ops_basic(cigar: &str) -> Option<Vec<(i32, char)>> {
75    let mut ops = Vec::new();
76    let mut number = String::new();
77    for ch in cigar.chars() {
78        if ch.is_ascii_digit() {
79            number.push(ch);
80            continue;
81        }
82        let len = number.parse::<i32>().ok()?;
83        number.clear();
84        ops.push((len, ch));
85    }
86    if number.is_empty() {
87        Some(ops)
88    } else {
89        None
90    }
91}
92
93/// Encodes only recoverable soft-clipped bases for segment-evidence sidecars.
94///
95/// Storing full read sequences would inflate the BSJ sidecar substantially. The
96/// final segments stage only needs clipped subsequences, so the compact
97/// `L:<seq>,R:<seq>` payload keeps I/O bounded while retaining the local evidence
98/// validated by Scan1/Scan2.
99pub fn clip_sequence_payload(cigar: &str, seq: &str) -> String {
100    if seq.is_empty() || seq == "*" || !cigar.contains('S') {
101        return "*".to_string();
102    }
103    let Some(ops) = parse_cigar_ops_basic(cigar) else {
104        return "*".to_string();
105    };
106    let mut fields = Vec::new();
107    if let Some((len, 'S')) = ops.first().copied() {
108        if let Some(part) = seq.get(..len as usize) {
109            fields.push(format!("L:{}", part));
110        }
111    }
112    if let Some((len, 'S')) = ops.last().copied() {
113        if let Some(part) = seq.get(seq.len().saturating_sub(len as usize)..) {
114            fields.push(format!("R:{}", part));
115        }
116    }
117    if fields.is_empty() {
118        "*".to_string()
119    } else {
120        fields.join(",")
121    }
122}
123
124/// Builds a minimap2-compatible short-form cs payload for one mapper alignment.
125///
126/// This is used by Scan1/Scan2 sidecars to retain allele bases without spilling
127/// full read sequences. The payload intentionally stays in standard short-form
128/// cs syntax here; CIRI's custom `<...>` back-splice op is added later when the
129/// post-Summary chain materializer joins selected blocks across a BSJ boundary.
130pub fn alignment_short_cs(
131    cigar: &str,
132    seq: &str,
133    chrom: &str,
134    pos: i32,
135    reference: &HashMap<String, String>,
136) -> String {
137    if seq.is_empty() || seq == "*" || pos <= 0 {
138        return "*".to_string();
139    }
140    let Some(chr_seq) = reference.get(chrom) else {
141        return "*".to_string();
142    };
143    let Some(ops) = parse_cigar_ops_basic(cigar) else {
144        return "*".to_string();
145    };
146    let mut out = String::new();
147    let mut matches = 0usize;
148    let mut read_idx = 0usize;
149    let mut ref_pos = pos;
150    let seq = seq.to_ascii_lowercase();
151    for (len, op) in ops {
152        if len < 0 {
153            return "*".to_string();
154        }
155        let len = len as usize;
156        match op {
157            'M' | '=' | 'X' => {
158                let start = (ref_pos - 1) as usize;
159                let end = start + len;
160                let Some(reference_part) = chr_seq.get(start..end) else {
161                    return "*".to_string();
162                };
163                let Some(query_part) = seq.get(read_idx..read_idx + len) else {
164                    return "*".to_string();
165                };
166                for (ref_base, query_base) in reference_part.chars().zip(query_part.chars()) {
167                    let ref_base = ref_base.to_ascii_lowercase();
168                    let query_base = query_base.to_ascii_lowercase();
169                    if ref_base.eq_ignore_ascii_case(&query_base) {
170                        matches += 1;
171                    } else {
172                        flush_short_cs_match(&mut out, &mut matches);
173                        out.push('*');
174                        out.push(ref_base);
175                        out.push(query_base);
176                    }
177                }
178                read_idx += len;
179                ref_pos += len as i32;
180            }
181            'I' => {
182                let Some(query_part) = seq.get(read_idx..read_idx + len) else {
183                    return "*".to_string();
184                };
185                flush_short_cs_match(&mut out, &mut matches);
186                out.push('+');
187                out.push_str(query_part);
188                read_idx += len;
189            }
190            'D' => {
191                let start = (ref_pos - 1) as usize;
192                let end = start + len;
193                let Some(reference_part) = chr_seq.get(start..end) else {
194                    return "*".to_string();
195                };
196                flush_short_cs_match(&mut out, &mut matches);
197                out.push('-');
198                out.push_str(&reference_part.to_ascii_lowercase());
199                ref_pos += len as i32;
200            }
201            'N' => {
202                flush_short_cs_match(&mut out, &mut matches);
203                let donor =
204                    reference_dinucleotide_lower(chr_seq, ref_pos).unwrap_or_else(|| "nn".into());
205                let acceptor = reference_dinucleotide_lower(chr_seq, ref_pos + len as i32 - 2)
206                    .unwrap_or_else(|| "nn".into());
207                let _ = write!(&mut out, "~{}{}{}", donor, len, acceptor);
208                ref_pos += len as i32;
209            }
210            'S' => read_idx += len,
211            'H' | 'P' => {}
212            _ => return "*".to_string(),
213        }
214    }
215    flush_short_cs_match(&mut out, &mut matches);
216    if out.is_empty() {
217        "*".to_string()
218    } else {
219        out
220    }
221}
222
223fn flush_short_cs_match(out: &mut String, matches: &mut usize) {
224    if *matches > 0 {
225        let _ = write!(out, ":{}", *matches);
226        *matches = 0;
227    }
228}
229
230fn reference_dinucleotide_lower(chr_seq: &str, start: i32) -> Option<String> {
231    if start <= 0 {
232        return None;
233    }
234    let start = (start - 1) as usize;
235    let end = start + 2;
236    chr_seq.get(start..end).map(|seq| seq.to_ascii_lowercase())
237}
238
239/// Adds pseudo-alignment rows for local clip evidence accepted with BSJ calls.
240///
241/// This helper is deliberately sidecar-only: Scan1/Scan2 first make their normal
242/// CIRI3-compatible BSJ decision, then use the confirmed circ span to place
243/// clipped subsequences inside that circ interval. Full-clip exact matches are
244/// preferred; if the full clip does not place, the longest prefix/suffix partial
245/// exact match is retained when it still satisfies the minimum length. The
246/// resulting rows feed post-Summary segment assembly but never Summary itself.
247pub fn local_clip_evidence_lines<'a>(
248    read_id: &str,
249    stage: &str,
250    alignments: &[&AlignmentRecord<'a>],
251    bsj_lines: &[String],
252    reference: &HashMap<String, String>,
253    min_clip_len: usize,
254) -> Vec<String> {
255    let mut circ_spans = Vec::new();
256    let mut seen_circ = HashSet::new();
257    for line in bsj_lines {
258        if let Some((chr, start, end)) = parse_bsj_line_span(line) {
259            if seen_circ.insert(format!("{chr}\t{start}\t{end}")) {
260                circ_spans.push((chr, start, end));
261            }
262        }
263    }
264    if circ_spans.is_empty() {
265        return Vec::new();
266    }
267
268    let mut rows = Vec::new();
269    let mut seen_rows = HashSet::new();
270    for (chr, circ_start, circ_end) in circ_spans {
271        let Some(chr_seq) = reference.get(&chr) else {
272            continue;
273        };
274        if circ_start <= 0 || circ_end < circ_start || circ_end as usize > chr_seq.len() {
275            continue;
276        }
277        let circ_seq = chr_seq[(circ_start - 1) as usize..circ_end as usize].to_uppercase();
278        for aln in alignments {
279            if aln.chrom.as_ref() != chr || aln.seq.is_empty() || aln.seq.as_ref() == "*" {
280                continue;
281            }
282            let payload = clip_sequence_payload(aln.cigar.as_ref(), aln.seq.as_ref());
283            if payload == "*" {
284                continue;
285            }
286            for (side, clip_seq) in parse_clip_payload(&payload) {
287                if clip_seq.len() < min_clip_len || clip_seq.contains('N') {
288                    continue;
289                }
290                // SAM/BAM stores the sequence for this alignment record in the
291                // same orientation used by its CIGAR. Reversing clips again for
292                // `0x10` records loses valid circ-side local blocks from reverse
293                // strand BSJ reads.
294                let genomic_query = clip_seq.to_uppercase();
295                if genomic_query.contains('N') {
296                    continue;
297                }
298                let mut candidates = clip_match_placements(
299                    &circ_seq,
300                    &genomic_query,
301                    circ_start,
302                    circ_end,
303                    min_clip_len,
304                );
305                candidates.truncate(4);
306                for placement in candidates {
307                    let read_len = aln.seq.len() as i32;
308                    let Some(cigar) = clip_placement_cigar(
309                        side,
310                        clip_seq.len(),
311                        placement.clip_offset,
312                        placement.len,
313                        read_len,
314                    ) else {
315                        continue;
316                    };
317                    let pos = placement.pos;
318                    let flag = aln.flag | 0x800;
319                    let key = format!("{flag}\t{chr}\t{pos}\t{cigar}");
320                    if !seen_rows.insert(key) {
321                        continue;
322                    }
323                    let mate = if flag & 0x40 != 0 { "R1" } else { "R2" };
324                    let cs = alignment_short_cs(&cigar, aln.seq.as_ref(), &chr, pos, reference);
325                    rows.push(format!(
326                        "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t*\t{}\t*",
327                        read_id,
328                        stage,
329                        mate,
330                        flag,
331                        chr,
332                        pos,
333                        aln.mapq.saturating_sub(1),
334                        cigar,
335                        read_len,
336                        cs
337                    ));
338                }
339            }
340        }
341    }
342    rows
343}
344
345/// Builds a pseudo-CIGAR for one full or partial local clip placement.
346///
347/// `clip_offset` is zero-based inside the clipped subsequence. Left clips occupy
348/// read positions starting at one; right clips occupy the terminal read suffix.
349/// Preserving both surrounding soft clips lets the post-Summary chain builder
350/// know where a partial local block sits within the mate read.
351pub(crate) fn clip_placement_cigar(
352    side: char,
353    clip_len: usize,
354    clip_offset: usize,
355    match_len: usize,
356    read_len: i32,
357) -> Option<String> {
358    if match_len == 0 || clip_offset + match_len > clip_len || read_len <= 0 {
359        return None;
360    }
361    let read_len = read_len as usize;
362    let read_start = match side {
363        'L' => clip_offset + 1,
364        'R' => read_len.checked_sub(clip_len)? + clip_offset + 1,
365        _ => return None,
366    };
367    let read_end = read_start + match_len - 1;
368    if read_end > read_len {
369        return None;
370    }
371    Some(spliced_single_match_cigar(
372        read_start - 1,
373        match_len,
374        read_len - read_end,
375    ))
376}
377
378/// Formats one local pseudo-alignment with optional terminal soft clips.
379fn spliced_single_match_cigar(leading_s: usize, match_len: usize, trailing_s: usize) -> String {
380    let mut cigar = String::new();
381    if leading_s > 0 {
382        cigar.push_str(&format!("{leading_s}S"));
383    }
384    cigar.push_str(&format!("{match_len}M"));
385    if trailing_s > 0 {
386        cigar.push_str(&format!("{trailing_s}S"));
387    }
388    cigar
389}
390
391/// Extracts the circ span from old, display, or priority-annotated BSJ rows.
392fn parse_bsj_line_span(line: &str) -> Option<(String, i32, i32)> {
393    let parts: Vec<&str> = line.split('\t').collect();
394    if parts.is_empty() {
395        return None;
396    }
397    let payload_start = if parts.len() >= 3
398        && is_bsj_mate_label(parts[1])
399        && (parts[2] == "0" || parts[2] == "1")
400    {
401        3
402    } else if parts.len() >= 2 && is_bsj_mate_label(parts[1]) {
403        2
404    } else {
405        1
406    };
407    if parts.len() < payload_start + 5 {
408        return None;
409    }
410    let chr = parts[payload_start + 2].to_string();
411    let start = parts[payload_start + 3].parse::<i32>().ok()?;
412    let end = parts[payload_start + 4].parse::<i32>().ok()?;
413    Some((chr, start, end))
414}
415
416/// Parses compact sidecar clip payload fields.
417pub(crate) fn parse_clip_payload(payload: &str) -> Vec<(char, &str)> {
418    payload
419        .split(',')
420        .filter_map(|field| {
421            let (side, seq) = field.split_once(':')?;
422            let side = side.chars().next()?;
423            matches!(side, 'L' | 'R').then_some((side, seq))
424        })
425        .collect()
426}
427
428/// Finds full or longest prefix/suffix partial clip placements.
429pub(crate) fn clip_match_placements(
430    circ_seq: &str,
431    genomic_query: &str,
432    circ_start: i32,
433    circ_end: i32,
434    min_clip_len: usize,
435) -> Vec<ClipPlacement> {
436    let query_len = genomic_query.len();
437    let full: Vec<ClipPlacement> =
438        exact_clip_match_positions(circ_seq, genomic_query, circ_start, circ_end)
439            .into_iter()
440            .map(|pos| ClipPlacement {
441                pos,
442                clip_offset: 0,
443                len: query_len,
444            })
445            .collect();
446    if !full.is_empty() {
447        return full;
448    }
449    if query_len <= min_clip_len {
450        return Vec::new();
451    }
452
453    for len in (min_clip_len..query_len).rev() {
454        let mut placements = Vec::new();
455        let mut seen = HashSet::new();
456        for clip_offset in [0, query_len - len] {
457            if !seen.insert(clip_offset) {
458                continue;
459            }
460            let sub_query = &genomic_query[clip_offset..clip_offset + len];
461            placements.extend(
462                exact_clip_match_positions(circ_seq, sub_query, circ_start, circ_end)
463                    .into_iter()
464                    .map(|pos| ClipPlacement {
465                        pos,
466                        clip_offset,
467                        len,
468                    }),
469            );
470        }
471        if !placements.is_empty() {
472            placements.sort_by_key(|placement| {
473                let end = placement.pos + placement.len as i32 - 1;
474                (
475                    (placement.pos - circ_start)
476                        .abs()
477                        .min((end - circ_end).abs()),
478                    placement.pos,
479                    placement.clip_offset,
480                )
481            });
482            return placements;
483        }
484    }
485    Vec::new()
486}
487
488/// Finds exact local-clip placements and ranks boundary-near hits first.
489pub(crate) fn exact_clip_match_positions(
490    circ_seq: &str,
491    genomic_query: &str,
492    circ_start: i32,
493    circ_end: i32,
494) -> Vec<i32> {
495    let mut positions = Vec::new();
496    let mut offset = 0usize;
497    while let Some(found) = circ_seq[offset..].find(genomic_query) {
498        let absolute = offset + found;
499        let pos = circ_start + absolute as i32;
500        positions.push(pos);
501        offset = absolute + 1;
502    }
503    let len = genomic_query.len() as i32;
504    positions.sort_by_key(|pos| {
505        let end = *pos + len - 1;
506        ((*pos - circ_start).abs().min((end - circ_end).abs()), *pos)
507    });
508    positions
509}
510
511/// Parses memory strings like `2G`, `512M`, or `1024K` into bytes.
512///
513/// The fallback defaults intentionally match the historical CLI behavior rather
514/// than failing hard on malformed input.
515pub fn parse_mem_str(mem_str: &str) -> u64 {
516    let s = mem_str.to_uppercase();
517    if s.ends_with('G') {
518        s[..s.len() - 1].parse::<u64>().unwrap_or(2) * 1024 * 1024 * 1024
519    } else if s.ends_with('M') {
520        s[..s.len() - 1].parse::<u64>().unwrap_or(2048) * 1024 * 1024
521    } else if s.ends_with('K') {
522        s[..s.len() - 1].parse::<u64>().unwrap_or(2097152) * 1024
523    } else {
524        s.parse::<u64>().unwrap_or(2) * 1024 * 1024 * 1024 // Default 2G
525    }
526}
527
528/// Returns the final Summary output path for one CLI `-o/--out` prefix.
529///
530/// The CLI now treats `-o` strictly as a prefix so all outputs follow one
531/// predictable scheme. Stage-local files such as `.bsj1/.bsj2` are now
532/// implementation details that the CLI removes unless `--debug` is set.
533///
534/// Keeping the naming centralized here avoids another round of drift between
535/// CLI help, logging, temporary-file cleanup, and test fixtures.
536pub fn result_path_for_output(output_arg: &str) -> String {
537    format!("{}.out", output_arg)
538}
539
540/// Returns the sidecar log path for one CLI prefix.
541///
542/// This file stores the high-level stage summaries emitted by the main pipeline
543/// so long runs can be reviewed after the terminal session ends.
544pub fn log_path_for_output(output_arg: &str) -> String {
545    format!("{}.log", output_arg)
546}
547
548/// Returns the targeted read-trace path for one CLI prefix.
549///
550/// `--trace` writes verbose per-read processing traces here so they do not get
551/// mixed into the always-on stage summary log.
552pub fn trace_path_for_output(output_arg: &str) -> String {
553    format!("{}.trace.log", output_arg)
554}
555
556/// Returns the profiling report path for one CLI prefix.
557///
558/// `--perf` is intentionally a boolean switch, so profiling output always lands
559/// in a predictable sibling file without forcing the user to invent another
560/// path on the command line.
561pub fn perf_path_for_output(output_arg: &str) -> String {
562    format!("{}.perf.log", output_arg)
563}
564
565/// Returns the merged Scan1 BSJ output path for one CLI prefix.
566pub fn bsj1_path_for_output(output_arg: &str) -> String {
567    format!("{}.bsj1", output_arg)
568}
569
570/// Returns the Scan1 segments-evidence sidecar path for one CLI prefix.
571///
572/// This file is intentionally separate from `.bsj1`: Summary consumes `.bsj1`
573/// for CIRI3 parity, while the segments sidecar keeps read-level alignment
574/// blocks for post-Summary internal-structure reconstruction.
575pub fn segments1_path_for_output(output_arg: &str) -> String {
576    format!("{}.segments1", output_arg)
577}
578
579/// Returns the final combined BSJ output path for one CLI prefix.
580pub fn bsj_path_for_output(output_arg: &str) -> String {
581    format!("{}.bsj", output_arg)
582}
583
584/// Returns whether a field is the mate label used by the expanded BSJ protocol.
585///
586/// The parser helpers below deliberately accept both the historical CIRI3-style
587/// BSJ rows and the new mate-level rows. That compatibility keeps `Summary` and
588/// `Scan2` focused on their parity logic instead of scattering column-offset
589/// checks across hot code paths.
590pub fn is_bsj_mate_label(value: &str) -> bool {
591    value == "R1" || value == "R2"
592}
593
594/// Returns the first field index of the legacy BSJ payload.
595///
596/// Old rows start with `read_id, cigar, tag...`; new rows start with
597/// `read_id, mate_label, priority, cigar, tag...`.
598pub fn bsj_payload_start(parts: &[&str]) -> usize {
599    if parts.len() >= 3 && is_bsj_mate_label(parts[1]) && (parts[2] == "0" || parts[2] == "1") {
600        3
601    } else {
602        1
603    }
604}
605
606/// Returns whether a BSJ row should participate in the Java-compatible summary.
607///
608/// Historical rows have no explicit priority and are therefore treated as
609/// summary-priority rows. New mate-level rows must use `priority=1`.
610pub fn bsj_is_summary_priority(parts: &[&str]) -> bool {
611    let payload_start = bsj_payload_start(parts);
612    payload_start == 1 || parts.get(2).is_some_and(|priority| *priority == "1")
613}
614
615/// Returns the basename used for Scan2 shard-local BSJ spill files.
616pub fn bsj2_path_for_output(output_arg: &str) -> String {
617    format!("{}.bsj2", output_arg)
618}
619
620/// Returns the Scan2 segments-evidence sidecar path for one CLI prefix.
621///
622/// This mirrors [`segments1_path_for_output`] for Scan2 rescue reads and lets
623/// the final `<prefix>.segments` stage avoid re-scanning BAM/SAM for confirmed
624/// BSJ read-level blocks.
625pub fn segments2_path_for_output(output_arg: &str) -> String {
626    format!("{}.segments2", output_arg)
627}
628
629/// Returns the Scan2 non-BSJ segments sidecar path for one CLI prefix.
630///
631/// Unlike `.segments1/2`, this file is not restricted to confirmed BSJ reads.
632/// Scan2 writes read-level backward/outward topology candidates here so the
633/// post-Summary segments stage can avoid a third full BAM scan while still
634/// allowing candidates with no known circRNA or BSJ assignment.
635pub fn segments_non_bsj_path_for_output(output_arg: &str) -> String {
636    format!("{}.segments.non_bsj", output_arg)
637}
638
639/// Returns the basename used for FSJ shard-local spill files.
640///
641/// The pipeline no longer keeps a final `.fsj` artifact, but the `<prefix>.fsj`
642/// stem is still used to name temporary spill files such as
643/// `<prefix>.fsj.part_0001.tmp`.
644pub fn fsj_path_for_output(output_arg: &str) -> String {
645    format!("{}.fsj", output_arg)
646}
647
648/// Returns the temporary spill-file path for one shard-local intermediate.
649///
650/// Stable zero-padded numbering keeps shard files easy to scan by eye and
651/// avoids mixing two historical naming schemes (`.shard_N` vs `fsj_shard_N`).
652/// The input must be the logical merged artifact name or stem without a
653/// pre-existing `.tmp` suffix; shard files are always named
654/// `<merged>.part_XXXX.tmp` so every parallel stage uses the same convention.
655pub fn part_path(final_path: &str, part_idx: usize) -> String {
656    format!("{}.part_{:04}.tmp", final_path, part_idx + 1)
657}
658
659/// Chooses a safe BAM shard count for BGZF-parallel Scan1/Scan2 processing.
660///
661/// This intentionally falls back to fewer shards for tiny BAMs. The goal is not
662/// throughput on tiny fixtures, but avoiding shard starts that are too dense to
663/// reliably find a distinct next BGZF member.
664pub fn bam_shard_count(file_len: usize, requested_threads: usize) -> usize {
665    let requested = requested_threads.max(1);
666    let by_size = (file_len / MIN_BAM_SHARD_BYTES).max(1);
667    requested.min(by_size.max(1))
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_reverse_complement() {
676        assert_eq!(reverse_complement("ATGC"), "GCAT");
677        assert_eq!(reverse_complement("AAttGGcc"), "ggCCaaTT");
678        assert_eq!(reverse_complement("N"), "N");
679    }
680
681    #[test]
682    fn test_parse_mem_str() {
683        assert_eq!(parse_mem_str("1G"), 1024 * 1024 * 1024);
684        assert_eq!(parse_mem_str("512M"), 512 * 1024 * 1024);
685        assert_eq!(parse_mem_str("2"), 2 * 1024 * 1024 * 1024);
686    }
687
688    #[test]
689    fn test_bam_shard_count() {
690        assert_eq!(bam_shard_count(32 * 1024, 16), 1);
691        assert_eq!(bam_shard_count(4 * 1024 * 1024, 16), 1);
692        assert_eq!(bam_shard_count(8 * 1024 * 1024, 16), 2);
693        assert_eq!(bam_shard_count(128 * 1024 * 1024, 4), 4);
694    }
695
696    #[test]
697    fn reverse_strand_clip_matches_record_sequence_orientation() {
698        let mut chr = vec![b'N'; 300];
699        chr[179..195].copy_from_slice(b"TTTTCTAACCTTGTGA");
700        let mut reference = HashMap::new();
701        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
702        let seq = format!("{}{}", "TTTTCTAACCTTGTGA", "A".repeat(134));
703        let alignment = AlignmentRecord {
704            flag: 0x40 | 0x10,
705            chrom: Cow::Borrowed("chr1"),
706            pos: 100,
707            mapq: 60,
708            cigar: Cow::Borrowed("16S134M"),
709            seq: Cow::Owned(seq),
710            xa: Cow::Borrowed(""),
711        };
712        let bsj_lines = vec!["read1\tx\ty\tchr1\t100\t199".to_string()];
713
714        let rows = local_clip_evidence_lines(
715            "read1",
716            "scan1_local",
717            &[&alignment],
718            &bsj_lines,
719            &reference,
720            10,
721        );
722
723        assert_eq!(
724            rows,
725            vec!["read1\tscan1_local\tR1\t2128\tchr1\t180\t59\t16M134S\t150\t*\t:16\t*"]
726        );
727    }
728
729    #[test]
730    fn local_clip_records_longest_prefix_partial_match() {
731        let mut chr = vec![b'N'; 300];
732        chr[179..194].copy_from_slice(b"AGCCATCTGTGAGGG");
733        let mut reference = HashMap::new();
734        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
735        let seq = format!("{}{}", "AGCCATCTGTGAGGGA", "A".repeat(134));
736        let alignment = AlignmentRecord {
737            flag: 0x40,
738            chrom: Cow::Borrowed("chr1"),
739            pos: 195,
740            mapq: 60,
741            cigar: Cow::Borrowed("16S134M"),
742            seq: Cow::Owned(seq),
743            xa: Cow::Borrowed(""),
744        };
745        let bsj_lines = vec!["read1\tx\ty\tchr1\t100\t250".to_string()];
746
747        let rows = local_clip_evidence_lines(
748            "read1",
749            "scan1_local",
750            &[&alignment],
751            &bsj_lines,
752            &reference,
753            10,
754        );
755
756        assert_eq!(
757            rows,
758            vec!["read1\tscan1_local\tR1\t2112\tchr1\t180\t59\t15M135S\t150\t*\t:15\t*"]
759        );
760    }
761
762    #[test]
763    fn local_clip_records_longest_suffix_partial_match() {
764        let mut chr = vec![b'N'; 300];
765        chr[199..214].copy_from_slice(b"CACATGTGGACTAAA");
766        let mut reference = HashMap::new();
767        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
768        let seq = format!("{}{}", "TTTTTTTTTTTTCACATGTGGACTAAA", "A".repeat(123));
769        let alignment = AlignmentRecord {
770            flag: 0x80,
771            chrom: Cow::Borrowed("chr1"),
772            pos: 215,
773            mapq: 60,
774            cigar: Cow::Borrowed("27S123M"),
775            seq: Cow::Owned(seq),
776            xa: Cow::Borrowed(""),
777        };
778        let bsj_lines = vec!["read1\tx\ty\tchr1\t100\t250".to_string()];
779
780        let rows = local_clip_evidence_lines(
781            "read1",
782            "scan1_local",
783            &[&alignment],
784            &bsj_lines,
785            &reference,
786            10,
787        );
788
789        assert_eq!(
790            rows,
791            vec!["read1\tscan1_local\tR2\t2176\tchr1\t200\t59\t12S15M123S\t150\t*\t:15\t*"]
792        );
793    }
794
795    #[test]
796    fn test_output_paths_for_prefix() {
797        assert_eq!(result_path_for_output("sample.ciri"), "sample.ciri.out");
798        assert_eq!(log_path_for_output("sample.ciri"), "sample.ciri.log");
799        assert_eq!(bsj1_path_for_output("sample.ciri"), "sample.ciri.bsj1");
800        assert_eq!(bsj_path_for_output("sample.ciri"), "sample.ciri.bsj");
801        assert_eq!(bsj2_path_for_output("sample.ciri"), "sample.ciri.bsj2");
802        assert_eq!(fsj_path_for_output("sample.ciri"), "sample.ciri.fsj");
803        assert_eq!(
804            part_path("sample.ciri.bsj", 0),
805            "sample.ciri.bsj.part_0001.tmp"
806        );
807        assert_eq!(
808            part_path("sample.ciri.segments", 15),
809            "sample.ciri.segments.part_0016.tmp"
810        );
811    }
812}