1use anyhow::{bail, Result};
8use chrono::Local;
9use clap::Parser;
10use std::cmp::Ordering;
11use std::collections::HashMap;
12use std::env;
13use std::fs::{self, File};
14use std::io::{BufRead, BufReader, BufWriter, Write};
15use std::path::Path;
16use std::process::Command;
17use std::time::Instant;
18
19use crate::annotation::Annotation;
20use crate::circ_catalog::read_circ_bed6;
21use crate::ciri_as::{
22 rebuild_major_isoforms_from_segments, run_ciri_as, AsConfig, IsoformRunSummary,
23};
24use crate::fasta::FastaReader;
25use crate::runtime::init_runtime;
26use crate::sam_bam::{check_bam_sorting, detect_format, InputFormat};
27use crate::scan1::Scan1;
28use crate::scan2::Scan2;
29use crate::summary::Summary;
30use crate::utils::{
31 bsj1_path_for_output, bsj2_path_for_output, bsj_path_for_output, fsj_path_for_output,
32 log_path_for_output, parse_mem_str, perf_path_for_output, result_path_for_output,
33 segments1_path_for_output, segments2_path_for_output, segments_non_bsj_path_for_output,
34 trace_path_for_output,
35};
36
37const IGV_BSJ_COLOR: &str = "220,53,69";
38const IGV_BACKWARD_COLOR: &str = "25,118,210";
39const IGV_OUTWARD_COLOR: &str = "245,124,0";
40
41#[derive(Parser, Debug)]
46#[command(
47 author,
48 version,
49 disable_version_flag = true,
50 about = "Run the CIRI Rust analysis pipeline",
51 long_about = None
52)]
53struct Args {
54 #[arg(short = 'i', long = "in")]
56 in_sam: String,
57
58 #[arg(short = 'o', long = "out")]
60 out_prefix: String,
61
62 #[arg(short = 'r', long = "ref")]
64 ref_fasta: String,
65
66 #[arg(short = 'a', long = "anno")]
68 gtf: Option<String>,
69
70 #[arg(short = 'm', long = "mapq", default_value_t = 10)]
72 min_mapq: i32,
73
74 #[arg(short = 's', long = "stringency", default_value_t = 0)]
76 stringency: i32,
77
78 #[arg(long = "max-span", default_value_t = 200000)]
80 max_span: i32,
81
82 #[arg(long = "min-span", default_value_t = 50)]
84 min_span: i32,
85
86 #[arg(long = "linear-range-size-min", default_value_t = 50000)]
88 linear_range_size_min: i32,
89
90 #[arg(short = 't', long = "threads", default_value_t = 0)]
92 threads: usize,
93
94 #[arg(short = 'M', long = "mem-per-thread", default_value = "512M")]
96 mem_per_thread: String,
97
98 #[arg(long = "trace", value_name = "READS")]
100 trace_reads: Option<String>,
101
102 #[arg(long = "debug", default_value_t = false)]
104 debug: bool,
105
106 #[arg(long = "perf", default_value_t = false)]
108 perf: bool,
109
110 #[arg(long = "continue", default_value_t = false)]
112 continue_run: bool,
113
114 #[arg(long = "1st-pass", default_value_t = false)]
116 first_pass: bool,
117
118 #[arg(long = "2nd-pass", default_value_t = false)]
120 second_pass: bool,
121
122 #[arg(long = "circ", value_name = "BED6")]
124 circ: Option<String>,
125
126 #[arg(short = 'v', long = "version", action = clap::ArgAction::SetTrue)]
128 _version: bool,
129}
130
131fn log_info(log_writer: &mut BufWriter<File>, label: &str, msg: &str) -> Result<()> {
136 let now = Local::now().format("%Y-%m-%d %H:%M:%S");
137 let line = format!("{} [INFO] {:<18}: {}", now, label, msg);
138 println!("{}", line);
139 writeln!(log_writer, "{}", line)?;
140 log_writer.flush()?;
141 Ok(())
142}
143
144fn write_display_bsj(final_bsj: &str, bsj1_path: &str, bsj2_path: &str) -> Result<()> {
149 let mut rows: Vec<(String, usize, usize, usize, String)> = Vec::new();
150 for (path, stage_rank, stage_name) in
151 [(bsj1_path, 0usize, "scan1"), (bsj2_path, 1usize, "scan2")]
152 {
153 if let Ok(file) = File::open(path) {
154 let reader = BufReader::new(file);
155 for line_res in reader.lines() {
156 let line = line_res?;
157 if line.is_empty() {
158 continue;
159 }
160 let mut parts = line.splitn(4, '\t');
161 if let (Some(read_id), Some(mate_label), Some(priority), Some(_)) =
162 (parts.next(), parts.next(), parts.next(), parts.next())
163 {
164 let mate_rank = if mate_label == "R1" { 0 } else { 1 };
165 let priority_rank = if priority == "1" { 0 } else { 1 };
166 rows.push((
167 read_id.to_string(),
168 mate_rank,
169 priority_rank,
170 stage_rank,
171 format!("{}\t{}", line, stage_name),
172 ));
173 }
174 }
175 }
176 }
177 rows.sort_by(|a, b| {
178 a.0.cmp(&b.0)
179 .then_with(|| a.1.cmp(&b.1))
180 .then_with(|| a.2.cmp(&b.2))
181 .then_with(|| a.3.cmp(&b.3))
182 });
183 let mut writer = BufWriter::with_capacity(1024 * 1024, File::create(final_bsj)?);
184 for (_, _, _, _, line) in rows {
185 writeln!(writer, "{}", line)?;
186 }
187 writer.flush()?;
188 Ok(())
189}
190
191fn write_bsj_bedpe(out_prefix: &str, result_output: &str) -> Result<(String, usize)> {
193 let bedpe_path = format!("{}.bedpe", out_prefix);
194 let rows = write_bsj_bedpe_from_out(result_output, &bedpe_path)?;
195 Ok((bedpe_path, rows))
196}
197
198fn write_bsj_bedpe_from_out(out_path: &str, bedpe_path: &str) -> Result<usize> {
204 let file = File::open(out_path)?;
205 let reader = BufReader::new(file);
206 let mut writer = BufWriter::with_capacity(1024 * 1024, File::create(bedpe_path)?);
207 let mut rows = 0usize;
208 for line_result in reader.lines() {
209 let line = line_result?;
210 if line.trim().is_empty() || line.starts_with('#') {
211 continue;
212 }
213 let cols: Vec<&str> = line.split('\t').collect();
214 if cols.len() < 5 {
215 continue;
216 }
217 let Ok(start) = cols[2].parse::<i32>() else {
218 continue;
219 };
220 let Ok(end) = cols[3].parse::<i32>() else {
221 continue;
222 };
223 if start < 1 || end < start {
224 continue;
225 }
226 let score = cols
227 .get(4)
228 .copied()
229 .filter(|value| value.parse::<i64>().is_ok())
230 .unwrap_or("0");
231 let strand = cols.get(10).copied().unwrap_or(".");
232 writeln!(
233 writer,
234 "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
235 cols[1],
236 start - 1,
237 start,
238 cols[1],
239 end - 1,
240 end,
241 cols[0],
242 score,
243 strand,
244 strand
245 )?;
246 rows += 1;
247 }
248 writer.flush()?;
249 Ok(rows)
250}
251
252fn write_and_log_segments_bam(
254 log_writer: &mut BufWriter<File>,
255 segments_output: &str,
256 out_prefix: &str,
257 reference: &HashMap<String, String>,
258 threads: usize,
259) -> Result<()> {
260 let bam_path = format!("{}.segments.bam", out_prefix);
261 write_segments_review_tracks_from_segments(
262 segments_output,
263 &bam_path,
264 out_prefix,
265 reference,
266 threads,
267 )?;
268 let message = format!("{}", bam_path);
269 log_info(log_writer, "Output segments BAM", &message)?;
270 Ok(())
271}
272
273fn format_isoform_summary(summary: IsoformRunSummary) -> String {
275 format!(
276 "Assembled {} high-confidence isoforms from {} circRNAs",
277 summary.fasta_isoforms, summary.fasta_circ_rnas,
278 )
279}
280
281#[derive(Debug, Clone, Copy)]
282struct IgvSegmentsColumns {
283 read_id: usize,
284 type_name: usize,
285 circ_id: usize,
286 chrom: usize,
287 r1_cs: Option<usize>,
288 r1_segments: usize,
289 r2_cs: Option<usize>,
290 r2_segments: usize,
291}
292
293#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
294struct IgvChromSortKey {
295 group: u8,
296 rank: u32,
297 suffix: String,
298}
299
300#[derive(Debug, Clone)]
301struct IgvSegmentRow {
302 chrom_key: IgvChromSortKey,
303 chrom: String,
304 chrom_start: i32,
305 chrom_end: i32,
306 name: String,
307 sam_line: Option<String>,
308}
309
310#[derive(Debug, Clone)]
311struct IgvPartPayload {
312 seq: String,
313 cs: String,
314}
315
316fn write_segments_review_tracks_from_segments(
323 segments_path: &str,
324 bam_path: &str,
325 out_prefix: &str,
326 reference: &HashMap<String, String>,
327 threads: usize,
328) -> Result<()> {
329 let file = File::open(segments_path)?;
330 let mut lines = BufReader::new(file).lines();
331 let header = lines
332 .next()
333 .transpose()?
334 .ok_or_else(|| anyhow::anyhow!("empty segments file: {}", segments_path))?;
335 let columns = igv_segments_columns(&header, segments_path)?;
336 let mut rows = Vec::new();
337 for line_result in lines {
338 let line = line_result?;
339 if line.trim().is_empty() {
340 continue;
341 }
342 let cols: Vec<&str> = line.split('\t').collect();
343 let Some(type_name) = cols.get(columns.type_name).copied() else {
344 continue;
345 };
346 if !matches!(type_name, "bsj" | "backward" | "outward") {
347 continue;
348 }
349 let Some(read_id) = cols.get(columns.read_id).copied() else {
350 continue;
351 };
352 let Some(circ_id) = cols.get(columns.circ_id).copied() else {
353 continue;
354 };
355 let Some(chrom) = cols.get(columns.chrom).copied() else {
356 continue;
357 };
358 let color = igv_segment_color(type_name);
359 for (mate_label, segments, cs) in [
360 (
361 "R1",
362 cols.get(columns.r1_segments).copied().unwrap_or("NA"),
363 columns
364 .r1_cs
365 .and_then(|idx| cols.get(idx).copied())
366 .unwrap_or("NA"),
367 ),
368 (
369 "R2",
370 cols.get(columns.r2_segments).copied().unwrap_or("NA"),
371 columns
372 .r2_cs
373 .and_then(|idx| cols.get(idx).copied())
374 .unwrap_or("NA"),
375 ),
376 ] {
377 push_one_segments_alignment_chain(
378 &mut rows, chrom, read_id, type_name, circ_id, mate_label, segments, cs, color,
379 reference,
380 )?;
381 }
382 }
383 rows.sort_by(compare_igv_segment_rows);
384 let reference_lengths: HashMap<String, usize> = reference
385 .iter()
386 .map(|(chrom, seq)| (chrom.clone(), seq.len()))
387 .collect();
388 write_segments_bam_from_sorted_rows(&rows, bam_path, out_prefix, &reference_lengths, threads)?;
389 Ok(())
390}
391
392fn igv_segments_columns(header: &str, path: &str) -> Result<IgvSegmentsColumns> {
394 let headers: Vec<&str> = header.split('\t').collect();
395 let idx = |name: &str| -> Result<usize> {
396 headers
397 .iter()
398 .position(|field| *field == name)
399 .ok_or_else(|| anyhow::anyhow!("missing `{}` column in {}", name, path))
400 };
401 Ok(IgvSegmentsColumns {
402 read_id: idx("read_id")?,
403 type_name: idx("type")?,
404 circ_id: idx("circ_id")?,
405 chrom: idx("chrom")?,
406 r1_cs: headers.iter().position(|field| *field == "r1_cs"),
407 r1_segments: idx("r1_segments")?,
408 r2_cs: headers.iter().position(|field| *field == "r2_cs"),
409 r2_segments: idx("r2_segments")?,
410 })
411}
412
413fn push_one_segments_alignment_chain(
415 rows: &mut Vec<IgvSegmentRow>,
416 chrom: &str,
417 read_id: &str,
418 type_name: &str,
419 circ_id: &str,
420 mate_label: &str,
421 segments: &str,
422 cs: &str,
423 color: &str,
424 reference: &HashMap<String, String>,
425) -> Result<()> {
426 if segments == "NA" || segments.is_empty() {
427 return Ok(());
428 }
429 let part_payloads = igv_part_payloads_from_cs(chrom, segments, cs, reference);
430 let mut part = 1usize;
431 let mut blocks: Vec<(i32, i32, char)> = Vec::new();
432 for token in segments.split('|') {
433 if token == "<bsj>" {
434 let payload = part_payloads
435 .as_ref()
436 .and_then(|payloads| payloads.get(part - 1));
437 if push_segments_alignment_part(
438 rows,
439 chrom,
440 read_id,
441 type_name,
442 circ_id,
443 mate_label,
444 part,
445 color,
446 &mut blocks,
447 payload,
448 )? {
449 part += 1;
450 }
451 continue;
452 }
453 if let Some((start, end, strand)) = parse_igv_segment_token(token) {
454 if igv_part_coordinate_break(&blocks, start, end) {
455 let payload = part_payloads
456 .as_ref()
457 .and_then(|payloads| payloads.get(part - 1));
458 if push_segments_alignment_part(
459 rows,
460 chrom,
461 read_id,
462 type_name,
463 circ_id,
464 mate_label,
465 part,
466 color,
467 &mut blocks,
468 payload,
469 )? {
470 part += 1;
471 }
472 }
473 blocks.push((start, end, strand));
474 }
475 }
476 push_segments_alignment_part(
477 rows,
478 chrom,
479 read_id,
480 type_name,
481 circ_id,
482 mate_label,
483 part,
484 color,
485 &mut blocks,
486 part_payloads
487 .as_ref()
488 .and_then(|payloads| payloads.get(part - 1)),
489 )?;
490 Ok(())
491}
492
493fn igv_part_coordinate_break(blocks: &[(i32, i32, char)], start: i32, end: i32) -> bool {
501 blocks.last().is_some_and(|(prev_start, prev_end, _)| {
502 (*prev_start, *prev_end) > (start, end) || start <= *prev_end
503 })
504}
505
506fn push_segments_alignment_part(
508 rows: &mut Vec<IgvSegmentRow>,
509 chrom: &str,
510 read_id: &str,
511 type_name: &str,
512 circ_id: &str,
513 mate_label: &str,
514 part: usize,
515 color: &str,
516 blocks: &mut Vec<(i32, i32, char)>,
517 payload: Option<&IgvPartPayload>,
518) -> Result<bool> {
519 if blocks.is_empty() {
520 return Ok(false);
521 }
522 blocks.retain(|(start, end, _)| *start >= 1 && *end >= *start);
523 if blocks.is_empty() {
524 return Ok(false);
525 }
526 blocks.sort_by_key(|(start, end, _)| (*start, *end));
527 let chrom_start = blocks.iter().map(|(start, _, _)| *start).min().unwrap() - 1;
528 let chrom_end = blocks.iter().map(|(_, end, _)| *end).max().unwrap();
529 let strand = igv_bed12_strand(blocks);
530 let name = format!(
531 "{}|{}|{}|{}|part{}",
532 read_id, type_name, circ_id, mate_label, part
533 );
534 rows.push(IgvSegmentRow {
535 chrom_key: igv_chrom_sort_key(chrom),
536 chrom: chrom.to_string(),
537 chrom_start,
538 chrom_end,
539 name: name.clone(),
540 sam_line: synthetic_segments_sam_line(
541 chrom,
542 chrom_start,
543 name.as_str(),
544 type_name,
545 circ_id,
546 mate_label,
547 part,
548 color,
549 strand,
550 blocks,
551 payload,
552 ),
553 });
554 blocks.clear();
555 Ok(true)
556}
557
558fn igv_chrom_sort_key(chrom: &str) -> IgvChromSortKey {
564 let core = chrom.strip_prefix("chr").unwrap_or(chrom);
565 if let Ok(rank) = core.parse::<u32>() {
566 return IgvChromSortKey {
567 group: 0,
568 rank,
569 suffix: String::new(),
570 };
571 }
572 match core {
573 "X" => IgvChromSortKey {
574 group: 0,
575 rank: 23,
576 suffix: String::new(),
577 },
578 "Y" => IgvChromSortKey {
579 group: 0,
580 rank: 24,
581 suffix: String::new(),
582 },
583 "M" | "MT" => IgvChromSortKey {
584 group: 0,
585 rank: 25,
586 suffix: String::new(),
587 },
588 _ => IgvChromSortKey {
589 group: 1,
590 rank: 0,
591 suffix: chrom.to_string(),
592 },
593 }
594}
595
596fn compare_igv_segment_rows(left: &IgvSegmentRow, right: &IgvSegmentRow) -> Ordering {
598 left.chrom_key
599 .cmp(&right.chrom_key)
600 .then_with(|| left.chrom_start.cmp(&right.chrom_start))
601 .then_with(|| left.chrom_end.cmp(&right.chrom_end))
602 .then_with(|| left.name.cmp(&right.name))
603}
604
605fn synthetic_segments_sam_line(
612 chrom: &str,
613 chrom_start: i32,
614 name: &str,
615 type_name: &str,
616 circ_id: &str,
617 mate_label: &str,
618 part: usize,
619 color: &str,
620 strand: char,
621 blocks: &[(i32, i32, char)],
622 payload: Option<&IgvPartPayload>,
623) -> Option<String> {
624 let (fallback_cigar, fallback_query_len) = synthetic_segments_cigar(blocks)?;
625 if fallback_query_len == 0 {
626 return None;
627 }
628 let flag = if strand == '-' { 16 } else { 0 };
629 let mapq = igv_segment_mapq(type_name);
630 let fallback_ref_len = cigar_query_ref_consumption(&fallback_cigar)?.1;
631 let usable_payload = payload.and_then(|payload| {
632 let cigar = cigar_from_part_cs(&payload.cs)?;
633 let (query_len, ref_len) = cigar_query_ref_consumption(&cigar)?;
634 (query_len == payload.seq.len()
635 && ref_len == fallback_ref_len
636 && payload
637 .seq
638 .bytes()
639 .all(|base| matches!(base.to_ascii_uppercase(), b'A' | b'C' | b'G' | b'T' | b'N')))
640 .then_some((payload, cigar))
641 });
642 let (cigar, seq, cs_tag) = if let Some((payload, cigar)) = usable_payload {
643 (cigar, payload.seq.clone(), format!("\tcs:Z:{}", payload.cs))
644 } else {
645 (
646 fallback_cigar,
647 "N".repeat(fallback_query_len),
648 String::new(),
649 )
650 };
651 Some(format!(
652 "{}\t{}\t{}\t{}\t{}\t{}\t*\t0\t0\t{}\t*\tRG:Z:{}\tYC:Z:{}\tZT:Z:{}\tCI:Z:{}\tML:Z:{}\tPT:i:{}{}\n",
653 name,
654 flag,
655 chrom,
656 chrom_start + 1,
657 mapq,
658 cigar,
659 seq,
660 type_name,
661 color,
662 type_name,
663 circ_id,
664 mate_label,
665 part,
666 cs_tag
667 ))
668}
669
670#[derive(Debug, Clone, Copy)]
671enum IgvCsOp<'a> {
672 Match(usize),
673 Sub { query: u8 },
674 Ins(&'a str),
675 Del(usize),
676 Skip { raw: &'a str, len: usize, bsj: bool },
677}
678
679#[derive(Debug)]
680enum IgvChainUnit {
681 Segment { start: i32, end: i32 },
682 Bsj,
683}
684
685fn igv_part_payloads_from_cs(
692 chrom: &str,
693 segments: &str,
694 cs: &str,
695 reference: &HashMap<String, String>,
696) -> Option<Vec<IgvPartPayload>> {
697 if matches!(cs, "NA" | "*" | "") {
698 return None;
699 }
700 let chr_seq = reference.get(chrom)?;
701 let units = igv_chain_units(segments)?;
702 let mut unit_idx = first_segment_unit_idx(&units)?;
703 let mut ref_pos = match units.get(unit_idx)? {
704 IgvChainUnit::Segment { start, .. } => *start,
705 IgvChainUnit::Bsj => return None,
706 };
707 let mut parts = Vec::new();
708 let mut current = IgvPartPayload {
709 seq: String::new(),
710 cs: String::new(),
711 };
712
713 let mut pos = 0usize;
714 while pos < cs.len() {
715 let (op, next_pos) = parse_igv_cs_op(cs, pos)?;
716 pos = next_pos;
717 match op {
718 IgvCsOp::Match(len) => {
719 let segment_end = segment_end_at(&units, unit_idx)?;
720 if ref_pos + len as i32 - 1 > segment_end {
721 return None;
722 }
723 let start = (ref_pos - 1) as usize;
724 let end = start + len;
725 current.seq.push_str(chr_seq.get(start..end)?);
726 current.cs.push(':');
727 current.cs.push_str(&len.to_string());
728 ref_pos += len as i32;
729 }
730 IgvCsOp::Sub { query } => {
731 if ref_pos > segment_end_at(&units, unit_idx)? {
732 return None;
733 }
734 let raw = &cs[next_pos - 3..next_pos];
735 current.seq.push(query.to_ascii_uppercase() as char);
736 current.cs.push_str(raw);
737 ref_pos += 1;
738 }
739 IgvCsOp::Ins(seq) => {
740 current.seq.push_str(&seq.to_ascii_uppercase());
741 current.cs.push('+');
742 current.cs.push_str(seq);
743 }
744 IgvCsOp::Del(len) => {
745 if ref_pos + len as i32 - 1 > segment_end_at(&units, unit_idx)? {
746 return None;
747 }
748 current.cs.push_str(&cs[next_pos - len - 1..next_pos]);
749 ref_pos += len as i32;
750 }
751 IgvCsOp::Skip { raw, len, bsj } => {
752 let next_unit_idx = next_segment_unit_idx(&units, unit_idx, bsj)?;
753 let coordinate_break = match (units.get(unit_idx)?, units.get(next_unit_idx)?) {
754 (
755 IgvChainUnit::Segment {
756 start: prev_start,
757 end: prev_end,
758 },
759 IgvChainUnit::Segment { start, end },
760 ) => igv_part_coordinate_break(&[(*prev_start, *prev_end, '+')], *start, *end),
761 _ => return None,
762 };
763 if bsj || coordinate_break {
764 parts.push(current);
765 current = IgvPartPayload {
766 seq: String::new(),
767 cs: String::new(),
768 };
769 } else {
770 current.cs.push_str(raw);
771 }
772 unit_idx = next_unit_idx;
773 ref_pos = match units.get(unit_idx)? {
774 IgvChainUnit::Segment { start, .. } => *start,
775 IgvChainUnit::Bsj => return None,
776 };
777 let _ = len;
778 }
779 }
780 }
781 parts.push(current);
782 Some(parts)
783}
784
785fn igv_chain_units(segments: &str) -> Option<Vec<IgvChainUnit>> {
787 let mut units = Vec::new();
788 for token in segments.split('|') {
789 if token == "<bsj>" {
790 units.push(IgvChainUnit::Bsj);
791 continue;
792 }
793 let (start, end, _strand) = parse_igv_segment_token(token)?;
794 units.push(IgvChainUnit::Segment { start, end });
795 }
796 (!units.is_empty()).then_some(units)
797}
798
799fn first_segment_unit_idx(units: &[IgvChainUnit]) -> Option<usize> {
800 units
801 .iter()
802 .position(|unit| matches!(unit, IgvChainUnit::Segment { .. }))
803}
804
805fn segment_end_at(units: &[IgvChainUnit], unit_idx: usize) -> Option<i32> {
806 match units.get(unit_idx)? {
807 IgvChainUnit::Segment { end, .. } => Some(*end),
808 IgvChainUnit::Bsj => None,
809 }
810}
811
812fn next_segment_unit_idx(
813 units: &[IgvChainUnit],
814 current_idx: usize,
815 expect_bsj: bool,
816) -> Option<usize> {
817 let mut idx = current_idx + 1;
818 if expect_bsj {
819 if !matches!(units.get(idx)?, IgvChainUnit::Bsj) {
820 return None;
821 }
822 idx += 1;
823 } else if matches!(units.get(idx)?, IgvChainUnit::Bsj) {
824 return None;
825 }
826 while idx < units.len() {
827 if matches!(units[idx], IgvChainUnit::Segment { .. }) {
828 return Some(idx);
829 }
830 idx += 1;
831 }
832 None
833}
834
835fn parse_igv_cs_op(cs: &str, pos: usize) -> Option<(IgvCsOp<'_>, usize)> {
837 let bytes = cs.as_bytes();
838 let op = *bytes.get(pos)? as char;
839 let mut cursor = pos + 1;
840 match op {
841 ':' => {
842 let start = cursor;
843 while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
844 cursor += 1;
845 }
846 if cursor == start {
847 return None;
848 }
849 Some((IgvCsOp::Match(cs[start..cursor].parse().ok()?), cursor))
850 }
851 '*' => {
852 let _ref_base = *bytes.get(cursor)?;
853 let query = *bytes.get(cursor + 1)?;
854 Some((IgvCsOp::Sub { query }, cursor + 2))
855 }
856 '+' => {
857 let start = cursor;
858 while bytes
859 .get(cursor)
860 .is_some_and(|base| base.is_ascii_alphabetic())
861 {
862 cursor += 1;
863 }
864 (cursor > start).then_some((IgvCsOp::Ins(&cs[start..cursor]), cursor))
865 }
866 '-' => {
867 let start = cursor;
868 while bytes
869 .get(cursor)
870 .is_some_and(|base| base.is_ascii_alphabetic())
871 {
872 cursor += 1;
873 }
874 (cursor > start).then_some((IgvCsOp::Del(cursor - start), cursor))
875 }
876 '~' | '<' => {
877 cursor += 2;
878 let len_start = cursor;
879 while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
880 cursor += 1;
881 }
882 if cursor == len_start {
883 return None;
884 }
885 cursor += 2;
886 if cursor > cs.len() {
887 return None;
888 }
889 Some((
890 IgvCsOp::Skip {
891 raw: &cs[pos..cursor],
892 len: cs[len_start..cursor - 2].parse().ok()?,
893 bsj: op == '<',
894 },
895 cursor,
896 ))
897 }
898 _ => None,
899 }
900}
901
902#[cfg(test)]
905fn cs_consumption_matches_cigar(cs: &str, cigar: &str) -> bool {
906 let Some((cs_query, cs_ref)) = cs_query_ref_consumption(cs) else {
907 return false;
908 };
909 let Some((cigar_query, cigar_ref)) = cigar_query_ref_consumption(cigar) else {
910 return false;
911 };
912 cs_query == cigar_query && cs_ref == cigar_ref
913}
914
915fn cigar_from_part_cs(cs: &str) -> Option<String> {
921 let mut out = String::new();
922 let mut pending: Option<(char, usize)> = None;
923 let mut pos = 0usize;
924 while pos < cs.len() {
925 let (op, next) = parse_igv_cs_op(cs, pos)?;
926 match op {
927 IgvCsOp::Match(len) => push_cigar_run(&mut out, &mut pending, 'M', len),
928 IgvCsOp::Sub { .. } => push_cigar_run(&mut out, &mut pending, 'M', 1),
929 IgvCsOp::Ins(seq) => push_cigar_run(&mut out, &mut pending, 'I', seq.len()),
930 IgvCsOp::Del(len) => push_cigar_run(&mut out, &mut pending, 'D', len),
931 IgvCsOp::Skip { len, bsj, .. } => {
932 if bsj {
933 return None;
934 }
935 push_cigar_run(&mut out, &mut pending, 'N', len);
936 }
937 }
938 pos = next;
939 }
940 flush_cigar_run(&mut out, &mut pending);
941 (!out.is_empty()).then_some(out)
942}
943
944fn push_cigar_run(out: &mut String, pending: &mut Option<(char, usize)>, op: char, len: usize) {
945 if len == 0 {
946 return;
947 }
948 match pending.as_mut() {
949 Some((pending_op, pending_len)) if *pending_op == op => *pending_len += len,
950 _ => {
951 flush_cigar_run(out, pending);
952 *pending = Some((op, len));
953 }
954 }
955}
956
957fn flush_cigar_run(out: &mut String, pending: &mut Option<(char, usize)>) {
958 if let Some((op, len)) = pending.take() {
959 out.push_str(&len.to_string());
960 out.push(op);
961 }
962}
963
964#[cfg(test)]
965fn cs_query_ref_consumption(cs: &str) -> Option<(usize, usize)> {
966 let mut query = 0usize;
967 let mut reference = 0usize;
968 let mut pos = 0usize;
969 while pos < cs.len() {
970 let (op, next) = parse_igv_cs_op(cs, pos)?;
971 match op {
972 IgvCsOp::Match(len) => {
973 query += len;
974 reference += len;
975 }
976 IgvCsOp::Sub { .. } => {
977 query += 1;
978 reference += 1;
979 }
980 IgvCsOp::Ins(seq) => query += seq.len(),
981 IgvCsOp::Del(len) => reference += len,
982 IgvCsOp::Skip { len, .. } => reference += len,
983 }
984 pos = next;
985 }
986 Some((query, reference))
987}
988
989fn cigar_query_ref_consumption(cigar: &str) -> Option<(usize, usize)> {
990 let mut query = 0usize;
991 let mut reference = 0usize;
992 let mut number = String::new();
993 for op in cigar.chars() {
994 if op.is_ascii_digit() {
995 number.push(op);
996 continue;
997 }
998 if number.is_empty() {
999 return None;
1000 }
1001 let len = number.parse::<usize>().ok()?;
1002 match op {
1003 'M' | '=' | 'X' => {
1004 query += len;
1005 reference += len;
1006 }
1007 'I' | 'S' => query += len,
1008 'D' | 'N' => reference += len,
1009 'H' | 'P' => {}
1010 _ => return None,
1011 }
1012 number.clear();
1013 }
1014 number.is_empty().then_some((query, reference))
1015}
1016
1017fn synthetic_segments_cigar(blocks: &[(i32, i32, char)]) -> Option<(String, usize)> {
1019 let mut cigar = String::new();
1020 let mut query_len = 0usize;
1021 let mut prev_end0: Option<i32> = None;
1022 for &(start, end, _) in blocks {
1023 if start < 1 || end < start {
1024 return None;
1025 }
1026 let start0 = start - 1;
1027 if let Some(prev) = prev_end0 {
1028 let gap = start0 - prev;
1029 if gap < 0 {
1030 return None;
1031 }
1032 if gap > 0 {
1033 cigar.push_str(&format!("{}N", gap));
1034 }
1035 }
1036 let len = (end - start + 1) as usize;
1037 cigar.push_str(&format!("{}M", len));
1038 query_len += len;
1039 prev_end0 = Some(end);
1040 }
1041 Some((cigar, query_len))
1042}
1043
1044fn igv_segment_mapq(type_name: &str) -> u8 {
1046 match type_name {
1047 "bsj" => 60,
1048 "backward" => 45,
1049 "outward" => 30,
1050 _ => 0,
1051 }
1052}
1053
1054fn write_segments_bam_from_sorted_rows(
1061 rows: &[IgvSegmentRow],
1062 bam_path: &str,
1063 out_prefix: &str,
1064 reference_lengths: &HashMap<String, usize>,
1065 threads: usize,
1066) -> Result<()> {
1067 let samtools = require_samtools()?;
1068 let sam_path = format!("{}.segments.sam.tmp", out_prefix);
1069 write_segments_sam_from_sorted_rows(rows, &sam_path, reference_lengths)?;
1070 let view_threads = threads.max(1).to_string();
1071 let status = Command::new(&samtools)
1072 .arg("view")
1073 .arg("-@")
1074 .arg(&view_threads)
1075 .arg("-b")
1076 .arg("-o")
1077 .arg(bam_path)
1078 .arg(&sam_path)
1079 .status()?;
1080 if !status.success() {
1081 bail!("samtools view failed while creating {}", bam_path);
1082 }
1083 let status = Command::new(&samtools)
1084 .arg("index")
1085 .arg("-@")
1086 .arg(&view_threads)
1087 .arg(bam_path)
1088 .status()?;
1089 if !status.success() {
1090 bail!("samtools index failed while creating {}.bai", bam_path);
1091 }
1092 let _ = fs::remove_file(&sam_path);
1093 Ok(())
1094}
1095
1096fn write_segments_sam_from_sorted_rows(
1098 rows: &[IgvSegmentRow],
1099 sam_path: &str,
1100 reference_lengths: &HashMap<String, usize>,
1101) -> Result<()> {
1102 let mut writer = BufWriter::with_capacity(4 * 1024 * 1024, File::create(sam_path)?);
1103 writeln!(writer, "@HD\tVN:1.6\tSO:coordinate")?;
1104 for (chrom, len) in igv_bam_reference_lengths(rows, reference_lengths) {
1105 writeln!(writer, "@SQ\tSN:{}\tLN:{}", chrom, len)?;
1106 }
1107 writeln!(
1108 writer,
1109 "@RG\tID:bsj\tSM:CIRI_segments\tDS:back-spliced junction read segments"
1110 )?;
1111 writeln!(
1112 writer,
1113 "@RG\tID:backward\tSM:CIRI_segments\tDS:backward read segments"
1114 )?;
1115 writeln!(
1116 writer,
1117 "@RG\tID:outward\tSM:CIRI_segments\tDS:outward-facing read segments"
1118 )?;
1119 writeln!(
1120 writer,
1121 "@PG\tID:CIRI\tPN:CIRI\tVN:{}",
1122 env!("CARGO_PKG_VERSION")
1123 )?;
1124 for row in rows {
1125 if let Some(line) = &row.sam_line {
1126 writer.write_all(line.as_bytes())?;
1127 }
1128 }
1129 writer.flush()?;
1130 Ok(())
1131}
1132
1133fn igv_bam_reference_lengths(
1135 rows: &[IgvSegmentRow],
1136 reference_lengths: &HashMap<String, usize>,
1137) -> Vec<(String, usize)> {
1138 let mut lengths: HashMap<String, usize> = HashMap::new();
1139 for row in rows {
1140 let observed_len = usize::try_from(row.chrom_end.max(1)).unwrap_or(1);
1141 let len = reference_lengths
1142 .get(row.chrom.as_str())
1143 .copied()
1144 .unwrap_or(observed_len)
1145 .max(observed_len);
1146 lengths
1147 .entry(row.chrom.clone())
1148 .and_modify(|current| *current = (*current).max(len))
1149 .or_insert(len);
1150 }
1151 let mut entries: Vec<_> = lengths.into_iter().collect();
1152 entries
1153 .sort_by(|(left, _), (right, _)| igv_chrom_sort_key(left).cmp(&igv_chrom_sort_key(right)));
1154 entries
1155}
1156
1157fn find_samtools() -> Option<String> {
1159 if let Ok(path) = env::var("SAMTOOLS") {
1160 if samtools_is_usable(&path) {
1161 return Some(path);
1162 }
1163 }
1164 if samtools_is_usable("samtools") {
1165 return Some("samtools".to_string());
1166 }
1167 None
1168}
1169
1170fn require_samtools() -> Result<String> {
1176 find_samtools().ok_or_else(|| {
1177 anyhow::anyhow!(
1178 "`samtools` is not installed. Please install samtools in PATH or set SAMTOOLS=/path/to/samtools"
1179 )
1180 })
1181}
1182
1183fn samtools_is_usable(command: &str) -> bool {
1185 Command::new(command)
1186 .arg("--version")
1187 .output()
1188 .map(|output| output.status.success())
1189 .unwrap_or(false)
1190}
1191
1192fn parse_igv_segment_token(token: &str) -> Option<(i32, i32, char)> {
1194 let (range, strand_text) = token.rsplit_once(':')?;
1195 let (start, end) = range.split_once('-')?;
1196 let strand = strand_text.chars().next().unwrap_or('.');
1197 Some((start.parse().ok()?, end.parse().ok()?, strand))
1198}
1199
1200fn igv_bed12_strand(blocks: &[(i32, i32, char)]) -> char {
1202 let mut strand = None;
1203 for &(_, _, current) in blocks {
1204 if !matches!(current, '+' | '-') {
1205 return '.';
1206 }
1207 match strand {
1208 Some(previous) if previous != current => return '.',
1209 Some(_) => {}
1210 None => strand = Some(current),
1211 }
1212 }
1213 strand.unwrap_or('.')
1214}
1215
1216fn igv_segment_color(type_name: &str) -> &'static str {
1218 match type_name {
1219 "bsj" => IGV_BSJ_COLOR,
1220 "backward" => IGV_BACKWARD_COLOR,
1221 "outward" => IGV_OUTWARD_COLOR,
1222 _ => "128,128,128",
1223 }
1224}
1225
1226pub fn main() -> Result<()> {
1233 let run_started = Instant::now();
1234 if env::args()
1235 .skip(1)
1236 .any(|arg| arg == "-v" || arg == "--version")
1237 {
1238 println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
1239 return Ok(());
1240 }
1241 let args = Args::parse();
1242 if args.first_pass && args.second_pass {
1243 bail!("--1st-pass and --2nd-pass are mutually exclusive");
1244 }
1245 if args.continue_run && (args.first_pass || args.second_pass || args.circ.is_some()) {
1246 bail!("--continue cannot be combined with --1st-pass, --2nd-pass, or --circ");
1247 }
1248 if args.first_pass && args.circ.is_some() {
1249 bail!("--circ is used after ciri-merge and cannot be combined with --1st-pass");
1250 }
1251 if args.second_pass && args.circ.is_none() {
1252 bail!("--2nd-pass requires --circ <BED6>");
1253 }
1254 if !args.first_pass && !args.continue_run {
1255 let _ = require_samtools()?;
1256 }
1257 let mem_limit = parse_mem_str(&args.mem_per_thread);
1258 let result_output = result_path_for_output(&args.out_prefix);
1259 let log_output = log_path_for_output(&args.out_prefix);
1260 let trace_output = trace_path_for_output(&args.out_prefix);
1261 let perf_output = perf_path_for_output(&args.out_prefix);
1262 let bsj1_output = bsj1_path_for_output(&args.out_prefix);
1263 let bsj_output = bsj_path_for_output(&args.out_prefix);
1264 let bsj2_output = bsj2_path_for_output(&args.out_prefix);
1265 let segments1_output = segments1_path_for_output(&args.out_prefix);
1266 let segments2_output = segments2_path_for_output(&args.out_prefix);
1267 let segments_non_bsj_output = segments_non_bsj_path_for_output(&args.out_prefix);
1268 let fsj_output = fsj_path_for_output(&args.out_prefix);
1269 let mut log_writer = BufWriter::new(File::create(&log_output)?);
1270
1271 init_runtime(
1272 args.trace_reads.as_deref(),
1273 args.trace_reads.as_ref().map(|_| trace_output.as_str()),
1274 args.perf.then_some(perf_output.as_str()),
1275 )?;
1276
1277 if args.threads > 0 {
1278 rayon::ThreadPoolBuilder::new()
1279 .num_threads(args.threads)
1280 .build_global()?;
1281 }
1282
1283 log_info(&mut log_writer, "Reference FASTA", &args.ref_fasta)?;
1284 let mut fasta = FastaReader::new();
1285 fasta.read_fasta(&args.ref_fasta)?;
1286
1287 log_info(
1288 &mut log_writer,
1289 "Annotation GTF",
1290 args.gtf.as_deref().unwrap_or("None provided"),
1291 )?;
1292 let mut annotation = Annotation::new();
1293 if let Some(gtf_path) = &args.gtf {
1294 annotation.read_gtf(gtf_path)?;
1295 }
1296
1297 if args.trace_reads.is_some() {
1298 log_info(&mut log_writer, "Read trace", &trace_output)?;
1299 }
1300 if args.debug {
1301 log_info(
1302 &mut log_writer,
1303 "Debug temp",
1304 "Keeping internal temporary files after successful completion",
1305 )?;
1306 }
1307 if args.perf {
1308 log_info(&mut log_writer, "Perf report", &perf_output)?;
1309 }
1310 let external_circ_catalog = if let Some(circ_path) = &args.circ {
1311 let records = read_circ_bed6(circ_path)?;
1312 log_info(
1313 &mut log_writer,
1314 "User-defined BSJ",
1315 &format!("{} ({} circRNAs)", circ_path, records.len()),
1316 )?;
1317 Some(records)
1318 } else {
1319 None
1320 };
1321
1322 let input_path = args.in_sam.as_str();
1323 let format = detect_format(input_path)?;
1324 let format_str = match format {
1325 InputFormat::Bam => {
1326 check_bam_sorting(input_path)?;
1327 "BAM (queryname-sorted)"
1328 }
1329 InputFormat::Sam => "SAM (text-based)",
1330 };
1331 log_info(&mut log_writer, "Input format", format_str)?;
1332
1333 let segments_output = format!("{}.segments", args.out_prefix);
1334 if args.continue_run {
1335 if Path::new(&segments_output).is_file() {
1336 if !Path::new(&result_output).is_file() {
1337 bail!(
1338 "--continue found {} but missing required circRNA table {}",
1339 segments_output,
1340 result_output
1341 );
1342 }
1343 log_info(
1344 &mut log_writer,
1345 "Resume from checkpoint",
1346 "Start building circRNA isoforms...",
1347 )?;
1348 let isoform_summary = rebuild_major_isoforms_from_segments(
1349 &result_output,
1350 &segments_output,
1351 &args.out_prefix,
1352 &fasta.chr_tcga_map,
1353 args.gtf.as_ref().map(|_| &annotation),
1354 )?;
1355 log_info(
1356 &mut log_writer,
1357 "Isoforms output",
1358 &format!(
1359 "{}.isoforms.gtf, {}.isoforms.fa",
1360 args.out_prefix, args.out_prefix
1361 ),
1362 )?;
1363 log_info(
1364 &mut log_writer,
1365 "Isoforms summary",
1366 &format_isoform_summary(isoform_summary),
1367 )?;
1368 log_info(
1369 &mut log_writer,
1370 "Total runtime",
1371 &format!("{:.2} seconds", run_started.elapsed().as_secs_f64()),
1372 )?;
1373 return Ok(());
1374 }
1375
1376 bail!(
1377 "--continue did not find a resumable checkpoint: expected completed {}",
1378 segments_output
1379 );
1380 }
1381
1382 let total_stages = if args.first_pass {
1386 1
1387 } else if args.second_pass {
1388 2
1389 } else {
1390 3
1391 };
1392 let stage1_label = format!("=== STAGE 1/{} ===", total_stages);
1393 log_info(
1394 &mut log_writer,
1395 &stage1_label,
1396 "Back-splicing junction identification...",
1397 )?;
1398 log_info(
1399 &mut log_writer,
1400 "Running scan 1",
1401 "Identifying BSJ sites...",
1402 )?;
1403 let mut scan1 = Scan1::new(
1404 args.min_mapq,
1405 args.min_span,
1406 args.max_span,
1407 args.linear_range_size_min,
1408 );
1409 scan1.set_mem_limit(mem_limit);
1410 if args.first_pass {
1411 scan1.run_with_priority(input_path, &bsj1_output, &fasta.chr_tcga_map, &annotation)?;
1412 } else {
1413 scan1.run_with_priority_and_segments(
1414 input_path,
1415 &bsj1_output,
1416 Some(&segments1_output),
1417 &fasta.chr_tcga_map,
1418 &annotation,
1419 )?;
1420 }
1421 log_info(
1422 &mut log_writer,
1423 "Scan 1 summary",
1424 &format!(
1425 "{} mapped reads, {} BSJ1 reads",
1426 scan1.mapped_reads, scan1.bsj1_reads
1427 ),
1428 )?;
1429
1430 log_info(
1432 &mut log_writer,
1433 "Loading BSJ sites",
1434 "Generating candidate BSJ index...",
1435 )?;
1436 let scan2_seq_len = (scan1.read_len - 12).max(1);
1437 drop(scan1);
1440 let mut scan2 = Scan2::new(args.min_mapq, args.linear_range_size_min, scan2_seq_len);
1441 scan2.set_mem_limit(mem_limit);
1442 scan2.build_index(&bsj1_output)?;
1443 if let Some(records) = external_circ_catalog.as_ref() {
1444 let added = scan2.add_external_circ_candidates(records, &fasta.chr_tcga_map)?;
1445 log_info(
1446 &mut log_writer,
1447 "External BSJ sites",
1448 &format!("Added {} Scan2 candidate sites", added),
1449 )?;
1450 }
1451
1452 log_info(
1454 &mut log_writer,
1455 "Running scan 2",
1456 "Curating splicing signals & counting FSJs...",
1457 )?;
1458 let scan2_segment_artifacts = if args.first_pass {
1459 scan2.run_with_display(
1460 input_path,
1461 &bsj2_output,
1462 &fsj_output,
1463 Some(&bsj1_output),
1464 None,
1465 &fasta.chr_tcga_map,
1466 )?;
1467 Default::default()
1468 } else {
1469 scan2.run_with_display_and_segments(
1470 input_path,
1471 &bsj2_output,
1472 &fsj_output,
1473 Some(&bsj1_output),
1474 None,
1475 Some(&segments2_output),
1476 Some(&segments_non_bsj_output),
1477 &fasta.chr_tcga_map,
1478 )?
1479 };
1480 log_info(
1481 &mut log_writer,
1482 "Scan 2 summary",
1483 &format!("{} BSJ2 reads rescued", scan2.rescued_reads),
1484 )?;
1485 scan2.release_working_set();
1486
1487 log_info(
1489 &mut log_writer,
1490 "Post-processing",
1491 "Clustering sites and filtering results...",
1492 )?;
1493 let mut summary = Summary::new(args.stringency);
1494 summary.run_from_bsj_files(
1495 &[&bsj1_output, &bsj2_output],
1496 &result_output,
1497 &scan2.fsj_map,
1498 &fasta.chr_tcga_map,
1499 &annotation,
1500 )?;
1501 scan2.release_working_set();
1502
1503 log_info(
1504 &mut log_writer,
1505 "BSJ summary",
1506 &format!(
1507 "{} circRNAs, {} BSJ reads detected",
1512 summary.circ_count, summary.final_bsj_reads
1513 ),
1514 )?;
1515 log_info(&mut log_writer, "Output BSJ file", &result_output)?;
1516 if args.first_pass {
1517 log_info(
1518 &mut log_writer,
1519 "First pass output",
1520 "Stopping after 1st pass BSJ detection",
1521 )?;
1522 log_info(
1523 &mut log_writer,
1524 "Total runtime",
1525 &format!("{:.2} seconds", run_started.elapsed().as_secs_f64()),
1526 )?;
1527 if !args.debug {
1528 cleanup_pipeline_temp_files(
1529 &[
1530 &bsj1_output,
1531 &bsj2_output,
1532 &segments1_output,
1533 &segments2_output,
1534 &segments_non_bsj_output,
1535 &fsj_output,
1536 ],
1537 &scan2_segment_artifacts.non_bsj_segment_evidence_paths,
1538 );
1539 }
1540 return Ok(());
1541 }
1542 write_display_bsj(&bsj_output, &bsj1_output, &bsj2_output)?;
1543 let (bedpe_path, _bedpe_rows) = write_bsj_bedpe(&args.out_prefix, &result_output)?;
1544 log_info(
1545 &mut log_writer,
1546 "Output BEDPE file",
1547 &format!("{}", bedpe_path),
1548 )?;
1549
1550 let stage2_label = format!("=== STAGE 2/{} ===", total_stages);
1551 log_info(
1552 &mut log_writer,
1553 &stage2_label,
1554 "Internal splice junction identification...",
1555 )?;
1556 log_info(
1557 &mut log_writer,
1558 "Processing segments",
1559 "Generating read-level junction paths...",
1560 )?;
1561 let mut segment_progress_log =
1562 |label: &str, message: &str| log_info(&mut log_writer, label, message);
1563 let non_bsj_segment_evidence_paths: Vec<&str> = scan2_segment_artifacts
1564 .non_bsj_segment_evidence_paths
1565 .iter()
1566 .map(String::as_str)
1567 .collect();
1568 let segment_summary_result = run_ciri_as(AsConfig {
1569 input_path,
1570 circ_path: &result_output,
1571 bsj_path: Some(&bsj_output),
1572 segment_evidence_paths: vec![&segments1_output, &segments2_output],
1573 non_bsj_segment_evidence_paths,
1574 out_prefix: &args.out_prefix,
1575 keep_temp_files: args.debug,
1576 reference: &fasta.chr_tcga_map,
1577 annotation: args.gtf.as_ref().map(|_| &annotation),
1578 min_mapq: args.min_mapq,
1579 progress_log: Some(&mut segment_progress_log),
1580 });
1581 let segment_summary = segment_summary_result?;
1582 log_info(
1583 &mut log_writer,
1584 "Output segments file",
1585 &format!("{}.segments", args.out_prefix),
1586 )?;
1587 write_and_log_segments_bam(
1588 &mut log_writer,
1589 &segments_output,
1590 &args.out_prefix,
1591 &fasta.chr_tcga_map,
1592 args.threads,
1593 )?;
1594 log_info(
1595 &mut log_writer,
1596 "Segments summary",
1597 &format!(
1598 "{} reads ({} BSJ, {} backward, {} outward)",
1599 segment_summary.total_segments,
1600 segment_summary.bsj_segments,
1601 segment_summary.backward_segments,
1602 segment_summary.outward_segments
1603 ),
1604 )?;
1605 if args.second_pass {
1606 log_info(
1607 &mut log_writer,
1608 "Second pass output",
1609 "Stopping after 2nd pass segments detection",
1610 )?;
1611 log_info(
1612 &mut log_writer,
1613 "Total runtime",
1614 &format!("{:.2} seconds", run_started.elapsed().as_secs_f64()),
1615 )?;
1616 if !args.debug {
1617 let mut second_pass_extra_paths = scan2_segment_artifacts
1618 .non_bsj_segment_evidence_paths
1619 .clone();
1620 second_pass_extra_paths.push(bsj_output.clone());
1621 cleanup_pipeline_temp_files(
1622 &[
1623 &bsj1_output,
1624 &bsj2_output,
1625 &segments1_output,
1626 &segments2_output,
1627 &segments_non_bsj_output,
1628 &fsj_output,
1629 ],
1630 &second_pass_extra_paths,
1631 );
1632 }
1633 return Ok(());
1634 }
1635
1636 log_info(
1637 &mut log_writer,
1638 "=== STAGE 3/3 ===",
1639 "Full-length isoform reconstruction...",
1640 )?;
1641 log_info(
1642 &mut log_writer,
1643 "Reconstructing isoforms",
1644 "Calling circRNA isoforms from splice graphs...",
1645 )?;
1646 let isoform_summary = rebuild_major_isoforms_from_segments(
1647 &result_output,
1648 &segments_output,
1649 &args.out_prefix,
1650 &fasta.chr_tcga_map,
1651 args.gtf.as_ref().map(|_| &annotation),
1652 )?;
1653 log_info(
1654 &mut log_writer,
1655 "Output isoform files",
1656 &format!(
1657 "{}.isoforms.gtf, {}.isoforms.fa",
1658 args.out_prefix, args.out_prefix
1659 ),
1660 )?;
1661 log_info(
1662 &mut log_writer,
1663 "Isoform summary",
1664 &format_isoform_summary(isoform_summary),
1665 )?;
1666
1667 log_info(
1668 &mut log_writer,
1669 "Total runtime",
1670 &format!("{:.2} seconds", run_started.elapsed().as_secs_f64()),
1671 )?;
1672
1673 if !args.debug {
1674 cleanup_pipeline_temp_files(
1675 &[
1676 &bsj1_output,
1677 &bsj2_output,
1678 &segments1_output,
1679 &segments2_output,
1680 &segments_non_bsj_output,
1681 &fsj_output,
1682 ],
1683 &scan2_segment_artifacts.non_bsj_segment_evidence_paths,
1684 );
1685 }
1686
1687 Ok(())
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 use super::*;
1693
1694 #[test]
1695 fn igv_part_payloads_reconstruct_sequence_and_split_bsj_cs() {
1696 let mut reference = HashMap::new();
1697 reference.insert("chr1".to_string(), "NNNNACGTCCCCGGGG".to_string());
1698
1699 let payloads =
1700 igv_part_payloads_from_cs("chr1", "5-8:+|<bsj>|13-16:+", ":4<cc4gg:2*ga:1", &reference)
1701 .unwrap();
1702
1703 assert_eq!(payloads.len(), 2);
1704 assert_eq!(payloads[0].seq, "ACGT");
1705 assert_eq!(payloads[0].cs, ":4");
1706 assert_eq!(payloads[1].seq, "GGAG");
1707 assert_eq!(payloads[1].cs, ":2*ga:1");
1708 }
1709
1710 #[test]
1711 fn igv_part_payloads_split_coordinate_break_without_bsj_marker() {
1712 let mut reference = HashMap::new();
1713 reference.insert(
1714 "chr1".to_string(),
1715 "AAAACCCCGGGGTTTTAAAACCCCGGGG".to_string(),
1716 );
1717
1718 let payloads =
1719 igv_part_payloads_from_cs("chr1", "21-24:+|10-13:+", ":4~gg7tt:2*gc:1", &reference)
1720 .unwrap();
1721
1722 assert_eq!(payloads.len(), 2);
1723 assert_eq!(payloads[0].seq, "CCCC");
1724 assert_eq!(payloads[0].cs, ":4");
1725 assert_eq!(payloads[1].seq, "GGCT");
1726 assert_eq!(payloads[1].cs, ":2*gc:1");
1727 }
1728
1729 #[test]
1730 fn cs_consumption_must_match_synthetic_cigar() {
1731 assert!(cs_consumption_matches_cigar(":4~gt3ag:2", "4M3N2M"));
1732 assert!(!cs_consumption_matches_cigar(":2+a:2", "4M"));
1733 assert!(!cs_consumption_matches_cigar(":2-a:2", "4M"));
1734 }
1735
1736 #[test]
1737 fn cigar_from_part_cs_preserves_indels() {
1738 assert_eq!(cigar_from_part_cs(":2+a:1*ag-g:3").unwrap(), "2M1I2M1D3M");
1739 assert_eq!(cigar_from_part_cs(":4~gt3ag:2").unwrap(), "4M3N2M");
1740 assert!(cigar_from_part_cs(":4<gt3ag:2").is_none());
1741 }
1742
1743 #[test]
1744 fn synthetic_segments_sam_line_writes_real_seq_and_cs_when_valid() {
1745 let payload = IgvPartPayload {
1746 seq: "ACGT".to_string(),
1747 cs: ":4".to_string(),
1748 };
1749 let line = synthetic_segments_sam_line(
1750 "chr1",
1751 4,
1752 "read1|bsj|circ|R1|part1",
1753 "bsj",
1754 "circ",
1755 "R1",
1756 1,
1757 IGV_BSJ_COLOR,
1758 '+',
1759 &[(5, 8, '+')],
1760 Some(&payload),
1761 )
1762 .unwrap();
1763
1764 assert!(line.contains("\t4M\t"));
1765 assert!(line.contains("\tACGT\t"));
1766 assert!(line.contains("\tcs:Z::4\n"));
1767 }
1768
1769 #[test]
1770 fn synthetic_segments_sam_line_falls_back_when_cs_and_cigar_disagree() {
1771 let payload = IgvPartPayload {
1772 seq: "AACGT".to_string(),
1773 cs: "+a:4".to_string(),
1774 };
1775 let line = synthetic_segments_sam_line(
1776 "chr1",
1777 4,
1778 "read1|bsj|circ|R1|part1",
1779 "bsj",
1780 "circ",
1781 "R1",
1782 1,
1783 IGV_BSJ_COLOR,
1784 '+',
1785 &[(5, 8, '+')],
1786 Some(&payload),
1787 )
1788 .unwrap();
1789
1790 assert!(line.contains("\t1I4M\t"));
1791 assert!(line.contains("\tAACGT\t"));
1792 assert!(line.contains("\tcs:Z:+a:4\n"));
1793 }
1794
1795 #[test]
1796 fn synthetic_segments_sam_line_falls_back_when_cs_ref_span_disagrees() {
1797 let payload = IgvPartPayload {
1798 seq: "ACGT".to_string(),
1799 cs: ":4-a".to_string(),
1800 };
1801 let line = synthetic_segments_sam_line(
1802 "chr1",
1803 4,
1804 "read1|bsj|circ|R1|part1",
1805 "bsj",
1806 "circ",
1807 "R1",
1808 1,
1809 IGV_BSJ_COLOR,
1810 '+',
1811 &[(5, 8, '+')],
1812 Some(&payload),
1813 )
1814 .unwrap();
1815
1816 assert!(line.contains("\t4M\t"));
1817 assert!(line.contains("\tNNNN\t"));
1818 assert!(!line.contains("\tcs:Z:"));
1819 }
1820}
1821
1822fn cleanup_pipeline_temp_files(stems: &[&str], extra_paths: &[String]) {
1830 for path in extra_paths {
1831 let _ = std::fs::remove_file(path);
1832 }
1833 for stem in stems {
1834 let _ = std::fs::remove_file(stem);
1835 for idx in 0..rayon::current_num_threads().max(1) {
1836 let _ = std::fs::remove_file(crate::utils::part_path(stem, idx));
1837 }
1838 }
1839}