use std::borrow::Cow;
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::sync_channel;
use std::sync::{Arc, Mutex};
use std::thread;
use rayon::prelude::*;
use crate::config::FastQCConfig;
use crate::modules;
use crate::modules::QCModule;
use crate::progress::{self, FileProgress};
use crate::report;
use crate::sequence::casava;
use crate::sequence::group::FileOpener;
use crate::sequence::open_sequence_file;
use crate::sequence::{Sequence, SequenceFile, SequenceFileGroup};
const MAX_PROCESSORS_PER_FILE: usize = 6;
const BATCH_SIZE: usize = 1024;
const DEFAULT_THREADS: usize = 6;
const BATCH_BYTES: usize = 1024 * 1024;
const QUEUE_CAPACITY: usize = 32;
struct ThreadPlan {
outer_slots: usize,
threads_per_file: usize,
}
fn plan_threads(
requested_threads: Option<usize>,
n_files: usize,
hw_parallelism: usize,
) -> ThreadPlan {
let total = requested_threads
.unwrap_or_else(|| hw_parallelism.min(DEFAULT_THREADS))
.max(1);
let n_files = n_files.max(1);
let outer_slots = n_files.min(total);
let threads_per_file = (total / outer_slots).max(1);
ThreadPlan {
outer_slots,
threads_per_file,
}
}
fn processors_for(threads_per_file: usize, background_threads: usize) -> usize {
let decoder = background_threads.min(1);
MAX_PROCESSORS_PER_FILE.min(threads_per_file.saturating_sub(1 + decoder))
}
struct FileGroup {
name: String,
files: Vec<PathBuf>,
}
pub fn run(config: &FastQCConfig, files: &[PathBuf]) -> Result<(), i32> {
let progress_plan = progress::ProgressPlan::new(config.quiet);
let limits = config.load_limits().map_err(|e| {
eprintln!("Failed to load limits: {}", e);
1
})?;
let mut valid_files = Vec::new();
let mut something_failed = false;
for file_path in files {
let file_name = file_path.to_string_lossy();
if !file_name.starts_with("stdin") && !file_path.exists() {
eprintln!("{} doesn't exist", file_name);
something_failed = true;
} else if config.nano && file_path.is_dir() {
match find_fast5_files(file_path) {
Ok(fast5_files) => {
if fast5_files.is_empty() {
eprintln!("No .fast5 files found in {}", file_path.display());
something_failed = true;
} else {
valid_files.extend(fast5_files);
}
}
Err(e) => {
eprintln!("Error scanning directory {}: {}", file_path.display(), e);
something_failed = true;
}
}
} else {
valid_files.push(file_path.clone());
}
}
let file_groups = build_file_groups(config, &valid_files);
let ThreadPlan {
outer_slots,
threads_per_file,
} = plan_threads(
config.threads,
file_groups.len(),
crate::utils::available_parallelism(),
);
let config = if threads_per_file == 1 && config.decompress_threads == 1 {
Cow::Owned(FastQCConfig {
decompress_threads: 0,
..config.clone()
})
} else {
Cow::Borrowed(config)
};
let config = config.as_ref();
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(outer_slots)
.build()
.map_err(|e| {
eprintln!("Failed to create thread pool: {}", e);
1
})?;
let names: Vec<String> = file_groups.iter().map(|g| g.name.clone()).collect();
let report_locks = report_locks(config, &file_groups);
let progress = progress_plan.start(&names);
let analysed = pool.install(|| {
file_groups
.par_iter()
.enumerate()
.map(|(index, group)| {
let file_progress = progress.file(index);
file_progress.start(&group.name);
let report_lock = report_locks[index].as_deref();
match process_group(
config,
&limits,
group,
threads_per_file,
report_lock,
file_progress,
) {
Ok(reads) => {
file_progress.finish(&group.name, reads);
true
}
Err(e) => {
file_progress.fail();
progress.error(&format!("Failed to process {}: {}", group.name, e));
false
}
}
})
.filter(|&analysed| analysed)
.count()
});
let failed = something_failed || analysed != file_groups.len();
progress.finish(analysed, failed);
if failed {
Err(1)
} else {
Ok(())
}
}
fn build_file_groups(config: &FastQCConfig, files: &[PathBuf]) -> Vec<FileGroup> {
if config.casava {
let casava_groups = casava::get_casava_groups(files);
casava_groups
.into_iter()
.map(|(name, paths)| FileGroup { name, files: paths })
.collect()
} else {
files
.iter()
.map(|path| {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
FileGroup {
name,
files: vec![path.clone()],
}
})
.collect()
}
}
fn report_location(config: &FastQCConfig, group: &FileGroup) -> (PathBuf, String) {
let base_name = strip_extensions(&group.name.replace("stdin:", ""));
let output_dir = config.output_dir.clone().unwrap_or_else(|| {
group
.files
.first()
.and_then(|f| f.parent())
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."))
.to_path_buf()
});
(output_dir, base_name)
}
fn report_locks(config: &FastQCConfig, groups: &[FileGroup]) -> Vec<Option<Arc<Mutex<()>>>> {
let mut by_location: HashMap<PathBuf, Vec<usize>> = HashMap::new();
for (index, group) in groups.iter().enumerate() {
let (dir, base) = report_location(config, group);
let dir = dir.canonicalize().unwrap_or(dir);
by_location.entry(dir.join(base)).or_default().push(index);
}
let mut locks = vec![None; groups.len()];
let mut collisions: Vec<(PathBuf, Vec<usize>)> = by_location
.into_iter()
.filter(|(_, indices)| indices.len() > 1)
.collect();
collisions.sort_by_key(|(_, indices)| indices[0]);
for (location, indices) in collisions {
let inputs: Vec<String> = indices
.iter()
.map(|&i| match groups[i].files.as_slice() {
[path] => path.display().to_string(),
_ => groups[i].name.clone(),
})
.collect();
progress::log_line(&format!(
"Warning: {} all write the same report, {}_fastqc.{{html,zip}}; only one will be kept",
inputs.join(", "),
location.display()
));
let lock = Arc::new(Mutex::new(()));
for index in indices {
locks[index] = Some(Arc::clone(&lock));
}
}
locks
}
fn open_group(config: &FastQCConfig, group: &FileGroup) -> io::Result<Box<dyn SequenceFile>> {
if let [path] = group.files.as_slice() {
return open_sequence_file(config, path);
}
let openers = group
.files
.iter()
.map(|path| {
let config = config.clone();
let path = path.clone();
Box::new(move || open_sequence_file(&config, &path)) as FileOpener
})
.collect();
Ok(Box::new(SequenceFileGroup::new(
group.name.clone(),
openers,
)?))
}
fn process_group(
config: &FastQCConfig,
limits: &crate::config::Limits,
group: &FileGroup,
threads_per_file: usize,
report_lock: Option<&Mutex<()>>,
file_progress: FileProgress<'_>,
) -> io::Result<u64> {
let mut seq_file = open_group(config, group)?;
let file_display_name = group.name.clone();
let mut modules = modules::create_modules(config, limits);
let known_encoding = seq_file.known_phred_encoding().or(config
.phred64
.then_some(crate::utils::phred::PhredEncoding::PHRED64));
for module in modules.iter_mut() {
module.set_filename(&file_display_name);
if let Some(encoding) = known_encoding {
module.set_phred_encoding(encoding);
}
}
if let Some(live) = file_progress.live_stats() {
for module in modules.iter_mut() {
module.attach_live_stats(Arc::clone(&live));
}
}
let num_processors =
processors_for(threads_per_file, seq_file.background_threads()).min(modules.len());
let read_count = if num_processors == 0 {
process_sequences_sequential(config, seq_file.as_mut(), &mut modules, file_progress)?
} else {
let (rebuilt, count) = process_sequences_parallel(
config,
seq_file.as_mut(),
modules,
num_processors,
file_progress,
)?;
modules = rebuilt;
count
};
file_progress.stage("report");
for module in modules.iter_mut() {
module.finalize();
}
let (output_dir, base_name) = report_location(config, group);
let html_path = output_dir.join(format!("{}_fastqc.html", base_name));
let zip_path = output_dir.join(format!("{}_fastqc.zip", base_name));
let html_content = report::html::generate_html_report(
&modules,
&file_display_name,
config.template,
config.png_output,
)?;
let _turn = report_lock.map(|lock| lock.lock().unwrap_or_else(|e| e.into_inner()));
std::fs::write(&html_path, &html_content)?;
report::archive::create_zip_archive(
&modules,
&file_display_name,
&base_name,
&zip_path,
&html_content,
config.template,
)?;
if config.do_unzip == Some(true) {
report::archive::extract_zip(&zip_path)?;
if config.delete_after_unzip {
std::fs::remove_file(&zip_path)?;
}
}
Ok(read_count)
}
#[inline]
fn feed_module(module: &mut dyn QCModule, seq: &Sequence) {
if seq.is_filtered && module.ignore_filtered_sequences() {
return;
}
module.process_sequence(seq);
}
fn passes_length_filter(config: &FastQCConfig, seq: &Sequence) -> bool {
let len = seq.sequence.len();
len >= config.min_length && (config.max_length == 0 || len <= config.max_length)
}
const PROGRESS_INTERVAL: u64 = 1000;
fn process_sequences_sequential(
config: &FastQCConfig,
seq_file: &mut dyn SequenceFile,
modules: &mut [Box<dyn QCModule>],
file_progress: FileProgress<'_>,
) -> io::Result<u64> {
let mut sequence_count: u64 = 0;
loop {
match seq_file.next() {
Some(Ok(seq)) => {
sequence_count += 1;
if passes_length_filter(config, &seq) {
for module in modules.iter_mut() {
feed_module(module.as_mut(), &seq);
}
}
if sequence_count.is_multiple_of(PROGRESS_INTERVAL) {
file_progress.update(sequence_count, || seq_file.percent_complete());
}
}
Some(Err(e)) => {
return Err(io::Error::new(io::ErrorKind::InvalidData, e));
}
None => break, }
}
Ok(sequence_count)
}
fn partition_modules_by_cost(
modules: Vec<Box<dyn QCModule>>,
num_workers: usize,
) -> Vec<Vec<(usize, Box<dyn QCModule>)>> {
assert!(num_workers > 0, "partition needs at least one worker");
let mut order: Vec<(usize, Box<dyn QCModule>)> = modules.into_iter().enumerate().collect();
order.sort_by_key(|entry| std::cmp::Reverse(entry.1.cost_hint()));
let mut groups: Vec<Vec<(usize, Box<dyn QCModule>)>> =
(0..num_workers).map(|_| Vec::new()).collect();
let mut loads = vec![0u64; num_workers];
for (idx, module) in order {
let target = (0..num_workers).min_by_key(|&i| loads[i]).unwrap_or(0);
loads[target] += module.cost_hint() as u64;
groups[target].push((idx, module));
}
groups
}
fn process_sequences_parallel(
config: &FastQCConfig,
seq_file: &mut dyn SequenceFile,
modules: Vec<Box<dyn QCModule>>,
num_processors: usize,
file_progress: FileProgress<'_>,
) -> io::Result<(Vec<Box<dyn QCModule>>, u64)> {
let groups = partition_modules_by_cost(modules, num_processors);
let (senders, receivers): (Vec<_>, Vec<_>) = (0..num_processors)
.map(|_| sync_channel::<Arc<Vec<Sequence>>>(QUEUE_CAPACITY))
.unzip();
let mut reader_error: Option<io::Error> = None;
let mut sequence_count: u64 = 0;
let processed: Vec<Vec<(usize, Box<dyn QCModule>)>> = thread::scope(|scope| {
let handles: Vec<_> = groups
.into_iter()
.zip(receivers)
.map(|(mut group, rx)| {
scope.spawn(move || {
while let Ok(batch) = rx.recv() {
for seq in batch.iter() {
for (_, module) in group.iter_mut() {
feed_module(module.as_mut(), seq);
}
}
}
group
})
})
.collect();
let mut batch: Vec<Sequence> = Vec::with_capacity(BATCH_SIZE);
let mut batch_bytes = 0usize;
'read: loop {
match seq_file.next() {
Some(Ok(seq)) => {
sequence_count += 1;
if sequence_count.is_multiple_of(PROGRESS_INTERVAL) {
file_progress.update(sequence_count, || seq_file.percent_complete());
}
if !passes_length_filter(config, &seq) {
continue;
}
batch_bytes += seq.heap_bytes();
batch.push(seq);
if batch.len() == BATCH_SIZE || batch_bytes >= BATCH_BYTES {
let full = std::mem::replace(&mut batch, Vec::with_capacity(BATCH_SIZE));
batch_bytes = 0;
let shared = Arc::new(full);
for tx in &senders {
if tx.send(Arc::clone(&shared)).is_err() {
break 'read;
}
}
}
}
Some(Err(e)) => {
reader_error = Some(io::Error::new(io::ErrorKind::InvalidData, e));
break;
}
None => break, }
}
if reader_error.is_none() && !batch.is_empty() {
let shared = Arc::new(batch);
for tx in &senders {
let _ = tx.send(Arc::clone(&shared));
}
}
drop(senders);
handles
.into_iter()
.map(|h| h.join().expect("analysis worker thread panicked"))
.collect()
});
if let Some(e) = reader_error {
return Err(e);
}
let mut rebuilt: Vec<(usize, Box<dyn QCModule>)> = processed.into_iter().flatten().collect();
rebuilt.sort_by_key(|(idx, _)| *idx);
Ok((
rebuilt.into_iter().map(|(_, module)| module).collect(),
sequence_count,
))
}
fn strip_extensions(name: &str) -> String {
let mut result = name.to_string();
for ext in &[
".gz", ".bz2", ".txt", ".fastq", ".fq", ".csfastq", ".sam", ".bam", ".ubam", ".fast5",
] {
if result.ends_with(ext) {
result = result[..result.len() - ext.len()].to_string();
}
}
result
}
fn find_fast5_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
find_fast5_files_recursive(dir, &mut files)?;
files.sort(); Ok(files)
}
fn find_fast5_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
find_fast5_files_recursive(&path, files)?;
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("fast5"))
{
files.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modules::ModuleStatus;
struct MockSeqFile {
seqs: Vec<Sequence>,
pos: usize,
}
impl SequenceFile for MockSeqFile {
fn next(&mut self) -> Option<io::Result<Sequence>> {
if self.pos < self.seqs.len() {
let s = self.seqs[self.pos].clone();
self.pos += 1;
Some(Ok(s))
} else {
None
}
}
fn name(&self) -> &str {
"mock.fastq"
}
fn is_colorspace(&self) -> bool {
false
}
fn percent_complete(&self) -> f64 {
if self.seqs.is_empty() {
100.0
} else {
(self.pos as f64 / self.seqs.len() as f64) * 100.0
}
}
}
fn make_test_sequences(count: usize, len: usize) -> Vec<Sequence> {
let alphabet = b"ACGTACGTGGCCATN";
let adapter = b"AGATCGGAAGAGC";
let mut seqs = Vec::with_capacity(count);
for i in 0..count {
let mut bases = vec![0u8; len];
for (p, b) in bases.iter_mut().enumerate() {
*b = alphabet[(i * 7 + p * 3) % alphabet.len()];
}
if i % 12 == 0 && len > adapter.len() + 5 {
let start = len - adapter.len() - (i % 5);
bases[start..start + adapter.len()].copy_from_slice(adapter);
}
if i % 50 == 0 {
for (p, b) in bases.iter_mut().enumerate() {
*b = b"ACGT"[p % 4];
}
}
let quality: Vec<u8> = (0..len)
.map(|p| {
let q = 40i32 - (18 * p as i32) / len as i32;
33 + q.clamp(2, 40) as u8
})
.collect();
seqs.push(Sequence::new(format!("READ{}", i), bases, quality));
}
seqs
}
#[test]
fn test_partition_keeps_heavy_modules_apart() {
let config = FastQCConfig::default();
let limits = config.load_limits().expect("load limits");
let modules = modules::create_modules(&config, &limits);
let n = modules.len();
let groups = partition_modules_by_cost(modules, 3);
let worker_of = |name: &str| {
groups
.iter()
.position(|g| g.iter().any(|(_, m)| m.name() == name))
.unwrap_or_else(|| panic!("{name} not placed"))
};
let heavy = [
worker_of("Per base sequence quality"),
worker_of("Per base sequence content"),
worker_of("Adapter Content"),
];
assert!(
heavy[0] != heavy[1] && heavy[0] != heavy[2] && heavy[1] != heavy[2],
"the three heaviest modules should land on different workers: {heavy:?}"
);
let mut indices: Vec<usize> = groups
.iter()
.flat_map(|g| g.iter().map(|(i, _)| *i))
.collect();
indices.sort_unstable();
assert_eq!(indices, (0..n).collect::<Vec<_>>());
}
#[test]
fn test_plan_threads_unspecified() {
let p = plan_threads(None, 1, 128);
assert_eq!((p.outer_slots, p.threads_per_file), (1, DEFAULT_THREADS));
let p = plan_threads(None, 1, 1);
assert_eq!((p.outer_slots, p.threads_per_file), (1, 1));
let p = plan_threads(None, 0, 0);
assert_eq!((p.outer_slots, p.threads_per_file), (1, 1));
}
#[test]
fn test_plan_threads_explicit_budget() {
for total in 1..=64usize {
for n_files in 1..=8usize {
let p = plan_threads(Some(total), n_files, 64);
for decoder in [0, 1] {
let decoder = if p.threads_per_file == 1 { 0 } else { decoder };
let per_file = 1 + decoder + processors_for(p.threads_per_file, decoder);
let used = p.outer_slots * per_file;
assert!(
used <= total,
"-t {total} over {n_files} files used {used} threads"
);
}
}
}
let p = plan_threads(Some(4), 10, 8);
assert_eq!((p.outer_slots, p.threads_per_file), (4, 1));
let p = plan_threads(Some(8), 2, 16);
assert_eq!((p.outer_slots, p.threads_per_file), (2, 4));
let p = plan_threads(Some(32), 1, 4);
assert_eq!(p.threads_per_file, 32);
}
#[test]
fn test_processors_for() {
assert_eq!(processors_for(4, 1), 2);
assert_eq!(processors_for(2, 1), 0);
assert_eq!(processors_for(4, 0), 3);
assert_eq!(processors_for(1, 0), 0);
assert_eq!(processors_for(4, 8), 2);
assert_eq!(processors_for(64, 1), MAX_PROCESSORS_PER_FILE);
}
#[test]
fn test_report_locks_pair_colliding_groups() {
let config = FastQCConfig {
output_dir: Some(PathBuf::from("qc")),
..FastQCConfig::default()
};
let groups = build_file_groups(
&config,
&[
PathBuf::from("run1/S1.fastq.gz"),
PathBuf::from("run1/S2.fastq.gz"),
PathBuf::from("run2/S1.fastq.gz"),
],
);
let locks = report_locks(&config, &groups);
assert!(locks[1].is_none());
let (a, b) = (locks[0].as_ref().unwrap(), locks[2].as_ref().unwrap());
assert!(Arc::ptr_eq(a, b));
let config = FastQCConfig::default();
let groups = build_file_groups(
&config,
&[
PathBuf::from("run1/S1.fastq.gz"),
PathBuf::from("run2/S1.fastq.gz"),
],
);
assert!(report_locks(&config, &groups).iter().all(Option::is_none));
}
#[test]
fn test_parallel_matches_sequential() {
let config = FastQCConfig::default();
let limits = config.load_limits().expect("load limits");
let seqs = make_test_sequences(4000, 100);
let mut mods_seq = modules::create_modules(&config, &limits);
for m in mods_seq.iter_mut() {
m.set_filename("mock.fastq");
}
let reporter = progress::ProgressReporter::hidden();
let mut seq_file = MockSeqFile {
seqs: seqs.clone(),
pos: 0,
};
let sequential_reads =
process_sequences_sequential(&config, &mut seq_file, &mut mods_seq, reporter.file(0))
.expect("sequential run");
assert_eq!(sequential_reads, seqs.len() as u64);
for m in mods_seq.iter_mut() {
m.finalize();
}
let reference: Vec<(String, Vec<u8>, ModuleStatus)> = mods_seq
.iter()
.map(|m| {
let mut buf = Vec::new();
m.write_text_report(&mut buf).expect("text report");
(m.name().to_string(), buf, m.status())
})
.collect();
for num_processors in 1..=MAX_PROCESSORS_PER_FILE {
let mut mods_par = modules::create_modules(&config, &limits);
for m in mods_par.iter_mut() {
m.set_filename("mock.fastq");
}
let mut seq_file = MockSeqFile {
seqs: seqs.clone(),
pos: 0,
};
let (mut mods_par, parallel_reads) = process_sequences_parallel(
&config,
&mut seq_file,
mods_par,
num_processors,
reporter.file(0),
)
.expect("parallel run");
assert_eq!(parallel_reads, sequential_reads);
for m in mods_par.iter_mut() {
m.finalize();
}
assert_eq!(mods_par.len(), reference.len());
for (module, (name, ref_text, ref_status)) in mods_par.iter().zip(&reference) {
assert_eq!(module.name(), name, "module order changed");
let mut buf = Vec::new();
module.write_text_report(&mut buf).expect("text report");
assert_eq!(
&buf, ref_text,
"module `{}` text report differs with {} processor(s)",
name, num_processors
);
assert_eq!(
module.status(),
*ref_status,
"module `{}` status differs with {} processor(s)",
name,
num_processors
);
}
}
}
#[test]
fn test_strip_extensions() {
assert_eq!(strip_extensions("sample.fastq"), "sample");
assert_eq!(strip_extensions("sample.fastq.gz"), "sample");
assert_eq!(strip_extensions("sample.fq.bz2"), "sample");
assert_eq!(strip_extensions("sample.bam"), "sample");
assert_eq!(strip_extensions("sample.sam"), "sample");
assert_eq!(strip_extensions("sample.txt.gz"), "sample");
assert_eq!(strip_extensions("minimal.fastq"), "minimal");
}
#[test]
fn test_build_file_groups_default() {
let config = FastQCConfig::default();
let files = vec![PathBuf::from("a.fastq"), PathBuf::from("b.fastq")];
let groups = build_file_groups(&config, &files);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].name, "a.fastq");
assert_eq!(groups[0].files.len(), 1);
assert_eq!(groups[1].name, "b.fastq");
assert_eq!(groups[1].files.len(), 1);
}
#[test]
fn test_build_file_groups_casava() {
let config = FastQCConfig {
casava: true,
..FastQCConfig::default()
};
let files = vec![
PathBuf::from("Sample_S1_L001_R1_001.fastq.gz"),
PathBuf::from("Sample_S1_L001_R1_002.fastq.gz"),
PathBuf::from("Other_S2_L001_R1_001.fastq.gz"),
];
let groups = build_file_groups(&config, &files);
assert_eq!(groups.len(), 2);
let sample_group = groups
.iter()
.find(|g| g.name == "Sample_S1_L001_R1.fastq.gz")
.unwrap();
assert_eq!(sample_group.files.len(), 2);
let other_group = groups
.iter()
.find(|g| g.name == "Other_S2_L001_R1.fastq.gz")
.unwrap();
assert_eq!(other_group.files.len(), 1);
}
#[test]
fn test_build_file_groups_stdin() {
let config = FastQCConfig::default();
let files = vec![PathBuf::from("stdin")];
let groups = build_file_groups(&config, &files);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0].name, "stdin");
}
}