use crate::commands::command::Command;
use crate::commands::trim::{
Adapter, OverlapAdapterLibrary, OverlapScratch, OverlapStats, QualityTrim,
count_mismatches_ci_bounded, cut_right_quality_position, detect_pe_overlap,
find_adapter_3prime, find_polyx_tail_len, load_adapter_fasta_with_names,
validate_adapter_bases,
};
use crate::commands::utils::{
BUFFER_SIZE, PairingRule, SplitNameCheck, aggregate_errors, check_at_most_two,
check_dash_at_most_once, check_distinct_inputs, default_dash, fmt_count, open_fastq_inputs,
pull_pair_interleaved, resolve_inputs, sniff_single_input,
};
use anyhow::{Result, anyhow};
use chelae_lib::adapter_db::ALL_KITS;
use clap::Parser;
use fgoxide::io::Io;
use log::{info, warn};
use seq_io::fastq::OwnedRecord;
use seq_io::fastq::{Reader as FastqReader, Record};
use std::collections::HashMap;
use std::io::{BufWriter, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
const TAIL_KMER_LEN: usize = 16;
const FUZZY_MERGE_HAMMING: usize = 2;
const KIT_NEAR_MATCH_HAMMING: usize = 1;
const BASES: [u8; 5] = [b'A', b'C', b'G', b'T', b'N'];
const CONSENSUS_MAJORITY_FLOOR: f64 = 0.50;
const CONSENSUS_DROP_TOLERANCE: f64 = 0.10;
#[derive(Parser, Debug)]
#[command(version)]
#[clap(verbatim_doc_comment)]
pub(crate) struct Detect {
#[clap(long, short = 'i', num_args = 1..=2)]
inputs: Vec<PathBuf>,
#[clap(long, short = 'o')]
output_fasta: Option<PathBuf>,
#[clap(long, short = 'a')]
adapter_sequence: Vec<String>,
#[clap(long, short = 'f')]
adapter_fasta: Option<PathBuf>,
#[clap(long, short = 'n', default_value = "5000")]
num_detections: u64,
#[clap(long, default_value = "1000000")]
max_reads: u64,
#[clap(long, default_value = "20")]
min_detections_for_report: u64,
#[clap(long, default_value = "0.05")]
min_fraction: f64,
#[clap(long, default_value = "8")]
min_tail_length: usize,
#[clap(long, default_value = "30")]
overlap_min_length: usize,
#[clap(long, default_value = "0.10")]
overlap_max_mismatch_rate: f64,
#[clap(long, default_value = "64")]
overlap_diagnostic_length: usize,
#[clap(long, default_value = "10")]
adapter_min_length: usize,
#[clap(long, default_value = "0.125")]
adapter_mismatch_rate: f64,
#[clap(long, default_value = "10")]
trim_polyg: usize,
#[clap(long, default_value = "5")]
trim_polyx: usize,
#[clap(long, default_value = "4:20")]
quality_trim: QualityTrimSetting,
}
impl Detect {
fn validate(&self) -> Result<()> {
let mut errors: Vec<String> = Vec::new();
if let Err(e) = resolve_inputs(&self.inputs, std::io::stdin().is_terminal()) {
errors.push(e.to_string());
}
check_dash_at_most_once(&self.inputs, "--inputs", &mut errors);
check_at_most_two(&self.inputs, "--inputs", &mut errors);
check_distinct_inputs(&self.inputs, &mut errors);
for path in &self.inputs {
if path.as_os_str() != "-" && !path.exists() {
errors.push(format!("Input file {path:?} does not exist."));
}
}
if let Some(path) = &self.output_fasta
&& let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
errors.push(format!(
"Output FASTA parent directory {parent:?} does not exist (for {path:?})."
));
}
if self.inputs.len() == 2 {
if !self.adapter_sequence.is_empty() {
errors.push(
"--adapter-sequence is not used in paired-end mode (PE detection \
discovers adapters via overlap)."
.to_string(),
);
}
if self.adapter_fasta.is_some() {
errors.push(
"--adapter-fasta is not used in paired-end mode (PE detection \
discovers adapters via overlap)."
.to_string(),
);
}
}
for seq in &self.adapter_sequence {
if seq.is_empty() {
errors.push("--adapter-sequence values must not be empty.".to_string());
continue;
}
if let Err(msg) = validate_adapter_bases(seq.as_bytes()) {
errors.push(format!("--adapter-sequence {seq:?}: {msg}"));
}
if seq.len() < self.adapter_min_length {
errors.push(format!(
"--adapter-sequence {seq:?} is {} bp, shorter than --adapter-min-length ({}); \
it could never match. Lower --adapter-min-length or supply a longer sequence.",
seq.len(),
self.adapter_min_length,
));
}
}
if !(0.0..=1.0).contains(&self.min_fraction) {
errors.push(format!("--min-fraction must be in 0.0..=1.0, got {}.", self.min_fraction));
}
if !(0.0..=1.0).contains(&self.overlap_max_mismatch_rate) {
errors.push(format!(
"--overlap-max-mismatch-rate must be in 0.0..=1.0, got {}.",
self.overlap_max_mismatch_rate
));
}
if !(0.0..=1.0).contains(&self.adapter_mismatch_rate) {
errors.push(format!(
"--adapter-mismatch-rate must be in 0.0..=1.0, got {}.",
self.adapter_mismatch_rate
));
}
if self.num_detections == 0 {
errors.push("--num-detections must be at least 1.".to_string());
}
if self.max_reads == 0 {
errors.push("--max-reads must be at least 1.".to_string());
}
if self.min_detections_for_report == 0 {
errors.push("--min-detections-for-report must be at least 1.".to_string());
}
if self.min_detections_for_report > self.num_detections {
errors.push(format!(
"--min-detections-for-report ({}) cannot exceed --num-detections ({}) — the \
sampler would never reach the floor.",
self.min_detections_for_report, self.num_detections,
));
}
if self.min_detections_for_report > self.max_reads {
errors.push(format!(
"--min-detections-for-report ({}) cannot exceed --max-reads ({}) — the \
sampler would scan all input and still bail under the floor.",
self.min_detections_for_report, self.max_reads,
));
}
if self.min_tail_length == 0 {
errors.push("--min-tail-length must be at least 1.".to_string());
}
if self.overlap_min_length == 0 {
errors.push("--overlap-min-length must be at least 1.".to_string());
}
if self.overlap_diagnostic_length == 0 {
errors.push("--overlap-diagnostic-length must be at least 1.".to_string());
}
if self.adapter_min_length == 0 {
errors.push("--adapter-min-length must be at least 1.".to_string());
}
aggregate_errors(errors)
}
fn run_pe(&self, mut pairs: PairSource) -> Result<()> {
let empty_lib = OverlapAdapterLibrary::default();
let mut stats = OverlapStats::new(None);
let mut overlap_scratch = OverlapScratch::default();
let mut r1_kmers: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
let mut r2_kmers: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
let mut detections: u64 = 0;
let mut reads_scanned: u64 = 0;
let mut overlap_hits: u64 = 0;
loop {
if detections >= self.num_detections || reads_scanned >= self.max_reads {
break;
}
let Some((r1, r2)) = pairs.next_pair()? else { break };
reads_scanned += 1;
let r1_full = r1.seq();
let r2_full = r2.seq();
let r1_end = effective_trimmed_len(
r1_full,
r1.qual(),
self.trim_polyg,
self.trim_polyx,
self.quality_trim.as_option(),
);
let r2_end = effective_trimmed_len(
r2_full,
r2.qual(),
self.trim_polyg,
self.trim_polyx,
self.quality_trim.as_option(),
);
let r1_seq = &r1_full[..r1_end];
let r2_seq = &r2_full[..r2_end];
let center = stats.center_shift(r2_seq.len());
let result = detect_pe_overlap(
r1_seq,
r2_seq,
self.overlap_min_length,
self.overlap_max_mismatch_rate,
self.overlap_diagnostic_length,
&empty_lib,
center,
false,
None,
&mut overlap_scratch,
);
stats.observe(result, false);
let Some(insert) = result.inferred_insert else { continue };
overlap_hits += 1;
if insert >= r1_seq.len() || insert >= r2_seq.len() {
continue;
}
let r1_tail = &r1_seq[insert..];
let r2_tail = &r2_seq[insert..];
if r1_tail.len() < self.min_tail_length || r2_tail.len() < self.min_tail_length {
continue;
}
push_tail_kmer(&mut r1_kmers, r1_tail);
push_tail_kmer(&mut r2_kmers, r2_tail);
detections += 1;
}
if detections == 0 {
return Err(anyhow!(
"No usable PE adapter detections in {} pair(s) scanned ({} overlap hits, but \
none with a post-template tail of >= {} bp on both mates). The library may \
have inserts longer than the read length on every pair (no adapter \
readthrough), or the library may have very low readthrough — try increasing \
`--max-reads`, lowering `--overlap-min-length`, or (if overlap hits > 0) \
lowering `--min-tail-length`.",
fmt_count(reads_scanned),
fmt_count(overlap_hits),
self.min_tail_length,
));
}
if detections < self.min_detections_for_report {
return Err(anyhow!(
"Only {} usable detections after scanning {} pair(s); below the \
`--min-detections-for-report` floor of {}. Reporting fractions from this \
small a sample would mislead. Increase `--max-reads`, lower \
`--min-detections-for-report` if you accept a noisier estimate, or rerun on \
a larger input.",
fmt_count(detections),
fmt_count(reads_scanned),
fmt_count(self.min_detections_for_report),
));
}
if detections < self.num_detections {
warn!(
"Reached EOF / --max-reads with only {} of the requested {} detections; \
reported fractions may be noisier than expected.",
fmt_count(detections),
fmt_count(self.num_detections),
);
}
let r1_hits = aggregate_kmers(r1_kmers, FUZZY_MERGE_HAMMING);
let r2_hits = aggregate_kmers(r2_kmers, FUZZY_MERGE_HAMMING);
let r1_reported = filter_min_fraction(&r1_hits, detections, self.min_fraction);
let r2_reported = filter_min_fraction(&r2_hits, detections, self.min_fraction);
let r1_annot = annotate_pe_hits(&r1_reported, KitMate::R1);
let r2_annot = annotate_pe_hits(&r2_reported, KitMate::R2);
emit_pe_report(detections, reads_scanned, overlap_hits, &r1_annot, &r2_annot);
if let Some(path) = &self.output_fasta {
let missing = match (r1_reported.is_empty(), r2_reported.is_empty()) {
(true, true) => Some("both R1 and R2"),
(true, false) => Some("R1"),
(false, true) => Some("R2"),
(false, false) => None,
};
if let Some(which) = missing {
return Err(anyhow!(
"No adapter reached --min-fraction ({}) on {which}. The console report \
above shows what was found. Lower --min-fraction and rerun to write a \
FASTA, or drop --output-fasta if you only want the diagnostic.",
self.min_fraction,
));
}
write_fasta(path, &pe_fasta_records(&r1_annot, &r2_annot))?;
info!("Wrote discovered adapter FASTA to {path:?}");
}
Ok(())
}
fn run_se(&self, mut records: impl Iterator<Item = Result<OwnedRecord>>) -> Result<()> {
let candidates = build_se_candidates(
&self.adapter_sequence,
&self.adapter_fasta,
self.adapter_min_length,
)?;
debug_assert!(!candidates.is_empty(), "candidate list is empty; ALL_KITS broken?");
let mut counts = vec![0u64; candidates.len()];
let mut detections: u64 = 0;
let mut reads_scanned: u64 = 0;
loop {
if detections >= self.num_detections || reads_scanned >= self.max_reads {
break;
}
let rec = match records.next() {
Some(Ok(r)) => r,
Some(Err(e)) => return Err(e),
None => break,
};
reads_scanned += 1;
let full = rec.seq();
let end = effective_trimmed_len(
full,
rec.qual(),
self.trim_polyg,
self.trim_polyx,
self.quality_trim.as_option(),
);
let seq = &full[..end];
let mut best: Option<(usize, usize)> = None;
for (idx, cand) in candidates.iter().enumerate() {
if let Some(k) = find_adapter_3prime(
seq,
&cand.adapter,
self.adapter_min_length,
self.adapter_mismatch_rate,
None,
) && best.is_none_or(|(_, bk)| k < bk)
{
best = Some((idx, k));
}
}
if let Some((idx, k)) = best {
let matched_len = (seq.len() - k).min(candidates[idx].adapter.bytes.len());
if matched_len >= self.min_tail_length {
counts[idx] += 1;
detections += 1;
}
}
}
if detections == 0 {
return Err(anyhow!(
"No candidate adapter matched any read in {} record(s) scanned. The library \
may be free of read-through, or the configured candidates may not include \
the adapter actually present — try supplying it via `--adapter-sequence` or \
`--adapter-fasta`, or (if you have paired-end reads) rerun in PE mode where \
detect discovers novel adapters via overlap.",
fmt_count(reads_scanned),
));
}
if detections < self.min_detections_for_report {
return Err(anyhow!(
"Only {} candidate-matched reads after scanning {} record(s); below the \
`--min-detections-for-report` floor of {}. Reporting fractions from this \
small a sample would mislead. Increase `--max-reads`, lower \
`--min-detections-for-report` if you accept a noisier estimate, or rerun on \
a larger input.",
fmt_count(detections),
fmt_count(reads_scanned),
fmt_count(self.min_detections_for_report),
));
}
if detections < self.num_detections {
warn!(
"Reached EOF / --max-reads with only {} of the requested {} detections.",
fmt_count(detections),
fmt_count(self.num_detections),
);
}
let mut hits: Vec<Hit> = candidates
.iter()
.zip(counts.iter())
.filter(|&(_, &c)| c > 0)
.map(|(c, &count)| Hit {
name: Some(c.name.clone()),
seq: c.adapter.bytes.clone(),
count,
})
.collect();
hits.sort_by_key(|h| std::cmp::Reverse(h.count));
let reported = filter_min_fraction(&hits, detections, self.min_fraction);
let annotated = annotate_se_hits(&reported);
emit_se_report(detections, reads_scanned, &annotated);
if let Some(path) = &self.output_fasta {
if reported.is_empty() {
return Err(anyhow!(
"No candidate adapter reached --min-fraction ({}). The console report \
above shows the per-candidate counts. Lower --min-fraction and rerun to \
write a FASTA, or drop --output-fasta if you only want the diagnostic.",
self.min_fraction,
));
}
let records: Vec<(String, &[u8])> = reported
.iter()
.map(|h| {
(h.name.clone().unwrap_or_else(|| "adapter".to_string()), h.seq.as_slice())
})
.collect();
write_fasta(path, &records)?;
info!("Wrote winning adapter FASTA to {path:?}");
}
Ok(())
}
}
impl Command for Detect {
fn execute(&self) -> Result<()> {
self.validate()?;
let inputs = default_dash(&self.inputs);
info!(
"Detecting adapters in {} input file(s) (target {} detections, hard cap {} reads)",
inputs.len(),
fmt_count(self.num_detections),
fmt_count(self.max_reads),
);
let mut readers = open_fastq_inputs(&inputs)?;
match readers.len() {
1 => {
let sniffed = sniff_single_input(readers.pop().unwrap())?;
if sniffed.interleaved {
if !self.adapter_sequence.is_empty() || self.adapter_fasta.is_some() {
return Err(anyhow!(
"--adapter-sequence/--adapter-fasta are not used in paired-end mode \
(PE detection discovers adapters via overlap); input was sniffed as \
interleaved paired-end."
));
}
let rule = sniffed
.pairing_rule
.expect("interleaved input always selects a rule at sniff time");
self.run_pe(PairSource::Interleaved {
records: Box::new(sniffed.records),
rule,
pairs_read: 0,
})
} else {
self.run_se(sniffed.records)
}
}
2 => {
let r2 = readers.pop().unwrap();
let r1 = readers.pop().unwrap();
self.run_pe(PairSource::Split {
r1,
r2,
name_check: SplitNameCheck::Pending,
pairs_read: 0,
})
}
n => Err(anyhow!("Expected 1 or 2 inputs; got {n}.")),
}
}
}
#[allow(clippy::large_enum_variant)]
enum PairSource {
Split {
r1: FastqReader<Box<dyn Read + Send>>,
r2: FastqReader<Box<dyn Read + Send>>,
name_check: SplitNameCheck,
pairs_read: u64,
},
Interleaved {
records: Box<dyn Iterator<Item = Result<OwnedRecord>>>,
rule: PairingRule,
pairs_read: u64,
},
}
impl PairSource {
fn next_pair(&mut self) -> Result<Option<(OwnedRecord, OwnedRecord)>> {
match self {
PairSource::Split { r1, r2, name_check, pairs_read } => {
let rec1 = match r1.next() {
Some(Ok(rec)) => rec.to_owned_record(),
Some(Err(e)) => return Err(anyhow!("R1 FASTQ read error: {e}")),
None => match r2.next() {
None => return Ok(None),
Some(Ok(_)) => {
return Err(anyhow!("R1 exhausted before R2 (inputs out of sync)"));
}
Some(Err(e)) => return Err(anyhow!("R2 FASTQ read error: {e}")),
},
};
let rec2 = match r2.next() {
Some(Ok(rec)) => rec.to_owned_record(),
Some(Err(e)) => return Err(anyhow!("R2 FASTQ read error: {e}")),
None => return Err(anyhow!("R2 exhausted before R1 (inputs out of sync)")),
};
*pairs_read += 1;
name_check.check(&rec1.head, &rec2.head, *pairs_read)?;
Ok(Some((rec1, rec2)))
}
PairSource::Interleaved { records, rule, pairs_read } => {
*pairs_read += 1;
pull_pair_interleaved(records, *rule, *pairs_read)
}
}
}
}
#[derive(Debug, Clone, Copy)]
enum QualityTrimSetting {
Off,
On(QualityTrim),
}
impl QualityTrimSetting {
fn as_option(self) -> Option<QualityTrim> {
match self {
Self::Off => None,
Self::On(qt) => Some(qt),
}
}
}
impl FromStr for QualityTrimSetting {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let lower = s.to_ascii_lowercase();
if matches!(lower.as_str(), "off" | "none" | "no") {
Ok(Self::Off)
} else {
QualityTrim::from_str(s).map(Self::On)
}
}
}
#[derive(Debug, Clone)]
struct Hit {
name: Option<String>,
seq: Vec<u8>,
count: u64,
}
struct Candidate {
name: String,
adapter: Adapter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KitMatch {
Exact {
kit: &'static str,
kit_seq: &'static [u8],
},
Fuzzy {
kit: &'static str,
kit_seq: &'static [u8],
mismatches: usize,
},
}
impl KitMatch {
fn kit(&self) -> &'static str {
match self {
KitMatch::Exact { kit, .. } | KitMatch::Fuzzy { kit, .. } => kit,
}
}
fn kit_seq(&self) -> &'static [u8] {
match self {
KitMatch::Exact { kit_seq, .. } | KitMatch::Fuzzy { kit_seq, .. } => kit_seq,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KitMate {
R1,
R2,
}
struct AnnotatedHit<'a> {
hit: &'a Hit,
mate: Option<&'static str>,
kit_match: Option<KitMatch>,
}
#[derive(Default, Debug, Clone)]
struct TailAccumulator {
count: u64,
base_counts: Vec<[u64; 5]>,
}
impl TailAccumulator {
fn base_to_slot(b: u8) -> usize {
match b.to_ascii_uppercase() {
b'A' => 0,
b'C' => 1,
b'G' => 2,
b'T' => 3,
_ => 4,
}
}
fn observe(&mut self, tail: &[u8]) {
self.count += 1;
if self.base_counts.len() < tail.len() {
self.base_counts.resize(tail.len(), [0; 5]);
}
for (i, &b) in tail.iter().enumerate() {
self.base_counts[i][Self::base_to_slot(b)] += 1;
}
}
fn merge(&mut self, other: &TailAccumulator) {
self.count += other.count;
if self.base_counts.len() < other.base_counts.len() {
self.base_counts.resize(other.base_counts.len(), [0; 5]);
}
for (i, oc) in other.base_counts.iter().enumerate() {
for (s, &v) in oc.iter().enumerate() {
self.base_counts[i][s] += v;
}
}
}
fn consensus(&self, min_coverage: u64) -> Vec<u8> {
let mut out = Vec::with_capacity(self.base_counts.len());
let mut baseline: Option<f64> = None;
for col in &self.base_counts {
let total: u64 = col.iter().sum();
if total < min_coverage {
break;
}
let (best, &best_count) = col.iter().enumerate().max_by_key(|&(_, &v)| v).unwrap();
let majority = best_count as f64 / total as f64;
if majority < CONSENSUS_MAJORITY_FLOOR {
break;
}
if let Some(b) = baseline
&& majority < b - CONSENSUS_DROP_TOLERANCE
{
break;
}
out.push(BASES[best]);
baseline = Some(match baseline {
Some(b) => b.min(majority),
None => majority,
});
}
out
}
}
fn effective_trimmed_len(
seq: &[u8],
qual: &[u8],
polyg_min_run: usize,
polyx_min_run: usize,
quality_trim: Option<QualityTrim>,
) -> usize {
let mut end = seq.len();
if polyg_min_run > 0 {
let tail = find_polyx_tail_len(&seq[..end], b'G');
if tail >= polyg_min_run {
end -= tail;
}
}
if polyx_min_run > 0 {
let tail = [b'A', b'C', b'T']
.iter()
.map(|&x| find_polyx_tail_len(&seq[..end], x))
.max()
.unwrap_or(0);
if tail >= polyx_min_run {
end -= tail;
}
}
if let Some(qt) = quality_trim
&& let Some(cut_at) = cut_right_quality_position(&qual[..end], qt.window, qt.threshold)
{
end = end.min(cut_at);
}
end
}
fn push_tail_kmer(buckets: &mut HashMap<Vec<u8>, TailAccumulator>, tail: &[u8]) {
let n = tail.len().min(TAIL_KMER_LEN);
let key = tail[..n].to_ascii_uppercase();
buckets.entry(key).or_default().observe(tail);
}
fn aggregate_kmers(buckets: HashMap<Vec<u8>, TailAccumulator>, max_hamming: usize) -> Vec<Hit> {
let mut entries: Vec<(Vec<u8>, TailAccumulator)> = buckets.into_iter().collect();
entries.sort_by(|a, b| {
b.1.count
.cmp(&a.1.count)
.then_with(|| b.0.len().cmp(&a.0.len()))
.then_with(|| a.0.cmp(&b.0))
});
let mut primaries: Vec<(Vec<u8>, TailAccumulator, u64)> = Vec::new();
for (kmer, acc) in entries {
let mut merged = false;
for (prim_kmer, prim_acc, _) in primaries.iter_mut() {
let common = prim_kmer.len().min(kmer.len());
if common == 0 {
continue;
}
let mm =
count_mismatches_ci_bounded(&prim_kmer[..common], &kmer[..common], max_hamming);
if mm <= max_hamming {
prim_acc.merge(&acc);
merged = true;
break;
}
}
if !merged {
let own = acc.count;
primaries.push((kmer, acc, own));
}
}
primaries.sort_by(|a, b| b.1.count.cmp(&a.1.count).then_with(|| a.0.cmp(&b.0)));
primaries
.into_iter()
.filter_map(|(_kmer, acc, primary_count)| {
let floor = (primary_count / 20).max(5);
let seq = acc.consensus(floor);
if seq.len() < TAIL_KMER_LEN {
return None;
}
Some(Hit { name: None, seq, count: acc.count })
})
.collect()
}
fn filter_min_fraction(hits: &[Hit], total: u64, min_fraction: f64) -> Vec<Hit> {
if total == 0 {
return Vec::new();
}
let cutoff = (min_fraction * total as f64).ceil() as u64;
hits.iter().filter(|h| h.count >= cutoff.max(1)).cloned().collect()
}
fn classify_against_kits(seq: &[u8], mate: KitMate) -> Option<KitMatch> {
let n = seq.len().min(TAIL_KMER_LEN);
if n == 0 {
return None;
}
let mut best: Option<(usize, &'static str, &'static [u8])> = None;
for kit in ALL_KITS {
let kit_seq: &'static [u8] = match mate {
KitMate::R1 => kit.seq_r1,
KitMate::R2 => match kit.seq_r2 {
Some(s) => s,
None => continue,
},
};
let k = n.min(kit_seq.len());
if k == 0 {
continue;
}
let limit = best.map(|(m, _, _)| m).unwrap_or(KIT_NEAR_MATCH_HAMMING);
let mm = count_mismatches_ci_bounded(&seq[..k], &kit_seq[..k], limit);
if mm <= KIT_NEAR_MATCH_HAMMING && best.is_none_or(|(m, _, _)| mm < m) {
best = Some((mm, kit.name, kit_seq));
}
}
best.map(|(mm, name, kit_seq)| {
if mm == 0 {
KitMatch::Exact { kit: name, kit_seq }
} else {
KitMatch::Fuzzy { kit: name, kit_seq, mismatches: mm }
}
})
}
fn build_se_candidates(
adapter_sequence: &[String],
adapter_fasta: &Option<PathBuf>,
adapter_min_length: usize,
) -> Result<Vec<Candidate>> {
let mut out: Vec<Candidate> = Vec::new();
for kit in ALL_KITS {
out.push(Candidate {
name: kit.name.to_string(),
adapter: Adapter::new(kit.seq_r1.to_ascii_uppercase()),
});
if let Some(s2) = kit.seq_r2 {
let r2_name = format!("{}_r2", kit.name);
if s2 != kit.seq_r1 {
out.push(Candidate {
name: r2_name,
adapter: Adapter::new(s2.to_ascii_uppercase()),
});
}
}
}
for (i, s) in adapter_sequence.iter().enumerate() {
out.push(Candidate {
name: format!("user_{}", i + 1),
adapter: Adapter::new(s.as_bytes().to_ascii_uppercase()),
});
}
if let Some(path) = adapter_fasta {
let entries = load_adapter_fasta_with_names(path)?;
for (name, seq) in entries {
if seq.len() < adapter_min_length {
return Err(anyhow!(
"--adapter-fasta entry {name:?} ({path:?}) is {} bp, shorter than \
--adapter-min-length ({adapter_min_length}); it could never match. Lower \
--adapter-min-length or edit the FASTA.",
seq.len(),
));
}
out.push(Candidate { name, adapter: Adapter::new(seq.to_ascii_uppercase()) });
}
}
Ok(out)
}
fn annotate_pe_hits<'a>(hits: &'a [Hit], mate: KitMate) -> Vec<AnnotatedHit<'a>> {
let label = match mate {
KitMate::R1 => "R1",
KitMate::R2 => "R2",
};
hits.iter()
.map(|h| AnnotatedHit {
hit: h,
mate: Some(label),
kit_match: classify_against_kits(&h.seq, mate),
})
.collect()
}
fn annotate_se_hits<'a>(hits: &'a [Hit]) -> Vec<AnnotatedHit<'a>> {
hits.iter()
.map(|h| {
let kit_match = classify_against_kits(&h.seq, KitMate::R1)
.or_else(|| classify_against_kits(&h.seq, KitMate::R2));
AnnotatedHit { hit: h, mate: None, kit_match }
})
.collect()
}
fn format_consensus_with_kit_marker(consensus: &[u8], kit_seq: Option<&[u8]>) -> String {
let kit_len = kit_seq.map(|k| k.len()).unwrap_or(consensus.len());
let split = consensus.len().min(kit_len);
let mut out = String::with_capacity(consensus.len());
for &b in &consensus[..split] {
out.push(b.to_ascii_uppercase() as char);
}
for &b in &consensus[split..] {
out.push(b.to_ascii_lowercase() as char);
}
out
}
fn emit_pe_report(
detections: u64,
reads_scanned: u64,
overlap_hits: u64,
r1: &[AnnotatedHit<'_>],
r2: &[AnnotatedHit<'_>],
) {
info!("chelae detect complete (paired-end):");
info!(
" scanned: {} pair(s) ({} overlap hits, {} usable detections)",
fmt_count(reads_scanned),
fmt_count(overlap_hits),
fmt_count(detections),
);
emit_matched_kits_section(r1.iter().chain(r2.iter()));
info!(" Full-length discovered consensus (uppercase = matches kit, lowercase = extension):");
emit_full_length_rows(r1, detections);
emit_full_length_rows(r2, detections);
}
fn emit_se_report(detections: u64, reads_scanned: u64, reported: &[AnnotatedHit<'_>]) {
info!("chelae detect complete (single-end):");
info!(
" scanned: {} read(s); {} matched a candidate adapter",
fmt_count(reads_scanned),
fmt_count(detections),
);
emit_matched_kits_section(reported.iter());
info!(" Candidate adapter(s) above --min-fraction:");
emit_full_length_rows(reported, detections);
}
fn emit_matched_kits_section<'a, I>(hits: I)
where
I: IntoIterator<Item = &'a AnnotatedHit<'a>>,
{
let mut rows: Vec<(Option<&'static str>, &'static str, &'static [u8], bool)> = Vec::new();
for ah in hits {
if let Some(km) = ah.kit_match {
let exact = matches!(km, KitMatch::Exact { .. });
let row = (ah.mate, km.kit(), km.kit_seq(), exact);
if !rows.iter().any(|r| r.0 == row.0 && r.1 == row.1) {
rows.push(row);
}
}
}
if rows.is_empty() {
info!(" Matched kit(s): (none — discovered adapter does not resemble any known kit)");
return;
}
info!(" Matched kit(s):");
for (mate, kit, kit_seq, exact) in rows {
let mate_prefix = match mate {
Some(m) => format!("{m}: "),
None => String::new(),
};
let exactness = if exact { "exact" } else { "fuzzy" };
info!(" {mate_prefix}{kit} ({exactness}) — {}", String::from_utf8_lossy(kit_seq),);
}
}
fn emit_full_length_rows(hits: &[AnnotatedHit<'_>], total: u64) {
if hits.is_empty() {
info!(" (no candidate above the --min-fraction cutoff)");
return;
}
for (i, ah) in hits.iter().enumerate() {
let pct = (ah.hit.count as f64 * 100.0) / total.max(1) as f64;
let kit_seq = ah.kit_match.as_ref().map(|km| km.kit_seq());
let display = format_consensus_with_kit_marker(&ah.hit.seq, kit_seq);
let kit_note = match ah.kit_match {
Some(KitMatch::Exact { kit, .. }) => format!(" matches kit {kit}"),
Some(KitMatch::Fuzzy { kit, mismatches, .. }) => {
format!(" near kit {kit} ({mismatches} mm in first {TAIL_KMER_LEN} bp)")
}
None => " no kit match".to_string(),
};
let mate_label = match (ah.mate, ah.hit.name.as_deref()) {
(Some(m), _) => format!("{m} [{}]: ", i + 1),
(None, Some(name)) => format!("[{}] {name}: ", i + 1),
(None, None) => format!("[{}] ", i + 1),
};
info!(
" {mate_label}{display} count={} ({:.2}%){kit_note}",
fmt_count(ah.hit.count),
pct,
);
}
}
fn write_fasta(path: &Path, records: &[(String, &[u8])]) -> Result<()> {
let to_stdout = path.as_os_str() == "-";
let mut w: Box<dyn Write> = if to_stdout {
Box::new(BufWriter::new(std::io::stdout()))
} else {
Box::new(
Io::new(5, BUFFER_SIZE)
.new_writer(path)
.map_err(|e| anyhow!("Failed to create {path:?}: {e}"))?,
)
};
let written = records
.iter()
.try_for_each(|(name, seq)| {
writeln!(w, ">{name}")?;
w.write_all(seq)?;
writeln!(w)
})
.and_then(|()| w.flush());
match written {
Err(e) if to_stdout && e.kind() == std::io::ErrorKind::BrokenPipe => {
info!("stdout closed by downstream reader; FASTA output stopped early");
Ok(())
}
Err(e) => Err(anyhow!("Failed to write {path:?}: {e}")),
Ok(()) => Ok(()),
}
}
fn pe_fasta_records<'a>(
r1: &'a [AnnotatedHit<'a>],
r2: &'a [AnnotatedHit<'a>],
) -> Vec<(String, &'a [u8])> {
let mut out: Vec<(String, &[u8])> = Vec::with_capacity(r1.len() + r2.len());
push_named(&mut out, r1, "r1_adapter");
push_named(&mut out, r2, "r2_adapter");
out
}
fn push_named<'a>(out: &mut Vec<(String, &'a [u8])>, hits: &'a [AnnotatedHit<'a>], prefix: &str) {
match hits {
[] => {}
[only] => out.push((prefix.to_string(), fasta_seq_for(only))),
_ => {
for (i, ah) in hits.iter().enumerate() {
out.push((format!("{prefix}_{}", i + 1), fasta_seq_for(ah)));
}
}
}
}
fn fasta_seq_for<'a>(ah: &'a AnnotatedHit<'a>) -> &'a [u8] {
match ah.kit_match {
Some(km) => km.kit_seq(),
None => &ah.hit.seq,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chelae_lib::adapter_db::{NEXTERA, TRUSEQ};
use fgoxide::io::Io;
use std::fs;
use tempfile::TempDir;
fn bucket_map(entries: &[(&[u8], u64)]) -> HashMap<Vec<u8>, TailAccumulator> {
let mut out: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
for &(seq, count) in entries {
let n = seq.len().min(TAIL_KMER_LEN);
let key = seq[..n].to_vec();
let acc = out.entry(key).or_default();
for _ in 0..count {
acc.observe(seq);
}
}
out
}
fn rc(seq: &[u8]) -> Vec<u8> {
seq.iter()
.rev()
.map(|&b| match b {
b'A' => b'T',
b'C' => b'G',
b'G' => b'C',
b'T' => b'A',
_ => b'N',
})
.collect()
}
fn template_of_len(n: usize) -> Vec<u8> {
let bases = b"ACGT";
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
(0..n)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
bases[(state as usize) & 0x3]
})
.collect()
}
fn pe_pair(template: &[u8], tail_r1: &[u8], tail_r2: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut r1 = template.to_vec();
r1.extend_from_slice(tail_r1);
let mut r2 = rc(template);
r2.extend_from_slice(tail_r2);
(r1, r2)
}
fn write_fq(tmp: &TempDir, name: &str, records: &[(String, Vec<u8>)]) -> PathBuf {
let path = tmp.path().join(format!("{name}.fq"));
let mut lines: Vec<String> = Vec::with_capacity(records.len() * 4);
for (id, seq) in records {
lines.push(format!("@{id}"));
lines.push(String::from_utf8(seq.clone()).unwrap());
lines.push("+".to_string());
lines.push("I".repeat(seq.len()));
}
Io::default().write_lines(&path, &lines).unwrap();
path
}
fn make_detect(inputs: Vec<PathBuf>, output_fasta: Option<PathBuf>) -> Detect {
Detect {
inputs,
output_fasta,
adapter_sequence: vec![],
adapter_fasta: None,
num_detections: 50,
max_reads: 10_000,
min_fraction: 0.05,
min_tail_length: 8,
min_detections_for_report: 5,
overlap_min_length: 30,
overlap_max_mismatch_rate: 0.10,
overlap_diagnostic_length: 64,
adapter_min_length: 10,
adapter_mismatch_rate: 0.125,
trim_polyg: 10,
trim_polyx: 5,
quality_trim: QualityTrimSetting::On(QualityTrim { window: 4, threshold: 20 }),
}
}
#[test]
fn aggregate_kmers_merges_within_hamming() {
let input = bucket_map(&[
(b"AGATCGGAAGAGCACA".as_slice(), 100),
(b"TGATCGGAAGAGCACA".as_slice(), 5), (b"CTGTCTCTTATACACA".as_slice(), 50),
]);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 2, "near-identical entry should merge into primary");
assert_eq!(out[0].seq, b"AGATCGGAAGAGCACA");
assert_eq!(out[0].count, 105);
assert_eq!(out[1].seq, b"CTGTCTCTTATACACA");
assert_eq!(out[1].count, 50);
}
#[test]
fn aggregate_kmers_keeps_unrelated_entries_distinct() {
let input = bucket_map(&[
(b"AGATCGGAAGAGCACA".as_slice(), 100),
(b"CTGTCTCTTATACACA".as_slice(), 50),
(b"GGGGGGGGGGGGGGGG".as_slice(), 25),
]);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 3);
let counts: Vec<u64> = out.iter().map(|h| h.count).collect();
assert_eq!(counts, vec![100, 50, 25]);
}
#[test]
fn aggregate_kmers_merges_at_boundary() {
let input = bucket_map(&[
(b"AAAAAAAAAAAAAAAA".as_slice(), 100),
(b"CCAAAAAAAAAAAAAA".as_slice(), 10),
]);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 1);
assert_eq!(out[0].count, 110);
assert_eq!(out[0].seq, b"AAAAAAAAAAAAAAAA");
}
#[test]
fn aggregate_kmers_folds_shorter_prefix_into_full_primary() {
let input = bucket_map(&[
(b"AGATCGGAAGAGCACA".as_slice(), 100), (b"AGATCGGAAGAGC".as_slice(), 7), (b"AGATCGGAAGAGCAC".as_slice(), 4), ]);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 1, "shorter prefixes should fold into the longer primary");
assert_eq!(out[0].seq, b"AGATCGGAAGAGCACA");
assert_eq!(out[0].count, 111);
}
#[test]
fn aggregate_kmers_keeps_beyond_boundary() {
let input = bucket_map(&[
(b"AAAAAAAAAAAAAAAA".as_slice(), 100),
(b"CCCAAAAAAAAAAAAA".as_slice(), 10),
]);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 2);
}
#[test]
fn aggregate_kmers_extends_consensus_past_kmer_when_supported() {
let tail_long = b"AGATCGGAAGAGCACACGTCTGAACT";
let mut input: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
let mut acc = TailAccumulator::default();
for _ in 0..100 {
acc.observe(tail_long);
}
input.insert(tail_long[..16].to_vec(), acc);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 1);
assert_eq!(out[0].seq, tail_long);
assert_eq!(out[0].count, 100);
}
#[test]
fn aggregate_kmers_truncates_consensus_where_coverage_drops() {
let mut acc = TailAccumulator::default();
for _ in 0..100 {
acc.observe(b"AGATCGGAAGAGCACA");
}
for _ in 0..3 {
acc.observe(b"AGATCGGAAGAGCACATAILBASES");
}
let mut input: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
input.insert(b"AGATCGGAAGAGCACA".to_vec(), acc);
let out = aggregate_kmers(input, 2);
assert_eq!(out.len(), 1);
assert_eq!(out[0].seq, b"AGATCGGAAGAGCACA");
assert_eq!(out[0].count, 103);
}
#[test]
fn filter_min_fraction_drops_below_cutoff() {
let hits = vec![
Hit { name: None, seq: b"A".to_vec(), count: 600 },
Hit { name: None, seq: b"B".to_vec(), count: 300 },
Hit { name: None, seq: b"C".to_vec(), count: 50 }, Hit { name: None, seq: b"D".to_vec(), count: 49 }, ];
let out = filter_min_fraction(&hits, 999, 0.05);
assert_eq!(out.len(), 3);
assert_eq!(out[2].seq, b"C");
}
#[test]
fn classify_against_kits_exact_truseq_r1() {
let seq = &TRUSEQ.seq_r1[..16];
let res = classify_against_kits(seq, KitMate::R1).unwrap();
assert_eq!(res.kit(), "truseq");
assert!(matches!(res, KitMatch::Exact { .. }));
assert_eq!(res.kit_seq(), TRUSEQ.seq_r1);
}
#[test]
fn classify_against_kits_fuzzy_truseq_r2() {
let r2 = TRUSEQ.seq_r2.unwrap();
let mut seq = r2[..16].to_vec();
seq[0] = b'C'; let res = classify_against_kits(&seq, KitMate::R2).unwrap();
assert_eq!(res.kit(), "truseq");
assert!(matches!(res, KitMatch::Fuzzy { mismatches: 1, .. }));
assert_eq!(res.kit_seq(), TRUSEQ.seq_r2.unwrap());
}
#[test]
fn classify_against_kits_no_match_for_random_seq() {
let seq = b"GGGGGGGGGGGGGGGG";
let res = classify_against_kits(seq, KitMate::R1);
assert_eq!(res, None);
}
#[test]
fn classify_against_kits_nextera_symmetric() {
let seq = &NEXTERA.seq_r1[..16];
let r1_match = classify_against_kits(seq, KitMate::R1).unwrap();
assert_eq!(r1_match.kit(), "nextera");
assert!(matches!(r1_match, KitMatch::Exact { .. }));
let r2_match = classify_against_kits(seq, KitMate::R2).unwrap();
assert_eq!(r2_match.kit(), "nextera");
assert!(matches!(r2_match, KitMatch::Exact { .. }));
}
#[test]
fn build_se_candidates_includes_every_kit() {
let cands = build_se_candidates(&[], &None, 10).unwrap();
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
for kit in ALL_KITS {
assert!(names.contains(&kit.name), "kit {} missing from SE candidate list", kit.name);
}
assert!(!names.contains(&"nextera_r2"));
assert!(names.contains(&"truseq_r2"));
}
#[test]
fn build_se_candidates_adds_user_sequences() {
let user = vec!["AAAAAAAAAAAA".to_string()];
let cands = build_se_candidates(&user, &None, 10).unwrap();
assert!(cands.iter().any(|c| c.name == "user_1"));
}
#[test]
fn push_tail_kmer_uppercases_and_truncates_key_but_keeps_full_tail() {
let mut h: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
push_tail_kmer(&mut h, b"agatcggaagagcacaTAIL"); let key: Vec<u8> = h.keys().next().cloned().unwrap();
assert_eq!(key, b"AGATCGGAAGAGCACA");
let acc = &h[&key];
assert_eq!(acc.count, 1);
assert_eq!(acc.base_counts.len(), 20);
assert_eq!(acc.base_counts[16][TailAccumulator::base_to_slot(b'T')], 1);
assert_eq!(acc.base_counts[19][TailAccumulator::base_to_slot(b'L')], 1); }
#[test]
fn push_tail_kmer_short_tail_uses_full_length() {
let mut h: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
push_tail_kmer(&mut h, b"AGAT"); let acc = &h[b"AGAT".as_ref()];
assert_eq!(acc.count, 1);
assert_eq!(acc.base_counts.len(), 4);
}
#[test]
fn pe_end_to_end_identifies_truseq() {
let tmp = TempDir::new().unwrap();
let template = template_of_len(80);
let tail_r1 = &TRUSEQ.seq_r1[..20];
let tail_r2 = &TRUSEQ.seq_r2.unwrap()[..20];
let mut r1_recs: Vec<(String, Vec<u8>)> = Vec::new();
let mut r2_recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..200 {
let (r1, r2) = pe_pair(&template, tail_r1, tail_r2);
r1_recs.push((format!("pair_{i}/1"), r1));
r2_recs.push((format!("pair_{i}/2"), r2));
}
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let fasta_path = tmp.path().join("out.fa");
make_detect(vec![r1_path, r2_path], Some(fasta_path.clone())).execute().unwrap();
let fasta = fs::read_to_string(&fasta_path).unwrap();
let truseq_r1: &str = std::str::from_utf8(TRUSEQ.seq_r1).unwrap();
let truseq_r2: &str = std::str::from_utf8(TRUSEQ.seq_r2.unwrap()).unwrap();
assert!(
fasta.contains(">r1_adapter\n"),
"FASTA should label single-winner R1 hit as >r1_adapter; got:\n{fasta}"
);
assert!(
fasta.contains(truseq_r1),
"FASTA missing kit-published TruSeq R1 {truseq_r1:?}; got:\n{fasta}"
);
assert!(
fasta.contains(">r2_adapter\n"),
"FASTA should label single-winner R2 hit as >r2_adapter; got:\n{fasta}"
);
assert!(
fasta.contains(truseq_r2),
"FASTA missing kit-published TruSeq R2 {truseq_r2:?}; got:\n{fasta}"
);
}
#[test]
fn se_end_to_end_identifies_truseq() {
let tmp = TempDir::new().unwrap();
let template = template_of_len(70);
let tail = &TRUSEQ.seq_r1[..25];
let mut recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..200 {
let mut read = template.clone();
read.extend_from_slice(tail);
recs.push((format!("read_{i}"), read));
}
let in_path = write_fq(&tmp, "r1", &recs);
let fasta_path = tmp.path().join("out.fa");
make_detect(vec![in_path], Some(fasta_path.clone())).execute().unwrap();
let fasta = fs::read_to_string(&fasta_path).unwrap();
assert!(
fasta.contains(">truseq\n"),
"SE FASTA should label winning candidate as >truseq; got:\n{fasta}"
);
let truseq_r1: &str = std::str::from_utf8(TRUSEQ.seq_r1).unwrap();
assert!(
fasta.contains(truseq_r1),
"SE FASTA missing TruSeq R1 full sequence; got:\n{fasta}"
);
}
#[test]
fn interleaved_input_finds_same_adapters_as_split_input() {
let tmp = TempDir::new().unwrap();
let template = template_of_len(80);
let tail_r1 = &TRUSEQ.seq_r1[..20];
let tail_r2 = &TRUSEQ.seq_r2.unwrap()[..20];
let mut interleaved: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..200 {
let (r1, r2) = pe_pair(&template, tail_r1, tail_r2);
interleaved.push((format!("pair_{i}/1"), r1));
interleaved.push((format!("pair_{i}/2"), r2));
}
let in_path = write_fq(&tmp, "interleaved", &interleaved);
let fasta_path = tmp.path().join("out.fa");
make_detect(vec![in_path], Some(fasta_path.clone())).execute().unwrap();
let fasta = fs::read_to_string(&fasta_path).unwrap();
let truseq_r1: &str = std::str::from_utf8(TRUSEQ.seq_r1).unwrap();
let truseq_r2: &str = std::str::from_utf8(TRUSEQ.seq_r2.unwrap()).unwrap();
assert!(fasta.contains(">r1_adapter\n"), "got:\n{fasta}");
assert!(fasta.contains(truseq_r1), "missing kit-published TruSeq R1; got:\n{fasta}");
assert!(fasta.contains(">r2_adapter\n"), "got:\n{fasta}");
assert!(fasta.contains(truseq_r2), "missing kit-published TruSeq R2; got:\n{fasta}");
}
#[test]
fn interleaved_odd_record_count_errors() {
let tmp = TempDir::new().unwrap();
let recs = vec![
("pair0/1".to_string(), b"ACGTACGTAC".to_vec()),
("pair0/2".to_string(), b"TGCATGCATG".to_vec()),
("pair1/1".to_string(), b"ACGTACGTAC".to_vec()), ];
let in_path = write_fq(&tmp, "interleaved", &recs);
let err = make_detect(vec![in_path], None).execute().unwrap_err().to_string();
assert!(err.contains("odd record count"), "{err}");
}
#[test]
fn interleaved_mid_stream_name_mismatch_errors() {
let tmp = TempDir::new().unwrap();
let recs = vec![
("pair0/1".to_string(), b"ACGTACGTAC".to_vec()),
("pair0/2".to_string(), b"TGCATGCATG".to_vec()),
("pair1/1".to_string(), b"ACGTACGTAC".to_vec()),
("pair1/2".to_string(), b"TGCATGCATG".to_vec()),
("pair2/1".to_string(), b"ACGTACGTAC".to_vec()),
("mismatched".to_string(), b"TGCATGCATG".to_vec()),
];
let in_path = write_fq(&tmp, "interleaved", &recs);
let err = make_detect(vec![in_path], None).execute().unwrap_err().to_string();
assert!(err.contains("out of sync"), "{err}");
}
#[test]
fn adapter_sequence_with_sniffed_interleaved_input_errors() {
let tmp = TempDir::new().unwrap();
let recs = vec![
("pair0/1".to_string(), b"ACGTACGTAC".to_vec()),
("pair0/2".to_string(), b"TGCATGCATG".to_vec()),
];
let in_path = write_fq(&tmp, "interleaved", &recs);
let mut cmd = make_detect(vec![in_path], None);
cmd.adapter_sequence = vec!["AAAAAAAAAAAA".to_string()];
let err = cmd.execute().unwrap_err().to_string();
assert!(err.contains("not used in paired-end mode"), "{err}");
}
#[test]
fn se_detection_unchanged_for_single_end_names() {
let tmp = TempDir::new().unwrap();
let template = template_of_len(70);
let tail = &TRUSEQ.seq_r1[..25];
let mut recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..200 {
let mut read = template.clone();
read.extend_from_slice(tail);
recs.push((format!("read_{i}"), read));
}
let in_path = write_fq(&tmp, "se", &recs);
let fasta_path = tmp.path().join("out.fa");
make_detect(vec![in_path], Some(fasta_path.clone())).execute().unwrap();
let fasta = fs::read_to_string(&fasta_path).unwrap();
assert!(fasta.contains(">truseq\n"), "got:\n{fasta}");
}
#[test]
fn se_does_not_match_unrelated_kits() {
let tmp = TempDir::new().unwrap();
let template = template_of_len(70);
let tail = &TRUSEQ.seq_r1[..25];
let mut recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..200 {
let mut read = template.clone();
read.extend_from_slice(tail);
recs.push((format!("read_{i}"), read));
}
let in_path = write_fq(&tmp, "r1", &recs);
let fasta_path = tmp.path().join("out.fa");
make_detect(vec![in_path], Some(fasta_path.clone())).execute().unwrap();
let fasta = fs::read_to_string(&fasta_path).unwrap();
assert!(!fasta.contains(&format!(">{}\n", NEXTERA.name)));
}
#[test]
fn pe_validation_rejects_se_only_flags() {
let tmp = TempDir::new().unwrap();
let r1 = write_fq(&tmp, "r1", &[("x".to_string(), b"ACGT".to_vec())]);
let r2 = write_fq(&tmp, "r2", &[("x".to_string(), b"ACGT".to_vec())]);
let mut cmd = make_detect(vec![r1, r2], None);
cmd.adapter_sequence = vec!["AAAA".to_string()];
let err = cmd.execute().unwrap_err().to_string();
assert!(err.contains("--adapter-sequence is not used in paired-end mode"));
}
#[test]
fn aggregate_kmers_empty_input_returns_empty() {
let out = aggregate_kmers(HashMap::<Vec<u8>, TailAccumulator>::new(), 2);
assert!(out.is_empty());
}
#[test]
fn filter_min_fraction_zero_total_returns_empty() {
let hits = vec![Hit { name: None, seq: b"A".to_vec(), count: 0 }];
assert!(filter_min_fraction(&hits, 0, 0.5).is_empty());
}
#[test]
fn push_tail_kmer_increments_existing_entry() {
let mut h: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
push_tail_kmer(&mut h, b"AGATCGGAAGAGCACA");
push_tail_kmer(&mut h, b"AGATCGGAAGAGCACA");
push_tail_kmer(&mut h, b"agatcggaagagcaca"); assert_eq!(h[b"AGATCGGAAGAGCACA".as_ref()].count, 3);
}
#[test]
fn classify_against_kits_short_sequence_still_classifies() {
let short = &TRUSEQ.seq_r1[..10];
let res = classify_against_kits(short, KitMate::R1).unwrap();
assert_eq!(res.kit(), "truseq");
assert!(matches!(res, KitMatch::Exact { .. }));
}
#[test]
fn classify_against_kits_r2_skips_small_rna() {
let small_rna_r1_prefix: &[u8] = &chelae_lib::adapter_db::SMALL_RNA.seq_r1[..16];
let res = classify_against_kits(small_rna_r1_prefix, KitMate::R2);
let kit_name = res.map(|m| m.kit());
assert!(
kit_name != Some("small-rna"),
"R2 lookup should skip the R1-only small-rna kit; got {res:?}"
);
}
#[test]
fn build_se_candidates_preserves_fasta_record_names() {
let tmp = TempDir::new().unwrap();
let fa = tmp.path().join("extra.fa");
std::fs::write(&fa, ">foo description here\nACGTACGTACGT\n>bar\nTTTTAAAATTTT\n").unwrap();
let cands = build_se_candidates(&[], &Some(fa), 10).unwrap();
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"foo"), "expected FASTA name 'foo' in {names:?}");
assert!(names.contains(&"bar"), "expected FASTA name 'bar' in {names:?}");
let foo = cands.iter().find(|c| c.name == "foo").unwrap();
assert_eq!(foo.adapter.bytes, b"ACGTACGTACGT");
}
#[test]
fn build_se_candidates_fasta_without_header_gets_synthetic_name() {
let tmp = TempDir::new().unwrap();
let fa = tmp.path().join("noheader.fa");
std::fs::write(&fa, "ACGTACGTACGT\n>named\nTTTTAAAATTTT\n").unwrap();
let cands = build_se_candidates(&[], &Some(fa), 10).unwrap();
let names: Vec<&str> = cands.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"record_1"));
assert!(names.contains(&"named"));
}
#[test]
fn pe_r2_short_produces_clear_error() {
let tmp = TempDir::new().unwrap();
let r1_recs = vec![
("a/1".to_string(), b"ACGTACGTACGT".to_vec()),
("b/1".to_string(), b"ACGTACGTACGT".to_vec()),
];
let r2_recs = vec![("a/2".to_string(), b"ACGTACGTACGT".to_vec())];
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let err = make_detect(vec![r1_path, r2_path], None).execute().unwrap_err().to_string();
assert!(err.contains("R2 exhausted before R1"), "expected out-of-sync error; got: {err}");
}
#[test]
fn pe_r1_short_produces_clear_error() {
let tmp = TempDir::new().unwrap();
let r1_recs = vec![("a/1".to_string(), b"ACGTACGTACGT".to_vec())];
let r2_recs = vec![
("a/2".to_string(), b"ACGTACGTACGT".to_vec()),
("b/2".to_string(), b"ACGTACGTACGT".to_vec()),
];
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let err = make_detect(vec![r1_path, r2_path], None).execute().unwrap_err().to_string();
assert!(err.contains("R1 exhausted before R2"), "expected out-of-sync error; got: {err}");
}
#[test]
fn pe_fasta_records_uses_unsuffixed_name_for_single_winner() {
let r1 = [Hit { name: None, seq: b"AAAA".to_vec(), count: 10 }];
let r2 = [Hit { name: None, seq: b"CCCC".to_vec(), count: 10 }];
let r1_annot: Vec<AnnotatedHit<'_>> =
r1.iter().map(|h| AnnotatedHit { hit: h, mate: Some("R1"), kit_match: None }).collect();
let r2_annot: Vec<AnnotatedHit<'_>> =
r2.iter().map(|h| AnnotatedHit { hit: h, mate: Some("R2"), kit_match: None }).collect();
let recs = pe_fasta_records(&r1_annot, &r2_annot);
let names: Vec<&str> = recs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["r1_adapter", "r2_adapter"]);
}
#[test]
fn pe_fasta_records_suffixes_multi_winner_names() {
let r1 = [
Hit { name: None, seq: b"AAAA".to_vec(), count: 10 },
Hit { name: None, seq: b"GGGG".to_vec(), count: 5 },
];
let r1_annot: Vec<AnnotatedHit<'_>> =
r1.iter().map(|h| AnnotatedHit { hit: h, mate: Some("R1"), kit_match: None }).collect();
let recs = pe_fasta_records(&r1_annot, &[]);
let names: Vec<&str> = recs.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(names, ["r1_adapter_1", "r1_adapter_2"]);
}
#[test]
fn pe_fasta_records_emits_kit_seq_when_kit_matched() {
let consensus = {
let mut v = TRUSEQ.seq_r1.to_vec();
v.extend_from_slice(b"GGGGGG"); v
};
let r1 = [Hit { name: None, seq: consensus.clone(), count: 100 }];
let r1_annot: Vec<AnnotatedHit<'_>> = r1
.iter()
.map(|h| AnnotatedHit {
hit: h,
mate: Some("R1"),
kit_match: Some(KitMatch::Exact { kit: "truseq", kit_seq: TRUSEQ.seq_r1 }),
})
.collect();
let recs = pe_fasta_records(&r1_annot, &[]);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].0, "r1_adapter");
assert_eq!(recs[0].1, TRUSEQ.seq_r1, "kit-matched FASTA should emit kit's published seq");
assert!(
!recs[0].1.windows(6).any(|w| w == b"GGGGGG"),
"FASTA must not contain the in-sample barcode extension"
);
}
#[test]
fn pe_fasta_records_emits_consensus_when_no_kit_match() {
let novel = b"GCGCGCGCGCGCGCGCGCGCGCGCGC".to_vec();
let r1 = [Hit { name: None, seq: novel.clone(), count: 100 }];
let r1_annot: Vec<AnnotatedHit<'_>> =
r1.iter().map(|h| AnnotatedHit { hit: h, mate: Some("R1"), kit_match: None }).collect();
let recs = pe_fasta_records(&r1_annot, &[]);
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].1, novel);
}
#[test]
fn format_consensus_marks_kit_prefix_uppercase_extension_lowercase() {
let consensus = b"AGATCGGAAGAGCACATAILBASES";
let kit_seq = b"AGATCGGAAGAGCACA"; let out = format_consensus_with_kit_marker(consensus, Some(kit_seq));
assert_eq!(out, "AGATCGGAAGAGCACAtailbases");
}
#[test]
fn format_consensus_all_upper_when_no_kit() {
let consensus = b"AGATCGGAAGAGCACA";
let out = format_consensus_with_kit_marker(consensus, None);
assert_eq!(out, "AGATCGGAAGAGCACA");
}
#[test]
fn format_consensus_handles_consensus_shorter_than_kit() {
let consensus = b"AGATCGGAA";
let kit_seq = b"AGATCGGAAGAGCACACGTCTGAACTCCAGTCA";
let out = format_consensus_with_kit_marker(consensus, Some(kit_seq));
assert_eq!(out, "AGATCGGAA");
}
#[test]
fn pe_validation_reports_no_detections_clearly() {
let tmp = TempDir::new().unwrap();
let template = template_of_len(150);
let mut r1_recs: Vec<(String, Vec<u8>)> = Vec::new();
let mut r2_recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..50 {
let r1 = template[..100].to_vec();
let r2 = rc(&template[50..150]);
r1_recs.push((format!("pair_{i}/1"), r1));
r2_recs.push((format!("pair_{i}/2"), r2));
}
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let err = make_detect(vec![r1_path, r2_path], None).execute().unwrap_err().to_string();
assert!(
err.contains("No usable PE adapter detections"),
"expected zero-detection error message; got: {err}"
);
}
fn valid_se_baseline(tmp: &TempDir) -> Detect {
let p = tmp.path().join("ok.fq");
std::fs::write(&p, b"@x\nACGT\n+\nIIII\n").unwrap();
make_detect(vec![p], None)
}
fn assert_validate_err_contains(cmd: &Detect, substr: &str) {
let err = cmd.validate().unwrap_err().to_string();
assert!(err.contains(substr), "expected error containing {substr:?}; got: {err}");
}
#[test]
fn validate_rejects_missing_input() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.inputs = vec![tmp.path().join("nope.fq")];
assert_validate_err_contains(&cmd, "does not exist");
}
#[test]
fn validate_rejects_more_than_two_inputs() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.inputs = vec![cmd.inputs[0].clone(); 3];
assert_validate_err_contains(&cmd, "--inputs accepts at most 2 paths; got 3");
}
#[test]
fn validate_rejects_same_input_twice() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.inputs = vec![cmd.inputs[0].clone(); 2];
assert_validate_err_contains(&cmd, "R1 and R2 must be different files");
}
#[test]
fn validate_rejects_missing_output_parent() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.output_fasta = Some(tmp.path().join("no_such_dir/out.fa"));
assert_validate_err_contains(&cmd, "parent directory");
}
#[test]
fn validate_rejects_empty_adapter_sequence() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.adapter_sequence = vec!["".to_string()];
assert_validate_err_contains(&cmd, "must not be empty");
}
#[test]
fn validate_rejects_invalid_adapter_base() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.adapter_sequence = vec!["ACGZ".to_string()];
assert_validate_err_contains(&cmd, "invalid base");
}
#[test]
fn validate_rejects_out_of_range_min_fraction() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.min_fraction = -0.1;
assert_validate_err_contains(&cmd, "--min-fraction");
}
#[test]
fn validate_rejects_out_of_range_overlap_max_mismatch_rate() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.overlap_max_mismatch_rate = 1.5;
assert_validate_err_contains(&cmd, "--overlap-max-mismatch-rate");
}
#[test]
fn validate_rejects_out_of_range_adapter_mismatch_rate() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.adapter_mismatch_rate = 2.0;
assert_validate_err_contains(&cmd, "--adapter-mismatch-rate");
}
#[test]
fn validate_rejects_zero_num_detections() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.num_detections = 0;
assert_validate_err_contains(&cmd, "--num-detections");
}
#[test]
fn validate_rejects_zero_max_reads() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.max_reads = 0;
assert_validate_err_contains(&cmd, "--max-reads");
}
#[test]
fn validate_rejects_zero_min_detections_for_report() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.min_detections_for_report = 0;
assert_validate_err_contains(&cmd, "--min-detections-for-report must be at least 1");
}
#[test]
fn validate_rejects_floor_above_target_detections() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.min_detections_for_report = 100;
cmd.num_detections = 50;
assert_validate_err_contains(&cmd, "cannot exceed --num-detections");
}
#[test]
fn validate_rejects_floor_above_max_reads() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.num_detections = 200_000; cmd.max_reads = 100;
cmd.min_detections_for_report = 1000;
assert_validate_err_contains(&cmd, "cannot exceed --max-reads");
}
#[test]
fn validate_rejects_zero_min_tail_length() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.min_tail_length = 0;
assert_validate_err_contains(&cmd, "--min-tail-length");
}
#[test]
fn validate_rejects_zero_overlap_min_length() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.overlap_min_length = 0;
assert_validate_err_contains(&cmd, "--overlap-min-length");
}
#[test]
fn validate_rejects_zero_overlap_diagnostic_length() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.overlap_diagnostic_length = 0;
assert_validate_err_contains(&cmd, "--overlap-diagnostic-length");
}
#[test]
fn validate_rejects_zero_adapter_min_length() {
let tmp = TempDir::new().unwrap();
let mut cmd = valid_se_baseline(&tmp);
cmd.adapter_min_length = 0;
assert_validate_err_contains(&cmd, "--adapter-min-length");
}
#[test]
fn validate_pe_rejects_adapter_fasta() {
let tmp = TempDir::new().unwrap();
let r1 = tmp.path().join("r1.fq");
let r2 = tmp.path().join("r2.fq");
std::fs::write(&r1, b"@x\nACGT\n+\nIIII\n").unwrap();
std::fs::write(&r2, b"@x\nACGT\n+\nIIII\n").unwrap();
let mut cmd = make_detect(vec![r1, r2], None);
cmd.adapter_fasta = Some(tmp.path().join("x.fa"));
assert_validate_err_contains(&cmd, "--adapter-fasta is not used in paired-end mode");
}
#[test]
fn filter_min_fraction_passes_singleton_at_zero_fraction() {
let hits = vec![Hit { name: None, seq: b"A".to_vec(), count: 1 }];
let out = filter_min_fraction(&hits, 1000, 0.0);
assert_eq!(out.len(), 1);
}
#[test]
fn filter_min_fraction_at_one_keeps_only_full_winners() {
let hits = vec![
Hit { name: None, seq: b"A".to_vec(), count: 50 },
Hit { name: None, seq: b"B".to_vec(), count: 50 },
];
let out = filter_min_fraction(&hits, 100, 1.0);
assert!(out.is_empty());
}
#[test]
fn se_missing_adapter_fasta_errors_cleanly() {
let tmp = TempDir::new().unwrap();
let in_path = write_fq(
&tmp,
"r1",
&[("x".to_string(), b"ACGT".repeat(25))].into_iter().collect::<Vec<_>>(),
);
let mut cmd = make_detect(vec![in_path], None);
cmd.adapter_fasta = Some(tmp.path().join("does_not_exist.fa"));
let err = cmd.execute().unwrap_err().to_string();
assert!(
err.contains("Failed to open adapter FASTA") || err.contains("does_not_exist"),
"expected FASTA-open error; got: {err}"
);
}
#[test]
fn se_empty_adapter_fasta_loads_to_no_extra_candidates() {
let tmp = TempDir::new().unwrap();
let fa = tmp.path().join("empty.fa");
std::fs::write(&fa, b"").unwrap();
let cands = build_se_candidates(&[], &Some(fa), 10).unwrap();
let baseline = build_se_candidates(&[], &None, 10).unwrap();
assert_eq!(cands.len(), baseline.len());
}
#[test]
fn pe_below_detection_floor_errors_instead_of_reporting() {
let tmp = TempDir::new().unwrap();
let mut r1_recs: Vec<(String, Vec<u8>)> = Vec::new();
let mut r2_recs: Vec<(String, Vec<u8>)> = Vec::new();
let tail_r1 = &TRUSEQ.seq_r1[..20];
let tail_r2 = &TRUSEQ.seq_r2.unwrap()[..20];
for i in 0..3 {
let template = template_of_len(80);
let (r1, r2) = pe_pair(&template, tail_r1, tail_r2);
r1_recs.push((format!("p_{i}/1"), r1));
r2_recs.push((format!("p_{i}/2"), r2));
}
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let mut cmd = make_detect(vec![r1_path, r2_path], None);
cmd.min_detections_for_report = 10;
let err = cmd.execute().unwrap_err().to_string();
assert!(
err.contains("below the `--min-detections-for-report` floor"),
"expected floor-violation error; got: {err}"
);
}
#[test]
fn pe_varied_inserts_still_identify_truseq() {
let tmp = TempDir::new().unwrap();
let tail_r1 = &TRUSEQ.seq_r1[..20];
let tail_r2 = &TRUSEQ.seq_r2.unwrap()[..20];
let mut r1_recs: Vec<(String, Vec<u8>)> = Vec::new();
let mut r2_recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..200u64 {
let ins = 60 + (i.wrapping_mul(31) % 31) as usize; let template = template_of_len_seeded(ins, 0xABCD ^ i);
let (r1, r2) = pe_pair(&template, tail_r1, tail_r2);
r1_recs.push((format!("p_{i}/1"), r1));
r2_recs.push((format!("p_{i}/2"), r2));
}
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let fasta_path = tmp.path().join("out.fa");
make_detect(vec![r1_path, r2_path], Some(fasta_path.clone())).execute().unwrap();
let fasta = std::fs::read_to_string(&fasta_path).unwrap();
let truseq_r1: &str = std::str::from_utf8(&TRUSEQ.seq_r1[..TAIL_KMER_LEN]).unwrap();
assert!(
fasta.contains(truseq_r1),
"varied-insert PE should still identify TruSeq R1; got:\n{fasta}"
);
}
fn template_of_len_seeded(n: usize, seed: u64) -> Vec<u8> {
let bases = b"ACGT";
let mut state: u64 = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
(0..n)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
bases[(state as usize) & 0x3]
})
.collect()
}
#[test]
fn effective_trimmed_len_strips_polyg_tail() {
let seq = b"AGATCGGAAGAGCACAGTGTGGGGGGGGGGGGGGG";
let qual = vec![b'I'; seq.len()];
let end = effective_trimmed_len(seq, &qual, 10, 0, None);
assert_eq!(end, 20);
}
#[test]
fn effective_trimmed_len_strips_polyx_a_tail() {
let seq = b"AGATCGGAAGAGCACAGTGTAAAAAAAAAAAAAAA"; let qual = vec![b'I'; seq.len()];
let end = effective_trimmed_len(seq, &qual, 0, 5, None);
assert_eq!(end, 20);
}
#[test]
fn effective_trimmed_len_polyg_runs_before_polyx() {
let seq = b"AGATCGGAAGAGCACAGTGTAAAAAGGGGG"; let qual = vec![b'I'; seq.len()];
let end = effective_trimmed_len(seq, &qual, 5, 5, None);
assert_eq!(end, 20);
}
#[test]
fn effective_trimmed_len_quality_cut_after_homopolymers() {
let seq = b"AGATCGGAAGAGCACAGTGTACGTACGTAC"; let mut qual = vec![b'I'; 20];
qual.extend(vec![b'!'; 10]); let end =
effective_trimmed_len(seq, &qual, 0, 0, Some(QualityTrim { window: 4, threshold: 20 }));
assert_eq!(end, 19);
}
#[test]
fn effective_trimmed_len_all_disabled_is_noop() {
let seq = b"AGATCGGAAGAGCACAGTGT";
let qual = vec![b'I'; seq.len()];
assert_eq!(effective_trimmed_len(seq, &qual, 0, 0, None), seq.len());
}
#[test]
fn effective_trimmed_len_min_run_gate_respected() {
let seq = b"AGATCGGAAGAGCACAGTGTGGG";
let qual = vec![b'I'; seq.len()];
let end = effective_trimmed_len(seq, &qual, 10, 0, None);
assert_eq!(end, seq.len());
}
#[test]
fn quality_trim_setting_accepts_off_aliases() {
assert!(QualityTrimSetting::from_str("off").unwrap().as_option().is_none());
assert!(QualityTrimSetting::from_str("OFF").unwrap().as_option().is_none());
assert!(QualityTrimSetting::from_str("None").unwrap().as_option().is_none());
assert!(QualityTrimSetting::from_str("no").unwrap().as_option().is_none());
let qt = QualityTrimSetting::from_str("8:25").unwrap().as_option().unwrap();
assert_eq!(qt.window, 8);
assert_eq!(qt.threshold, 25);
assert!(QualityTrimSetting::from_str("garbage").is_err());
}
fn tail_accumulator_with_majorities(
majorities: &[f64],
base: u8,
total: u64,
) -> TailAccumulator {
let base_slot = TailAccumulator::base_to_slot(base);
let mut acc = TailAccumulator { count: total, base_counts: Vec::new() };
for &frac in majorities {
let top = (total as f64 * frac).round() as u64;
let other = (total - top) / 3;
let mut col = [0u64; 5];
col[base_slot] = top;
for (slot, c) in col.iter_mut().enumerate().take(4) {
if slot != base_slot {
*c = other;
}
}
let assigned: u64 = col.iter().sum();
col[4] += total - assigned;
acc.base_counts.push(col);
}
acc
}
#[test]
fn consensus_extends_through_steady_high_majority() {
let acc = tail_accumulator_with_majorities(&[0.95; 20], b'A', 100);
let seq = acc.consensus(5);
assert_eq!(seq.len(), 20);
assert!(seq.iter().all(|&b| b == b'A'));
}
#[test]
fn consensus_extends_through_gently_declining_majority() {
let majorities = [0.96, 0.94, 0.92, 0.90, 0.88, 0.86, 0.84, 0.82, 0.80];
let acc = tail_accumulator_with_majorities(&majorities, b'A', 100);
let seq = acc.consensus(5);
assert_eq!(seq.len(), majorities.len());
}
#[test]
fn consensus_cuts_at_sharp_discontinuity() {
let mut majorities = vec![0.95; 16];
majorities.push(0.82);
majorities.push(0.85);
let acc = tail_accumulator_with_majorities(&majorities, b'A', 100);
let seq = acc.consensus(5);
assert_eq!(seq.len(), 16, "should cut at the 0.82 discontinuity");
}
#[test]
fn consensus_cuts_at_absolute_floor_when_pool_dominates() {
let mut majorities = vec![0.95; 16];
majorities.push(0.40);
let acc = tail_accumulator_with_majorities(&majorities, b'A', 100);
let seq = acc.consensus(5);
assert_eq!(seq.len(), 16);
}
#[test]
fn consensus_stops_when_coverage_below_min() {
let mut acc = tail_accumulator_with_majorities(&[0.95; 10], b'A', 100);
acc.base_counts.push([4, 0, 0, 0, 0]); let seq = acc.consensus(5);
assert_eq!(seq.len(), 10);
}
#[test]
fn aggregate_kmers_drops_primaries_with_short_consensus() {
let mut input: HashMap<Vec<u8>, TailAccumulator> = HashMap::new();
let mut acc_a = TailAccumulator::default();
let mut acc_b = TailAccumulator::default();
for _ in 0..50 {
acc_a.observe(b"ACGTACGTACGTACGT");
acc_b.observe(b"ACGAACGTACGTACGT");
}
input.insert(b"ACGTACGTACGTACGT".to_vec(), acc_a);
input.insert(b"ACGAACGTACGTACGT".to_vec(), acc_b);
let out = aggregate_kmers(input, 2);
assert!(out.is_empty(), "merged 50/50-at-pos-3 cluster should be dropped; got {out:?}");
}
#[test]
fn validate_accepts_floor_equal_to_num_detections() {
let tmp = TempDir::new().unwrap();
let fq = write_fq(&tmp, "r", &[("x".to_string(), b"ACGT".to_vec())]);
let mut cmd = make_detect(vec![fq], None);
cmd.num_detections = 50;
cmd.min_detections_for_report = 50; assert!(cmd.validate().is_ok());
}
#[test]
fn validate_accepts_floor_equal_to_max_reads() {
let tmp = TempDir::new().unwrap();
let fq = write_fq(&tmp, "r", &[("x".to_string(), b"ACGT".to_vec())]);
let mut cmd = make_detect(vec![fq], None);
cmd.max_reads = 50;
cmd.num_detections = 50;
cmd.min_detections_for_report = 50;
assert!(cmd.validate().is_ok());
}
#[test]
fn validate_rejects_user_candidate_shorter_than_adapter_min_length() {
let tmp = TempDir::new().unwrap();
let fq = write_fq(&tmp, "r", &[("x".to_string(), b"ACGTACGTAC".to_vec())]);
let mut cmd = make_detect(vec![fq], None);
cmd.adapter_sequence = vec!["ACGTAC".to_string()]; let err = cmd.validate().unwrap_err().to_string();
assert!(
err.contains("shorter than --adapter-min-length"),
"expected too-short error; got: {err}"
);
}
#[test]
fn se_below_detection_floor_errors_instead_of_reporting() {
let tmp = TempDir::new().unwrap();
let tail = &TRUSEQ.seq_r1[..25];
let mut recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..3 {
let mut read = template_of_len(70);
read.extend_from_slice(tail);
recs.push((format!("r_{i}"), read));
}
let path = write_fq(&tmp, "r1", &recs);
let mut cmd = make_detect(vec![path], None);
cmd.min_detections_for_report = 10;
let err = cmd.execute().unwrap_err().to_string();
assert!(
err.contains("below the `--min-detections-for-report` floor"),
"expected SE floor-violation error; got: {err}"
);
}
#[test]
fn pe_output_fasta_errors_when_min_fraction_excludes_all_hits() {
let tmp = TempDir::new().unwrap();
let cap = 18;
let truseq_r1 = &TRUSEQ.seq_r1[..cap];
let truseq_r2 = &TRUSEQ.seq_r2.unwrap()[..cap];
let nextera_r1 = &NEXTERA.seq_r1[..cap];
let nextera_r2 = &NEXTERA.seq_r2.unwrap()[..cap];
let novel_r1: &[u8] = b"CCCCGGGGAAAATTTTAAAA";
let novel_r2: &[u8] = b"TTTTAAAAGGGGCCCCGGGG";
let mut r1_recs: Vec<(String, Vec<u8>)> = Vec::new();
let mut r2_recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..30 {
let template = template_of_len(80);
let (r1, r2) = pe_pair(&template, truseq_r1, truseq_r2);
r1_recs.push((format!("a_{i}/1"), r1));
r2_recs.push((format!("a_{i}/2"), r2));
let template = template_of_len(80);
let (r1, r2) = pe_pair(&template, nextera_r1, nextera_r2);
r1_recs.push((format!("b_{i}/1"), r1));
r2_recs.push((format!("b_{i}/2"), r2));
let template = template_of_len(80);
let (r1, r2) = pe_pair(&template, novel_r1, novel_r2);
r1_recs.push((format!("c_{i}/1"), r1));
r2_recs.push((format!("c_{i}/2"), r2));
}
let r1_path = write_fq(&tmp, "r1", &r1_recs);
let r2_path = write_fq(&tmp, "r2", &r2_recs);
let fasta_path = tmp.path().join("out.fa");
let mut cmd = make_detect(vec![r1_path, r2_path], Some(fasta_path.clone()));
cmd.min_fraction = 0.90;
let err = cmd.execute().unwrap_err().to_string();
assert!(
err.contains("No adapter reached --min-fraction"),
"expected empty-FASTA hard-fail; got: {err}"
);
assert!(!fasta_path.exists(), "empty FASTA should not be created; found file");
}
#[test]
fn se_output_fasta_errors_when_min_fraction_excludes_all_hits() {
let tmp = TempDir::new().unwrap();
let cap = 18;
let truseq_tail = &TRUSEQ.seq_r1[..cap];
let nextera_tail = &NEXTERA.seq_r1[..cap];
let mut recs: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..50 {
let mut a = template_of_len(70);
a.extend_from_slice(truseq_tail);
recs.push((format!("t_{i}"), a));
let mut b = template_of_len(70);
b.extend_from_slice(nextera_tail);
recs.push((format!("n_{i}"), b));
}
let path = write_fq(&tmp, "r1", &recs);
let fasta_path = tmp.path().join("out.fa");
let mut cmd = make_detect(vec![path], Some(fasta_path.clone()));
cmd.min_fraction = 0.90;
let err = cmd.execute().unwrap_err().to_string();
assert!(
err.contains("No candidate adapter reached --min-fraction"),
"expected SE empty-FASTA hard-fail; got: {err}"
);
assert!(!fasta_path.exists());
}
#[test]
fn load_adapter_fasta_errors_on_header_without_body() {
let tmp = TempDir::new().unwrap();
let fa = tmp.path().join("bad.fa");
std::fs::write(&fa, ">foo\n>bar\nACGTACGTACGT\n").unwrap();
let err = match build_se_candidates(&[], &Some(fa), 10) {
Ok(_) => panic!("expected empty-header error; loader accepted the malformed FASTA"),
Err(e) => e.to_string(),
};
assert!(
err.contains("empty sequence") || err.contains("header without a body"),
"expected empty-header error; got: {err}"
);
}
}