Skip to main content

ciri_toolkit/cli/
assemble.rs

1//! CLI implementation for `ciri-assemble` multi-sample isoform assembly.
2//!
3//! The command consumes completed second-pass sample prefixes rather than BAM
4//! files. This keeps integrated assembly on the stable `.out + .segments` boundary
5//! and lets users rerun usage/switching analysis without repeating expensive
6//! read scanning.
7
8use anyhow::{bail, Context, Result};
9use clap::Parser;
10use std::collections::HashSet;
11use std::fs::File;
12use std::io::{BufRead, BufReader};
13use std::path::Path;
14
15use crate::annotation::Annotation;
16use crate::ciri_as::{run_ciri_assemble, CohortAssembleConfig, CohortSampleInput};
17use crate::fasta::FastaReader;
18
19/// Parsed command-line arguments for `ciri-assemble`.
20#[derive(Parser, Debug)]
21#[command(
22    author,
23    version,
24    about = "Integrative assemble of circRNA isoforms from multi-sample segments",
25    long_about = None
26)]
27struct Args {
28    /// Sample list TSV.
29    #[arg(short = 'i', long = "in")]
30    sample_list: String,
31
32    /// Output prefix.
33    #[arg(short = 'o', long = "out")]
34    out_prefix: String,
35
36    /// Reference genome FASTA.
37    #[arg(short = 'r', long = "ref")]
38    ref_fasta: String,
39
40    /// GTF annotation file.
41    #[arg(short = 'a', long = "anno")]
42    gtf: String,
43}
44
45/// Parses arguments, loads reference resources, and runs multi-sample assembly.
46pub fn main() -> Result<()> {
47    let args = Args::parse();
48    let samples = read_sample_list(&args.sample_list)?;
49    let mut fasta = FastaReader::new();
50    fasta.read_fasta(&args.ref_fasta)?;
51    let mut annotation = Annotation::new();
52    annotation.read_gtf(&args.gtf)?;
53    let summary = run_ciri_assemble(CohortAssembleConfig {
54        samples,
55        out_prefix: &args.out_prefix,
56        reference: &fasta.chr_tcga_map,
57        annotation: Some(&annotation),
58    })?;
59    eprintln!(
60        "Assembled {} isoforms from {} circRNAs across {} samples ({} isoform switching events)",
61        summary.isoforms, summary.circ_rnas, summary.samples, summary.switching_circ_rnas
62    );
63    Ok(())
64}
65
66/// Reads a headerless two-column sample manifest.
67///
68/// The second column is a sample prefix. `ciri-assemble` derives
69/// `<prefix>.out` and `<prefix>.segments` from it so the manifest stays compact
70/// and matches the second-pass output contract.
71fn read_sample_list(path: &str) -> Result<Vec<CohortSampleInput>> {
72    let file = File::open(path).with_context(|| format!("open sample list {path}"))?;
73    let reader = BufReader::new(file);
74    let mut samples = Vec::new();
75    let mut seen = HashSet::new();
76    for (line_idx, line_result) in reader.lines().enumerate() {
77        let line = line_result?;
78        let trimmed = line.trim();
79        if trimmed.is_empty() || trimmed.starts_with('#') {
80            continue;
81        }
82        let fields: Vec<&str> = trimmed.split_whitespace().collect();
83        if fields.len() != 2 {
84            bail!(
85                "{}:{} expected exactly two columns: sample_id and prefix",
86                path,
87                line_idx + 1
88            );
89        }
90        if !seen.insert(fields[0].to_string()) {
91            bail!(
92                "{}:{} duplicate sample_id {}",
93                path,
94                line_idx + 1,
95                fields[0]
96            );
97        }
98        let prefix = fields[1].to_string();
99        let out_path = format!("{}.out", prefix);
100        let segments_path = format!("{}.segments", prefix);
101        if !Path::new(&out_path).is_file() {
102            bail!(
103                "{}:{} missing derived .out file {}",
104                path,
105                line_idx + 1,
106                out_path
107            );
108        }
109        if !Path::new(&segments_path).is_file() {
110            bail!(
111                "{}:{} missing derived .segments file {}",
112                path,
113                line_idx + 1,
114                segments_path
115            );
116        }
117        samples.push(CohortSampleInput {
118            sample_id: fields[0].to_string(),
119            prefix,
120        });
121    }
122    if samples.is_empty() {
123        bail!("sample list {} did not contain any samples", path);
124    }
125    Ok(samples)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use tempfile::tempdir;
132
133    #[test]
134    fn sample_list_requires_headerless_sample_and_prefix() {
135        let dir = tempdir().unwrap();
136        let prefix = dir.path().join("s1");
137        std::fs::write(prefix.with_extension("out"), "circRNA_ID\n").unwrap();
138        std::fs::write(prefix.with_extension("segments"), "read_id\n").unwrap();
139        let list = dir.path().join("samples.tsv");
140        std::fs::write(&list, format!("sampleA\t{}\n", prefix.to_string_lossy())).unwrap();
141
142        let samples = read_sample_list(list.to_str().unwrap()).unwrap();
143
144        assert_eq!(samples.len(), 1);
145        assert_eq!(samples[0].sample_id, "sampleA");
146        assert_eq!(samples[0].prefix, prefix.to_string_lossy().to_string());
147    }
148}