Skip to main content

ciri_toolkit/cli/
ciri.rs

1//! User-facing CLI entry point for the `ciri` binary.
2//!
3//! This module owns argument parsing and orchestration for the user pipeline.
4//! Keeping it separate from `src/bin/ciri.rs` lets the binary remain a thin
5//! wrapper while the behaviorally sensitive logic stays testable in the library.
6
7use 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/// Parsed command-line arguments for the end-to-end pipeline.
42///
43/// Defaults are kept aligned with CIRI3 unless there is explicit evidence that a
44/// different value is required for parity.
45#[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    /// Input SAM/BAM file.
55    #[arg(short = 'i', long = "in")]
56    in_sam: String,
57
58    /// Output prefix.
59    #[arg(short = 'o', long = "out")]
60    out_prefix: String,
61
62    /// Reference genome FASTA.
63    #[arg(short = 'r', long = "ref")]
64    ref_fasta: String,
65
66    /// GTF annotation file.
67    #[arg(short = 'a', long = "anno")]
68    gtf: Option<String>,
69
70    /// Minimum MAPQ for candidate BSJ reads.
71    #[arg(short = 'm', long = "mapq", default_value_t = 10)]
72    min_mapq: i32,
73
74    /// Stringency level [0/1/2].
75    #[arg(short = 's', long = "stringency", default_value_t = 0)]
76    stringency: i32,
77
78    /// Maximum circRNA span.
79    #[arg(long = "max-span", default_value_t = 200000)]
80    max_span: i32,
81
82    /// Minimum circRNA span.
83    #[arg(long = "min-span", default_value_t = 50)]
84    min_span: i32,
85
86    /// Linear competitor search range size.
87    #[arg(long = "linear-range-size-min", default_value_t = 50000)]
88    linear_range_size_min: i32,
89
90    /// Number of worker threads (0 = auto).
91    #[arg(short = 't', long = "threads", default_value_t = 0)]
92    threads: usize,
93
94    /// Maximum memory per thread (e.g, 512M, 2G).
95    #[arg(short = 'M', long = "mem-per-thread", default_value = "512M")]
96    mem_per_thread: String,
97
98    /// Comma-separated read IDs to trace in Scan1/Scan2.
99    #[arg(long = "trace", value_name = "READS")]
100    trace_reads: Option<String>,
101
102    /// Keep internal pipeline temporary files.
103    #[arg(long = "debug", default_value_t = false)]
104    debug: bool,
105
106    /// Write profiling report to `<prefix>.perf.log`.
107    #[arg(long = "perf", default_value_t = false)]
108    perf: bool,
109
110    /// Rebuild isoforms only from existing `<prefix>.out` + `<prefix>.segments`
111    #[arg(long = "continue", default_value_t = false)]
112    continue_run: bool,
113
114    /// Only output `<prefix>.out` for 1st pass BSJ detection
115    #[arg(long = "1st-pass", default_value_t = false)]
116    first_pass: bool,
117
118    /// Only output `<prefix>.segments` for 2nd-pass segments detection.
119    #[arg(long = "2nd-pass", default_value_t = false)]
120    second_pass: bool,
121
122    /// User defined circRNAs in BED6 format.
123    #[arg(long = "circ", value_name = "BED6")]
124    circ: Option<String>,
125
126    /// Print version.
127    #[arg(short = 'v', long = "version", action = clap::ArgAction::SetTrue)]
128    _version: bool,
129}
130
131/// Emits one aligned, timestamped progress line.
132///
133/// Logs are mirrored to stdout and to `<prefix>.log` so the user can inspect a
134/// durable run record after the process exits.
135fn 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
144/// Sorts the mate-level BSJ rows and writes the final user-facing `.bsj`.
145///
146/// This runs strictly after Summary has consumed only `priority=1` rows, so the
147/// user-facing sort order and `priority=0` evidence cannot perturb `.out`.
148fn 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
191/// Writes the `.out`-scoped BEDPE track and returns its output path and row count.
192fn 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
198/// Converts Summary-confirmed circRNA sites into a BEDPE BSJ arc track.
199///
200/// The BEDPE track intentionally ignores internal structure: each `.out` row
201/// becomes one pair connecting the left and right BSJ anchors. BED coordinates
202/// are 0-based half-open, while CIRI `.out` coordinates are 1-based closed.
203fn 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
252/// Writes the `.segments`-scoped synthetic BAM/BAI track and records the result.
253fn 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
273/// Formats the isoform stage summary for the CLI run log.
274fn 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
316/// Converts read-level segment chains into an indexed BAM review track.
317///
318/// Each R1/R2 chain is split at explicit `<bsj>` markers so BSJ-crossing pieces
319/// render as separate IGV alignments. Within each piece, ordinary internal
320/// junctions remain CIGAR `N` gaps. Rows are coordinate-sorted before BAM
321/// conversion so IGV region loading can use the generated BAI index.
322fn 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
392/// Resolves the public `<prefix>.segments` columns needed by IGV export.
393fn 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
413/// Appends one mate chain as one or more synthetic alignments split at BSJ markers.
414fn 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
493/// Returns whether a new segment must start a fresh BAM alignment part.
494///
495/// SAM/BAM CIGARs are reference-coordinate ordered. CIRI read chains can move
496/// backward across circular or ambiguous topology even when no explicit
497/// `<bsj>` token is present, so those coordinate breaks are written as separate
498/// synthetic records instead of forcing the sequence-aware cs payload through a
499/// reordered CIGAR.
500fn 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
506/// Appends one synthetic alignment part and clears the accumulated segment blocks.
507fn 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
558/// Returns a human-genome-friendly chromosome key for coordinate sorting.
559///
560/// IGV accepts generic BED files, but review sessions here usually use hg38.
561/// Putting `chr1..chr22, chrX, chrY, chrM/MT` before alternate contigs makes
562/// the sidecar easier to scan while still keeping non-standard contigs sorted.
563fn 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
596/// Orders synthetic alignment rows by coordinate, with the query name as tie-breaker.
597fn 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
605/// Builds a SAM alignment line from one segment row.
606///
607/// When the chain-level `r*_cs` payload can be checked against the part's
608/// genomic span, the aligned query bases and BAM CIGAR are reconstructed from
609/// cs. If the cs payload is missing or inconsistent, the writer falls back to
610/// `N` bases and omits `cs:Z` rather than producing an invalid BAM.
611fn 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
685/// Reconstructs per-BSJ-part sequence and cs payloads for `.segments.bam`.
686///
687/// The public `.segments` row stores one read-chain cs payload, while the review
688/// BAM intentionally splits chains at `<bsj>`. This parser walks the segment
689/// tokens and cs operations together, using reference sequence for `:` match
690/// runs and starting a new part at CIRI's custom `<...>` back-splice op.
691fn 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
785/// Parses segment tokens into the same chain units consumed by `r*_cs`.
786fn 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
835/// Parses one minimap2-style short-form cs operation, including CIRI's `<...>`.
836fn 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/// Checks that a part-level cs payload consumes the same query and reference as
903/// the CIGAR that will be written to BAM.
904#[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
915/// Converts a part-level cs payload into a BAM CIGAR.
916///
917/// `:` runs and `*` substitutions both become `M`, while `+`, `-`, and `~`
918/// become `I`, `D`, and `N`. CIRI's custom `<...>` should have been consumed
919/// when the chain was split into BAM parts, so it is rejected here.
920fn 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
1017/// Converts sorted segment blocks into a splice-aware CIGAR string.
1018fn 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
1044/// Returns a synthetic MAPQ that keeps segment classes visually separable.
1045fn 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
1054/// Writes a coordinate-sorted synthetic BAM and BAI from already sorted rows.
1055///
1056/// This intentionally requires `samtools` because the repo does not yet own a
1057/// native BAI writer and `.segments.bam/.bai` is part of the normal review
1058/// output contract. The SAM temp is deleted after successful conversion so the
1059/// indexed BAM is the durable high-performance IGV artifact.
1060fn 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
1096/// Writes sorted SAM records used as the conversion source for the review BAM.
1097fn 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
1133/// Returns reference lengths for all contigs touched by the synthetic rows.
1134fn 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
1157/// Finds the `samtools` executable used for BAM/BAI review-track generation.
1158fn 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
1170/// Requires `samtools` before the pipeline starts touching large input files.
1171///
1172/// CIRI-toolkit writes `<prefix>.segments.bam/.bai` as a standard IGV review
1173/// sidecar. Failing early avoids spending minutes or hours on Scan1/Scan2 only
1174/// to discover that the final indexed review artifact cannot be produced.
1175fn 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
1183/// Checks whether a candidate `samtools` command can be executed.
1184fn 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
1192/// Parses a `start-end:strand` segment token from `<prefix>.segments`.
1193fn 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
1200/// Returns the synthetic alignment strand when all blocks agree, otherwise `.`.
1201fn 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
1216/// Returns a stable RGB color for one segment type.
1217fn 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
1226/// Loads inputs, runs Scan1 -> Scan2 -> Summary, and writes outputs.
1227///
1228/// The current `ciri` entry keeps the historical direct CIRI3-style arguments
1229/// instead of forcing a `detect` subcommand. After Summary finishes, the CLI now
1230/// always performs the read-level circRNA segments pass that feeds future
1231/// full-length reconstruction.
1232pub 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    // Stage boundaries are logged explicitly because most benchmarking and parity
1383    // work is reasoned about in terms of Scan1 / Scan2 / Summary timings.
1384    // 3. Scan 1
1385    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    // 4. Indexing
1431    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    // Scan1 is no longer needed once its output file and derived read length have
1438    // been materialized, so drop it before Scan2 to keep whole-genome RSS lower.
1439    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    // 5. Scan 2
1453    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    // 6. Finalization
1488    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            // This count comes from Summary's retained circ/read assignments,
1508            // not from `Scan1 + Scan2` raw BSJ accumulation. Keeping the final
1509            // user-facing metric here prevents stage-local bookkeeping from
1510            // being mistaken for the clustered output size.
1511            "{} 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
1822/// Removes internal stage sidecars after the final user-facing outputs exist.
1823///
1824/// Official CLI outputs should remain focused on the final result files. The
1825/// intermediate `.bsj1/.bsj2/.segments1/.segments2` stems are implementation
1826/// details kept only when `--debug` is set, while shard paths are removed here
1827/// because Scan1/Scan2/segments may leave either merged stems or `.part_*.tmp`
1828/// files depending on which fast path was active.
1829fn 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}