#![forbid(unsafe_code)]
use std::io::Write;
use std::process::ExitCode;
use needletail::parse_fastx_file;
use spoars::align::{AlignmentEngine, AlignmentType, Scoring, SimdEngine, SisdEngine};
use spoars::graph::Graph;
const FORCE_SISD_ENV: &str = "SPOARS_FORCE_SISD";
fn should_force_sisd(value: Option<&str>) -> bool {
matches!(value, Some(v) if !v.is_empty() && v != "0")
}
struct Options {
m: i8,
n: i8,
g: i8,
e: i8,
q: i8,
c: i8,
min_coverage: i32,
algorithm: u8,
results: Vec<u8>,
dot_path: String,
strand_ambiguous: bool,
input: Option<String>,
}
impl Default for Options {
fn default() -> Options {
Options {
m: 5,
n: -4,
g: -8,
e: -6,
q: -10,
c: -4,
min_coverage: -1,
algorithm: 0,
results: vec![0],
dot_path: String::new(),
strand_ambiguous: false,
input: None,
}
}
}
enum ParseOutcome {
Run(Options),
ExitOk(Option<String>),
ExitErr(Option<String>),
}
fn parse_i32(value: &str) -> Result<i32, String> {
value
.trim()
.parse::<i32>()
.map_err(|_| format!("[spoars::] error: invalid integer argument '{value}'"))
}
fn parse_args(args: &[String]) -> ParseOutcome {
let mut opts = Options::default();
let mut i = 0usize;
while i < args.len() {
let token = args[i].clone();
if token == "--" {
i += 1;
while i < args.len() {
if opts.input.is_none() {
opts.input = Some(args[i].clone());
}
i += 1;
}
break;
}
if let Some(rest) = token.strip_prefix("--") {
match parse_long_option(rest, args, &mut i, &mut opts) {
Ok(()) => {
i += 1;
continue;
}
Err(outcome) => return outcome,
}
}
if token.starts_with('-') && token.len() > 1 {
match parse_short_cluster(&token, args, &mut i, &mut opts) {
Ok(()) => {
i += 1;
continue;
}
Err(outcome) => return outcome,
}
}
if opts.input.is_none() {
opts.input = Some(token);
}
i += 1;
}
if opts.results.len() > 1 {
opts.results.remove(0);
}
ParseOutcome::Run(opts)
}
fn parse_long_option(
rest: &str,
args: &[String],
i: &mut usize,
opts: &mut Options,
) -> Result<(), ParseOutcome> {
let (name, inline_value) = match rest.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (rest, None),
};
let needs_value = matches!(name, "algorithm" | "result" | "min-coverage" | "dot");
let value = if inline_value.is_some() {
inline_value
} else if needs_value {
*i += 1;
match args.get(*i) {
Some(v) => Some(v.clone()),
None => {
return Err(ParseOutcome::ExitErr(Some(format!(
"[spoars::] error: option '--{name}' requires an argument"
))))
}
}
} else {
None
};
match name {
"algorithm" => match parse_i32(value.as_deref().unwrap_or_default()) {
Ok(v) => opts.algorithm = v as u8,
Err(e) => return Err(ParseOutcome::ExitErr(Some(e))),
},
"result" => match parse_i32(value.as_deref().unwrap_or_default()) {
Ok(v) => opts.results.push(v as u8),
Err(e) => return Err(ParseOutcome::ExitErr(Some(e))),
},
"min-coverage" => match parse_i32(value.as_deref().unwrap_or_default()) {
Ok(v) => opts.min_coverage = v,
Err(e) => return Err(ParseOutcome::ExitErr(Some(e))),
},
"dot" => opts.dot_path = value.unwrap_or_default(),
"strand-ambiguous" => opts.strand_ambiguous = true,
"version" => {
return Err(ParseOutcome::ExitOk(Some(format!(
"{}\n",
env!("CARGO_PKG_VERSION")
))))
}
"help" => return Err(ParseOutcome::ExitOk(Some(help_text()))),
_ => {
return Err(ParseOutcome::ExitErr(Some(format!(
"[spoars::] error: unrecognized option '--{name}'"
))))
}
}
Ok(())
}
fn parse_short_cluster(
token: &str,
args: &[String],
i: &mut usize,
opts: &mut Options,
) -> Result<(), ParseOutcome> {
let mut chars = token[1..].chars();
while let Some(ch) = chars.next() {
match ch {
's' => opts.strand_ambiguous = true,
'h' => return Err(ParseOutcome::ExitOk(Some(help_text()))),
'm' | 'n' | 'g' | 'e' | 'q' | 'c' | 'l' | 'r' | 'd' => {
let attached: String = chars.by_ref().collect();
let value = if !attached.is_empty() {
attached
} else {
*i += 1;
match args.get(*i) {
Some(v) => v.clone(),
None => {
return Err(ParseOutcome::ExitErr(Some(format!(
"[spoars::] error: option '-{ch}' requires an argument"
))))
}
}
};
match ch {
'm' => opts.m = parse_i32(&value).map_err(err_outcome)? as i8,
'n' => opts.n = parse_i32(&value).map_err(err_outcome)? as i8,
'g' => opts.g = parse_i32(&value).map_err(err_outcome)? as i8,
'e' => opts.e = parse_i32(&value).map_err(err_outcome)? as i8,
'q' => opts.q = parse_i32(&value).map_err(err_outcome)? as i8,
'c' => opts.c = parse_i32(&value).map_err(err_outcome)? as i8,
'l' => opts.algorithm = parse_i32(&value).map_err(err_outcome)? as u8,
'r' => opts
.results
.push(parse_i32(&value).map_err(err_outcome)? as u8),
'd' => opts.dot_path = value,
_ => unreachable!("guarded by the outer match arm"),
}
}
other => {
return Err(ParseOutcome::ExitErr(Some(format!(
"[spoars::] error: unrecognized option '-{other}'"
))))
}
}
}
Ok(())
}
fn err_outcome(message: String) -> ParseOutcome {
ParseOutcome::ExitErr(Some(message))
}
fn help_text() -> String {
concat!(
"usage: spoars [options ...] <sequences>\n",
"\n",
" # default output is stdout\n",
" <sequences>\n",
" input file in FASTA/FASTQ format (can be compressed with gzip)\n",
"\n",
" options:\n",
" -m <int>\n",
" default: 5\n",
" score for matching bases\n",
" -n <int>\n",
" default: -4\n",
" score for mismatching bases\n",
" -g <int>\n",
" default: -8\n",
" gap opening penalty (must be non-positive)\n",
" -e <int>\n",
" default: -6\n",
" gap extension penalty (must be non-positive)\n",
" -q <int>\n",
" default: -10\n",
" gap opening penalty of the second affine function\n",
" (must be non-positive)\n",
" -c <int>\n",
" default: -4\n",
" gap extension penalty of the second affine function\n",
" (must be non-positive)\n",
" -l, --algorithm <int>\n",
" default: 0\n",
" alignment mode:\n",
" 0 - local (Smith-Waterman)\n",
" 1 - global (Needleman-Wunsch)\n",
" 2 - semi-global\n",
" -r, --result <int> (option can be used multiple times)\n",
" default: 0\n",
" result mode:\n",
" 0 - consensus (FASTA)\n",
" 1 - multiple sequence alignment (FASTA)\n",
" 2 - 0 & 1 (FASTA)\n",
" 3 - partial order graph (GFA)\n",
" 4 - 0 & 3 (GFA)\n",
" --min-coverage <int>\n",
" default: -1\n",
" minimal consensus coverage (usable only with -r 0)\n",
" -d, --dot <file>\n",
" output file for the partial order graph in DOT format\n",
" -s, --strand-ambiguous\n",
" for each sequence pick the strand with the better alignment\n",
" --version\n",
" prints the version number\n",
" -h, --help\n",
" prints the usage\n",
"\n",
" gap mode:\n",
" linear if g >= e\n",
" affine if g <= q or e >= c\n",
" convex otherwise (default)\n",
)
.to_string()
}
fn reverse_complement(seq: &[u8]) -> Vec<u8> {
seq.iter().rev().map(|&b| complement_base(b)).collect()
}
fn complement_base(base: u8) -> u8 {
match base {
b'A' => b'T',
b'a' => b't',
b'C' => b'G',
b'c' => b'g',
b'G' => b'C',
b'g' => b'c',
b'T' | b'U' => b'A',
b't' | b'u' => b'a',
b'R' => b'Y',
b'r' => b'y',
b'Y' => b'R',
b'y' => b'r',
b'K' => b'M',
b'k' => b'm',
b'M' => b'K',
b'm' => b'k',
b'B' => b'V',
b'b' => b'v',
b'V' => b'B',
b'v' => b'b',
b'D' => b'H',
b'd' => b'h',
b'H' => b'D',
b'h' => b'd',
other => other, }
}
const SUPPORTED_EXTENSIONS: &[&str] = &[
".fasta",
".fasta.gz",
".fna",
".fna.gz",
".faa",
".faa.gz",
".fa",
".fa.gz",
".fastq",
".fastq.gz",
".fq",
".fq.gz",
];
fn has_supported_extension(path: &str) -> bool {
SUPPORTED_EXTENSIONS.iter().any(|ext| path.ends_with(ext))
}
type SequenceRecords = (Vec<Vec<u8>>, Vec<Vec<u8>>, Vec<String>);
fn read_sequences(path: &str) -> Result<SequenceRecords, String> {
if !has_supported_extension(path) {
return Err(format!(
"[spoars::CreateParser] error: file {path} has unsupported format extension (valid \
extensions: .fasta, .fasta.gz, .fna, .fna.gz, .faa, .faa.gz, .fa, .fa.gz, .fastq, \
.fastq.gz, .fq, .fq.gz)"
));
}
let mut reader = parse_fastx_file(path)
.map_err(|e| format!("[spoars::CreateParser] error: failed to open {path}: {e}"))?;
let mut seqs = Vec::new();
let mut quals = Vec::new();
let mut names = Vec::new();
while let Some(record) = reader.next() {
let record = record.map_err(|e| format!("[spoars::CreateParser] error: {path}: {e}"))?;
let seq = record.seq().into_owned();
let qual = record.qual().map(<[u8]>::to_vec).unwrap_or_default();
let id = record.id();
let name_bytes = id.split(|&b| b == b' ' || b == b'\t').next().unwrap_or(id);
let name = String::from_utf8_lossy(name_bytes).into_owned();
seqs.push(seq);
quals.push(qual);
names.push(name);
}
Ok((seqs, quals, names))
}
fn run(opts: Options) -> ExitCode {
let Some(input_path) = opts.input.clone() else {
eprintln!("[spoars::] error: missing input file!");
print!("{}", help_text());
return ExitCode::FAILURE;
};
let alignment_type = match opts.algorithm {
0 => AlignmentType::Local,
1 => AlignmentType::Global,
2 => AlignmentType::Overlap,
other => {
eprintln!("[spoars::AlignmentEngine::Create] error: invalid alignment type {other}");
return ExitCode::FAILURE;
}
};
let scoring = match Scoring::new(opts.m, opts.n, opts.g, opts.e, opts.q, opts.c) {
Ok(s) => s,
Err(e) => {
eprintln!("{e}");
return ExitCode::FAILURE;
}
};
let (seqs, quals, names) = match read_sequences(&input_path) {
Ok(v) => v,
Err(e) => {
eprintln!("{e}");
return ExitCode::FAILURE;
}
};
let force_sisd = should_force_sisd(std::env::var(FORCE_SISD_ENV).ok().as_deref());
let mut engine: Box<dyn AlignmentEngine> = if force_sisd {
Box::new(SisdEngine::new(alignment_type, scoring))
} else {
Box::new(SimdEngine::new(alignment_type, scoring))
};
let mut graph = Graph::new();
let mut is_reversed: Vec<bool> = Vec::new();
for i in 0..seqs.len() {
let seq = &seqs[i];
let (mut alignment, score) = engine.align(seq, &graph);
let mut used_seq = seq.clone();
let mut used_quals = quals[i].clone();
if opts.strand_ambiguous {
let rev_seq = reverse_complement(seq);
let (alignment_rev, score_rev) = engine.align(&rev_seq, &graph);
if score >= score_rev {
is_reversed.push(false);
} else {
alignment = alignment_rev;
used_seq = rev_seq;
used_quals = quals[i].iter().rev().copied().collect();
is_reversed.push(true);
}
}
let add_result = if used_quals.is_empty() {
graph.add_alignment_weight(&alignment, &used_seq, 1)
} else {
graph.add_alignment_quality(&alignment, &used_seq, &used_quals)
};
if let Err(e) = add_result {
eprintln!("{e}");
return ExitCode::FAILURE;
}
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
for &mode in &opts.results {
match mode {
0 => {
let consensus = graph.generate_consensus_min_coverage(opts.min_coverage);
let _ = writeln!(out, ">Consensus LN:i:{}", consensus.len());
let _ = writeln!(out, "{consensus}");
}
1 | 2 => {
let msa = graph.generate_msa(mode == 2);
for (i, row) in msa.iter().enumerate() {
let name = if i < names.len() {
names[i].as_str()
} else {
"Consensus"
};
let _ = writeln!(out, ">{name}");
let _ = writeln!(out, "{row}");
}
}
3 | 4 => {
graph.generate_consensus();
let gfa = graph.to_gfa(&names, &is_reversed, mode == 4);
let _ = write!(out, "{gfa}");
}
_ => {} }
}
if !opts.dot_path.is_empty() {
let dot = graph.to_dot();
if let Err(e) = std::fs::write(&opts.dot_path, dot) {
eprintln!(
"[spoars::Graph::PrintDot] error: failed to write {}: {e}",
opts.dot_path
);
return ExitCode::FAILURE;
}
}
ExitCode::SUCCESS
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
match parse_args(&args) {
ParseOutcome::Run(opts) => run(opts),
ParseOutcome::ExitOk(message) => {
if let Some(message) = message {
print!("{message}");
}
ExitCode::SUCCESS
}
ParseOutcome::ExitErr(message) => {
if let Some(message) = message {
eprintln!("{message}");
}
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use spoars::graph::Graph;
#[test]
fn should_force_sisd_treats_unset_empty_and_zero_as_false() {
assert!(!should_force_sisd(None));
assert!(!should_force_sisd(Some("")));
assert!(!should_force_sisd(Some("0")));
}
#[test]
fn should_force_sisd_treats_any_other_value_as_true() {
assert!(should_force_sisd(Some("1")));
assert!(should_force_sisd(Some("true")));
assert!(should_force_sisd(Some("yes")));
assert!(should_force_sisd(Some("anything")));
}
#[test]
fn reverse_complement_reverses_and_complements_preserving_case() {
assert_eq!(reverse_complement(b"AACC"), b"GGTT");
assert_eq!(reverse_complement(b"ACGT"), b"ACGT"); assert_eq!(reverse_complement(b"acgt"), b"acgt"); assert_eq!(reverse_complement(b"N"), b"N"); }
#[test]
fn strand_flip_keeps_base_and_quality_index_aligned() {
let seq: &[u8] = b"AACCG";
let qual: &[u8] = b"IH#5!"; let rev_seq = reverse_complement(seq);
let rev_qual: Vec<u8> = qual.iter().rev().copied().collect();
assert_eq!(rev_seq.len(), rev_qual.len());
let n = seq.len();
for j in 0..n {
let orig = n - 1 - j;
assert_eq!(
rev_seq[j],
complement_base(seq[orig]),
"position {j}: base must be the complement of original base at {orig}"
);
assert_eq!(
rev_qual[j], qual[orig],
"position {j}: quality must equal original quality at {orig}"
);
}
}
#[test]
fn strand_flip_quality_reversal_yields_correct_graph_edge_weights() {
let seq: &[u8] = b"ACG";
let qual: &[u8] = b"5?I"; let rev_seq = reverse_complement(seq); let rev_qual: Vec<u8> = qual.iter().rev().copied().collect();
let mut correct = Graph::new();
correct
.add_alignment_quality(&[], &rev_seq, &rev_qual)
.expect("add_alignment_quality (reversed quals) must succeed");
let gfa_correct = correct.to_gfa(&["s".to_string()], &[], false);
assert!(
gfa_correct.contains("ew:f:70") && gfa_correct.contains("ew:f:50"),
"expected reversed-quality edge weights 70 and 50 in GFA:\n{gfa_correct}"
);
let mut buggy = Graph::new();
buggy
.add_alignment_quality(&[], &rev_seq, qual)
.expect("add_alignment_quality (forward quals) must succeed");
let gfa_buggy = buggy.to_gfa(&["s".to_string()], &[], false);
assert_ne!(
gfa_correct, gfa_buggy,
"reversing the qualities must change the quality-derived edge weights; if not, the \
strand-flip quality fix is unverifiable"
);
}
}