1use 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
17pub const MIN_BAM_SHARD_BYTES: usize = 4 * 1024 * 1024;
26
27#[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 pub xa: Cow<'a, str>,
45}
46
47pub 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
68pub 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
93pub 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
124pub 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
239pub 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 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
345pub(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
378fn 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
391fn 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
416pub(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
428pub(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
488pub(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
511pub 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 }
526}
527
528pub fn result_path_for_output(output_arg: &str) -> String {
537 format!("{}.out", output_arg)
538}
539
540pub fn log_path_for_output(output_arg: &str) -> String {
545 format!("{}.log", output_arg)
546}
547
548pub fn trace_path_for_output(output_arg: &str) -> String {
553 format!("{}.trace.log", output_arg)
554}
555
556pub fn perf_path_for_output(output_arg: &str) -> String {
562 format!("{}.perf.log", output_arg)
563}
564
565pub fn bsj1_path_for_output(output_arg: &str) -> String {
567 format!("{}.bsj1", output_arg)
568}
569
570pub fn segments1_path_for_output(output_arg: &str) -> String {
576 format!("{}.segments1", output_arg)
577}
578
579pub fn bsj_path_for_output(output_arg: &str) -> String {
581 format!("{}.bsj", output_arg)
582}
583
584pub fn is_bsj_mate_label(value: &str) -> bool {
591 value == "R1" || value == "R2"
592}
593
594pub 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
606pub 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
615pub fn bsj2_path_for_output(output_arg: &str) -> String {
617 format!("{}.bsj2", output_arg)
618}
619
620pub fn segments2_path_for_output(output_arg: &str) -> String {
626 format!("{}.segments2", output_arg)
627}
628
629pub fn segments_non_bsj_path_for_output(output_arg: &str) -> String {
636 format!("{}.segments.non_bsj", output_arg)
637}
638
639pub fn fsj_path_for_output(output_arg: &str) -> String {
645 format!("{}.fsj", output_arg)
646}
647
648pub fn part_path(final_path: &str, part_idx: usize) -> String {
656 format!("{}.part_{:04}.tmp", final_path, part_idx + 1)
657}
658
659pub 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}