ciri_toolkit/cli/
assemble.rs1use 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#[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 #[arg(short = 'i', long = "in")]
30 sample_list: String,
31
32 #[arg(short = 'o', long = "out")]
34 out_prefix: String,
35
36 #[arg(short = 'r', long = "ref")]
38 ref_fasta: String,
39
40 #[arg(short = 'a', long = "anno")]
42 gtf: String,
43}
44
45pub 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
66fn 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}