From e3b28ff51e3e76dcf83524dff2b22667f39f339f Mon Sep 17 00:00:00 2001 From: Tessa Pierce Ward Date: Mon, 7 Oct 2024 18:41:18 -0700 Subject: [PATCH] MRG: refactor sketching utilities (#112) - refactor for reusability: move all generalized sketch building utils --> `utils/buildutils.rs` - do minor refactoring to use `sourmash::encodings::HashFunctions` for `moltype`, created `Abund` enum. - requires https://github.com/sourmash-bio/sourmash/pull/3344 to use `Hash` for `sourmash::encodings::HashFunctions` Related: - https://github.com/sourmash-bio/sourmash_plugin_directsketch/issues/113 --- Cargo.lock | 3 +- Cargo.toml | 3 +- src/directsketch.rs | 29 +- src/{utils.rs => utils/buildutils.rs} | 721 ++++++------------------ src/utils/mod.rs | 378 +++++++++++++ tests/test-data/GCA_000961135.2.sig.zip | Bin 0 -> 31713 bytes 6 files changed, 574 insertions(+), 560 deletions(-) rename src/{utils.rs => utils/buildutils.rs} (54%) create mode 100644 src/utils/mod.rs create mode 100644 tests/test-data/GCA_000961135.2.sig.zip diff --git a/Cargo.lock b/Cargo.lock index eddeff7..a089546 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1854,8 +1854,7 @@ checksum = "bceb57dc07c92cdae60f5b27b3fa92ecaaa42fe36c55e22dbfb0b44893e0b1f7" [[package]] name = "sourmash" version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a73bae93170d8d0f816e18b6a630d76e134b90958850985ee2f0fb2f641d4de" +source = "git+https://github.com/sourmash-bio/sourmash.git?branch=latest#e1f88b114564f070dfbf9a9a1682e5e5e1c83a42" dependencies = [ "az", "byteorder", diff --git a/Cargo.toml b/Cargo.toml index 8c15eb0..2c846ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ crate-type = ["cdylib"] pyo3 = { version = "0.22.3", features = ["extension-module", "anyhow"] } rayon = "1.10.0" serde = { version = "1.0.204", features = ["derive"] } -sourmash = { version = "0.15.2"} +# sourmash = { version = "0.15.2"} +sourmash = { git = "https://github.com/sourmash-bio/sourmash.git", branch = "latest"} serde_json = "1.0.120" niffler = "2.4.0" needletail = "0.5.1" diff --git a/src/directsketch.rs b/src/directsketch.rs index 13b958b..2cb28f2 100644 --- a/src/directsketch.rs +++ b/src/directsketch.rs @@ -18,9 +18,12 @@ use tokio_util::compat::Compat; use pyo3::prelude::*; use crate::utils::{ - load_accession_info, load_gbassembly_info, parse_params_str, AccessionData, BuildCollection, - BuildManifest, GBAssemblyData, GenBankFileType, InputMolType, MultiBuildCollection, - MultiCollection, + load_accession_info, load_gbassembly_info, AccessionData, GBAssemblyData, GenBankFileType, + InputMolType, MultiCollection, +}; + +use crate::utils::buildutils::{ + BuildCollection, BuildManifest, BuildParamsSet, MultiBuildCollection, }; use reqwest::Url; @@ -918,16 +921,17 @@ pub async fn gbsketch( } // parse param string into params_vec, print error if fail - let param_result = parse_params_str(param_str); - let params_vec = match param_result { + let param_result = BuildParamsSet::from_params_str(param_str); + let params_set = match param_result { Ok(params) => params, Err(e) => { bail!("Failed to parse params string: {}", e); } }; - let dna_template_collection = BuildCollection::from_buildparams(¶ms_vec, "DNA"); - // prot will build protein, dayhoff, hp - let prot_template_collection = BuildCollection::from_buildparams(¶ms_vec, "protein"); + // Use the BuildParamsSet to create template collections for DNA and protein + let dna_template_collection = BuildCollection::from_buildparams_set(¶ms_set, "DNA"); + // // prot will build protein, dayhoff, hp + let prot_template_collection = BuildCollection::from_buildparams_set(¶ms_set, "protein"); let mut genomes_only = genomes_only; let mut proteomes_only = proteomes_only; @@ -1157,15 +1161,16 @@ pub async fn urlsketch( } // parse param string into params_vec, print error if fail - let param_result = parse_params_str(param_str); - let params_vec = match param_result { + let param_result = BuildParamsSet::from_params_str(param_str); + let params_set = match param_result { Ok(params) => params, Err(e) => { bail!("Failed to parse params string: {}", e); } }; - let dna_template_collection = BuildCollection::from_buildparams(¶ms_vec, "DNA"); - let prot_template_collection = BuildCollection::from_buildparams(¶ms_vec, "protein"); + // Use the BuildParamsSet to create template collections for DNA and protein + let dna_template_collection = BuildCollection::from_buildparams_set(¶ms_set, "DNA"); + let prot_template_collection = BuildCollection::from_buildparams_set(¶ms_set, "protein"); let mut genomes_only = false; let mut proteomes_only = false; diff --git a/src/utils.rs b/src/utils/buildutils.rs similarity index 54% rename from src/utils.rs rename to src/utils/buildutils.rs index 3c84f1a..7994e50 100644 --- a/src/utils.rs +++ b/src/utils/buildutils.rs @@ -1,3 +1,5 @@ +//! sketching utilities + use anyhow::{anyhow, Context, Result}; use async_zip::base::write::ZipFileWriter; use async_zip::{Compression, ZipDateTime, ZipEntryBuilder}; @@ -6,335 +8,171 @@ use chrono::Utc; use getset::{Getters, Setters}; use needletail::parser::SequenceRecord; use needletail::{parse_fastx_file, parse_fastx_reader}; -use reqwest::Url; use serde::Serialize; use sourmash::cmd::ComputeParameters; -use sourmash::collection::Collection; +use sourmash::encodings::HashFunctions; use sourmash::manifest::Record; use sourmash::signature::Signature; use std::collections::hash_map::DefaultHasher; use std::collections::HashMap; use std::collections::HashSet; -use std::fmt; use std::hash::{Hash, Hasher}; use std::io::{Cursor, Write}; use tokio::fs::File; use tokio_util::compat::Compat; -#[derive(Clone)] -pub enum InputMolType { - Dna, - Protein, +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Abundance { + Abund, + NoAbund, } -impl InputMolType {} - -impl fmt::Display for InputMolType { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - InputMolType::Dna => write!(f, "DNA"), - InputMolType::Protein => write!(f, "protein"), - } +impl Default for Abundance { + fn default() -> Self { + Abundance::NoAbund } } -impl std::str::FromStr for InputMolType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_str() { - "dna" => Ok(InputMolType::Dna), - "protein" => Ok(InputMolType::Protein), - _ => Err(()), - } - } -} - -#[allow(dead_code)] -pub enum GenBankFileType { - Genomic, - Protein, - AssemblyReport, - Checksum, +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuildParams { + pub ksize: u32, + pub abundance: Abundance, + pub num: u32, + pub scaled: u64, + pub seed: u32, + pub moltype: HashFunctions, } -impl GenBankFileType { - pub fn suffix(&self) -> &'static str { - match self { - GenBankFileType::Genomic => "_genomic.fna.gz", - GenBankFileType::Protein => "_protein.faa.gz", - GenBankFileType::AssemblyReport => "_assembly_report.txt", - GenBankFileType::Checksum => "md5checksums.txt", +impl Default for BuildParams { + fn default() -> Self { + BuildParams { + ksize: 31, + abundance: Abundance::NoAbund, + num: 0, + scaled: 1000, + seed: 42, + moltype: HashFunctions::Murmur64Dna, } } +} - //use for checksums - pub fn server_filename(&self, full_name: &str) -> String { - format!("{}{}", full_name, self.suffix()) +impl Hash for BuildParams { + fn hash(&self, state: &mut H) { + self.ksize.hash(state); + self.abundance.hash(state); + self.num.hash(state); + self.scaled.hash(state); + self.seed.hash(state); + self.moltype.hash(state); } +} - pub fn filename_to_write(&self, accession: &str) -> String { - match self { - GenBankFileType::Checksum => format!("{}_{}", accession, self.suffix()), - _ => format!("{}{}", accession, self.suffix()), - } +impl BuildParams { + pub fn calculate_hash(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.hash(&mut hasher); // Use the Hash trait implementation + hasher.finish() // Return the final u64 hash value } - pub fn url(&self, base_url: &Url, full_name: &str) -> Url { - match self { - GenBankFileType::Checksum => base_url - .join(&format!("{}/{}", full_name, self.suffix())) - .unwrap(), - _ => base_url - .join(&format!("{}/{}{}", full_name, full_name, self.suffix())) - .unwrap(), - } - } + pub fn from_record(record: &Record) -> Self { + let moltype = record.moltype(); // Get the moltype (HashFunctions enum) - pub fn moltype(&self) -> String { - match self { - GenBankFileType::Genomic => "DNA".to_string(), - GenBankFileType::Protein => "protein".to_string(), - _ => "".to_string(), + BuildParams { + ksize: record.ksize(), + abundance: if record.with_abundance() { + Abundance::Abund + } else { + Abundance::NoAbund + }, + num: *record.num(), + scaled: *record.scaled(), + seed: 42, + moltype, } } } -#[allow(dead_code)] -#[derive(Clone)] -pub struct AccessionData { - pub accession: String, - pub name: String, - pub moltype: InputMolType, - pub url: reqwest::Url, - pub expected_md5sum: Option, - pub download_filename: Option, // need to require this if --keep-fastas are used -} -#[derive(Clone)] -pub struct GBAssemblyData { - pub accession: String, - pub name: String, - pub url: Option, +// helper functions for paramstr parsing +fn parse_paramstr_value(value: &str, field: &str) -> Result { + value + .parse() + .map_err(|_| format!("cannot parse {}='{}' as a number", field, value)) } -pub fn load_gbassembly_info(input_csv: String) -> Result<(Vec, usize)> { - let mut results = Vec::new(); - let mut row_count = 0; - let mut processed_rows = std::collections::HashSet::new(); - let mut duplicate_count = 0; - let mut url_count = 0; // Counter for entries with URL - // to do - maybe use HashSet for accessions too to avoid incomplete dupes - let mut rdr = csv::Reader::from_path(input_csv)?; - - // Check column names - let header = rdr.headers()?; - let expected_header = vec!["accession", "name", "ftp_path"]; - if header != expected_header { - return Err(anyhow!( - "Invalid column names in CSV file. Columns should be: {:?}", - expected_header - )); - } +#[derive(Debug, Default)] +pub struct BuildParamsSet { + params: HashSet, +} - for result in rdr.records() { - let record = result?; - let row_string = record.iter().collect::>().join(","); - if processed_rows.contains(&row_string) { - duplicate_count += 1; - continue; +impl BuildParamsSet { + pub fn new() -> Self { + Self { + params: HashSet::new(), } - processed_rows.insert(row_string.clone()); - row_count += 1; - - // require acc, name - let acc = record - .get(0) - .ok_or_else(|| anyhow!("Missing 'accession' field"))? - .to_string(); - let name = record - .get(1) - .ok_or_else(|| anyhow!("Missing 'name' field"))? - .to_string(); - // optionally get url - let url = record.get(2).and_then(|s| { - if s.is_empty() { - None - } else { - let trimmed_s = s.trim_end_matches('/'); - reqwest::Url::parse(trimmed_s).map_err(|_| ()).ok() - } - }); + } - if url.is_some() { - url_count += 1; - } - // store accession data - results.push(GBAssemblyData { - accession: acc.to_string(), - name: name.to_string(), - url, - }); + pub fn insert(&mut self, param: BuildParams) { + self.params.insert(param); } - // Print warning if there were duplicated rows. - if duplicate_count > 0 { - println!("Warning: {} duplicated rows were skipped.", duplicate_count); + pub fn iter(&self) -> impl Iterator { + self.params.iter() } - println!( - "Loaded {} rows (including {} rows with valid URL).", - row_count, url_count - ); - Ok((results, row_count)) -} + pub fn from_params_str(params_str: String) -> Result { + let mut set = BuildParamsSet::new(); -pub fn load_accession_info( - input_csv: String, - keep_fasta: bool, -) -> Result<(Vec, usize)> { - let mut results = Vec::new(); - let mut row_count = 0; - let mut processed_rows = std::collections::HashSet::new(); - let mut duplicate_count = 0; - let mut md5sum_count = 0; // Counter for entries with MD5sum - // to do - maybe use HashSet for accessions too to avoid incomplete dupes - let mut rdr = csv::Reader::from_path(input_csv)?; - - // Check column names - let header = rdr.headers()?; - let expected_header = vec![ - "accession", - "name", - "moltype", - "md5sum", - "download_filename", - "url", - ]; - if header != expected_header { - return Err(anyhow!( - "Invalid column names in CSV file. Columns should be: {:?}", - expected_header - )); - } + for p_str in params_str.split('_') { + let mut base_param = BuildParams::default(); + let mut ksizes = Vec::new(); - for result in rdr.records() { - let record = result?; - let row_string = record.iter().collect::>().join(","); - if processed_rows.contains(&row_string) { - duplicate_count += 1; - continue; - } - processed_rows.insert(row_string.clone()); - row_count += 1; + for item in p_str.split(',') { + match item { + _ if item.starts_with("k=") => { + ksizes.push(parse_paramstr_value(&item[2..], "k")?) + } - // require acc, name - let acc = record - .get(0) - .ok_or_else(|| anyhow!("Missing 'accession' field"))? - .to_string(); - let name = record - .get(1) - .ok_or_else(|| anyhow!("Missing 'name' field"))? - .to_string(); - let moltype = record - .get(2) - .ok_or_else(|| anyhow!("Missing 'moltype' field"))? - .parse::() - .map_err(|_| anyhow!("Invalid 'moltype' value"))?; - let expected_md5sum = record.get(3).map(|s| s.to_string()); - let download_filename = record.get(4).map(|s| s.to_string()); - if keep_fasta && download_filename.is_none() { - return Err(anyhow!("Missing 'download_filename' field")); - } - let url = record - .get(5) - .ok_or_else(|| anyhow!("Missing 'url' field"))? - .split(',') - .filter_map(|s| { - if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("ftp://") - { - reqwest::Url::parse(s).ok() - } else { - None - } - }) - .next() - .ok_or_else(|| anyhow!("Invalid 'url' value"))?; - // count entries with url and md5sum - if expected_md5sum.is_some() { - md5sum_count += 1; - } - // store accession data - results.push(AccessionData { - accession: acc, - name, - moltype, - url, - expected_md5sum, - download_filename, - }); - } + // Set abundance using the Abundance enum + "abund" => base_param.abundance = Abundance::Abund, + "noabund" => base_param.abundance = Abundance::NoAbund, - // Print warning if there were duplicated rows. - if duplicate_count > 0 { - println!("Warning: {} duplicated rows were skipped.", duplicate_count); - } - println!( - "Loaded {} rows (including {} rows with MD5sum).", - row_count, md5sum_count - ); + _ if item.starts_with("num=") => { + base_param.num = parse_paramstr_value(&item[4..], "num")? + } + _ if item.starts_with("scaled=") => { + base_param.scaled = parse_paramstr_value(&item[7..], "scaled")? + } + _ if item.starts_with("seed=") => { + base_param.seed = parse_paramstr_value(&item[5..], "seed")? + } - Ok((results, row_count)) -} + // Set moltype using the existing HashFunctions enum + "protein" => base_param.moltype = HashFunctions::Murmur64Protein, + "dna" => base_param.moltype = HashFunctions::Murmur64Dna, + "dayhoff" => base_param.moltype = HashFunctions::Murmur64Dayhoff, + "hp" => base_param.moltype = HashFunctions::Murmur64Hp, -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BuildParams { - pub ksize: u32, - pub track_abundance: bool, - pub num: u32, - pub scaled: u64, - pub seed: u32, - pub is_protein: bool, - pub is_dayhoff: bool, - pub is_hp: bool, - pub is_dna: bool, -} + _ => return Err(format!("unknown component '{}' in params string", item)), + } + } -impl Hash for BuildParams { - fn hash(&self, state: &mut H) { - self.ksize.hash(state); - self.track_abundance.hash(state); - self.num.hash(state); - self.scaled.hash(state); - self.seed.hash(state); - self.is_protein.hash(state); - self.is_dayhoff.hash(state); - self.is_hp.hash(state); - self.is_dna.hash(state); - } -} + // Create a BuildParams for each ksize and add to the set + for &k in &ksizes { + let mut param = base_param.clone(); + param.ksize = k; + set.insert(param); + } + } -impl BuildParams { - pub fn calculate_hash(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.hash(&mut hasher); // Use the Hash trait implementation - hasher.finish() // Return the final u64 hash value + Ok(set) } - pub fn from_record(record: &Record) -> Self { - let moltype = record.moltype(); // Get the moltype (HashFunctions enum) + pub fn get_params(&self) -> &HashSet { + &self.params + } - BuildParams { - ksize: record.ksize(), - track_abundance: record.with_abundance(), - num: *record.num(), - scaled: *record.scaled(), - seed: 42, - is_protein: moltype.protein(), - is_dayhoff: moltype.dayhoff(), - is_hp: moltype.hp(), - is_dna: moltype.dna(), - } + pub fn into_vec(self) -> Vec { + self.params.into_iter().collect() } } @@ -391,19 +229,19 @@ where } } +// input moltype here? or params moltype??? impl BuildRecord { pub fn from_buildparams(param: &BuildParams, input_moltype: &str) -> Self { // Calculate the hash of Params - let mut hasher = DefaultHasher::new(); - param.hash(&mut hasher); - let hashed_params = hasher.finish(); + let hashed_params = param.calculate_hash(); BuildRecord { ksize: param.ksize, moltype: input_moltype.to_string(), + // moltype: param.moltype.to_string(), num: param.num, scaled: param.scaled, - with_abundance: param.track_abundance, + with_abundance: matches!(param.abundance, Abundance::Abund), // Convert the Abundance enum to a boolean hashed_params, ..Default::default() // automatically set optional fields to None } @@ -541,30 +379,48 @@ impl BuildCollection { collection } + pub fn from_buildparams_set(params_set: &BuildParamsSet, input_moltype: &str) -> Self { + let mut collection = BuildCollection::new(); + + for param in params_set.iter().cloned() { + collection.add_template_sig(param, input_moltype); + } + + collection + } + pub fn add_template_sig(&mut self, param: BuildParams, input_moltype: &str) { // Check the input_moltype against Params to decide if this should be added - match input_moltype { - "dna" | "DNA" if !param.is_dna => return, // Skip if it's not the correct moltype - "protein" if !param.is_protein && !param.is_dayhoff && !param.is_hp => return, + match input_moltype.to_lowercase().as_str() { + "dna" if param.moltype != HashFunctions::Murmur64Dna => return, // Skip if it's not DNA + "protein" + if param.moltype != HashFunctions::Murmur64Protein + && param.moltype != HashFunctions::Murmur64Dayhoff + && param.moltype != HashFunctions::Murmur64Hp => + { + return + } // Skip if not a protein type _ => (), } - let adjusted_ksize = if param.is_protein || param.is_dayhoff || param.is_hp { - param.ksize * 3 - } else { - param.ksize + // Adjust ksize for protein, dayhoff, or hp, which typically require tripling the k-mer size + let adjusted_ksize = match param.moltype { + HashFunctions::Murmur64Protein + | HashFunctions::Murmur64Dayhoff + | HashFunctions::Murmur64Hp => param.ksize * 3, + _ => param.ksize, }; // Construct ComputeParameters let cp = ComputeParameters::builder() .ksizes(vec![adjusted_ksize]) .scaled(param.scaled) - .protein(param.is_protein) - .dna(param.is_dna) - .dayhoff(param.is_dayhoff) - .hp(param.is_hp) + .protein(param.moltype == HashFunctions::Murmur64Protein) + .dna(param.moltype == HashFunctions::Murmur64Dna) + .dayhoff(param.moltype == HashFunctions::Murmur64Dayhoff) + .hp(param.moltype == HashFunctions::Murmur64Hp) .num_hashes(param.num) - .track_abundance(param.track_abundance) + .track_abundance(param.abundance == Abundance::Abund) .build(); // Create a Signature from the ComputeParameters @@ -793,125 +649,6 @@ impl MultiBuildCollection { } } -pub fn parse_params_str(params_strs: String) -> Result, String> { - let mut unique_params: std::collections::HashSet = - std::collections::HashSet::new(); - - // split params_strs by _ and iterate over each param - for p_str in params_strs.split('_').collect::>().iter() { - let items: Vec<&str> = p_str.split(',').collect(); - - let mut ksizes = Vec::new(); - let mut track_abundance = false; - let mut num = 0; - let mut scaled = 1000; - let mut seed = 42; - let mut is_protein = false; - let mut is_dayhoff = false; - let mut is_hp = false; - let mut is_dna = false; - - for item in items.iter() { - match *item { - _ if item.starts_with("k=") => { - let k_value = item[2..] - .parse() - .map_err(|_| format!("cannot parse k='{}' as a number", &item[2..]))?; - ksizes.push(k_value); - } - "abund" => track_abundance = true, - "noabund" => track_abundance = false, - _ if item.starts_with("num=") => { - num = item[4..] - .parse() - .map_err(|_| format!("cannot parse num='{}' as a number", &item[4..]))?; - } - _ if item.starts_with("scaled=") => { - scaled = item[7..] - .parse() - .map_err(|_| format!("cannot parse scaled='{}' as a number", &item[7..]))?; - } - _ if item.starts_with("seed=") => { - seed = item[5..] - .parse() - .map_err(|_| format!("cannot parse seed='{}' as a number", &item[5..]))?; - } - "protein" => { - is_protein = true; - } - "dna" => { - is_dna = true; - } - "dayhoff" => { - is_dayhoff = true; - } - "hp" => { - is_hp = true; - } - _ => return Err(format!("unknown component '{}' in params string", item)), - } - } - - for &k in &ksizes { - let param = BuildParams { - ksize: k, - track_abundance, - num, - scaled, - seed, - is_protein, - is_dna, - is_dayhoff, - is_hp, - }; - unique_params.insert(param); - } - } - - Ok(unique_params.into_iter().collect()) -} - -// this should be replaced with branchwater's MultiCollection when it's ready -#[derive(Clone)] -pub struct MultiCollection { - collections: Vec, -} - -impl MultiCollection { - pub fn new(collections: Vec) -> Self { - Self { collections } - } - - pub fn is_empty(&self) -> bool { - self.collections.is_empty() - } - - pub fn buildparams_hashmap(&self) -> HashMap> { - let mut name_params_map = HashMap::new(); - - // Iterate over all collections in MultiCollection - for collection in &self.collections { - // Iterate over all records in the current collection - for (_, record) in collection.iter() { - // Get the record's name or fasta filename - let record_name = record.name().clone(); - - // Calculate the hash of the Params for the current record - let params_hash = BuildParams::from_record(record).calculate_hash(); - - // If the name is already in the HashMap, extend the existing HashSet - // Otherwise, create a new HashSet and insert the hashed Params - name_params_map - .entry(record_name) - .or_insert_with(HashSet::new) // Create a new HashSet if the key doesn't exist - .insert(params_hash); // Insert the hashed Params into the HashSet - } - } - - name_params_map - } -} - #[cfg(test)] mod tests { use super::*; @@ -920,26 +657,14 @@ mod tests { fn test_buildparams_consistent_hashing() { let params1 = BuildParams { ksize: 31, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + abundance: Abundance::Abund, + ..Default::default() }; let params2 = BuildParams { ksize: 31, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + abundance: Abundance::Abund, + ..Default::default() }; let hash1 = params1.calculate_hash(); @@ -959,36 +684,33 @@ mod tests { fn test_buildparams_hashing_different() { let params1 = BuildParams { ksize: 31, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + ..Default::default() }; let params2 = BuildParams { ksize: 21, // Changed ksize - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + ..Default::default() + }; + + let params3 = BuildParams { + ksize: 31, + moltype: HashFunctions::Murmur64Protein, + ..Default::default() }; let hash1 = params1.calculate_hash(); let hash2 = params2.calculate_hash(); + let hash3 = params3.calculate_hash(); // Check that the hash for different Params is different assert_ne!( hash1, hash2, "Hashes for different Params should not be equal" ); + assert_ne!( + hash1, hash3, + "Hashes for different Params should not be equal" + ); } #[test] @@ -1016,14 +738,8 @@ mod tests { // create the expected Params based on the Record data let expected_params = BuildParams { ksize: 31, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + abundance: Abundance::Abund, + ..Default::default() }; // // Generate the Params from the Record using the from_record method @@ -1052,38 +768,22 @@ mod tests { fn test_filter_removes_matching_buildparams() { let params1 = BuildParams { ksize: 31, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + abundance: Abundance::Abund, + ..Default::default() }; let params2 = BuildParams { ksize: 21, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + abundance: Abundance::Abund, + // moltype: HashFunctions::Murmur64Protein, + ..Default::default() }; let params3 = BuildParams { ksize: 31, - track_abundance: true, - num: 0, scaled: 2000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, + abundance: Abundance::Abund, + ..Default::default() }; let params_list = [params1.clone(), params2.clone(), params3.clone()]; @@ -1116,73 +816,4 @@ mod tests { h2 ); } - - #[test] - fn test_buildparams_hashmap() { - // read in zipfiles to build a MultiCollection - // load signature + build record - let mut filename = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")); - filename.push("tests/test-data/GCA_000961135.2.sig.zip"); - let path = filename.clone(); - - let mut collections = Vec::new(); - let coll = Collection::from_zipfile(&path).unwrap(); - collections.push(coll); - let mc = MultiCollection::new(collections); - - // Call build_params_hashmap - let name_params_map = mc.buildparams_hashmap(); - - // Check that the HashMap contains the correct names - assert_eq!( - name_params_map.len(), - 1, - "There should be 1 unique names in the map" - ); - - let mut hashed_params = Vec::new(); - for (name, params_set) in name_params_map.iter() { - eprintln!("Name: {}", name); - for param_hash in params_set { - eprintln!(" Param Hash: {}", param_hash); - hashed_params.push(param_hash); - } - } - - let expected_params1 = BuildParams { - ksize: 31, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, - }; - - let expected_params2 = BuildParams { - ksize: 21, - track_abundance: true, - num: 0, - scaled: 1000, - seed: 42, - is_protein: false, - is_dayhoff: false, - is_hp: false, - is_dna: true, - }; - - let expected_hash1 = expected_params1.calculate_hash(); - let expected_hash2 = expected_params2.calculate_hash(); - - assert!( - hashed_params.contains(&&expected_hash1), - "Expected hash1 should be in the hashed_params" - ); - assert!( - hashed_params.contains(&&expected_hash2), - "Expected hash2 should be in the hashed_params" - ); - } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..f6d0a57 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,378 @@ +use anyhow::{anyhow, Result}; +use reqwest::Url; +use sourmash::collection::Collection; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; + +pub mod buildutils; +use crate::utils::buildutils::BuildParams; + +#[derive(Clone)] +pub enum InputMolType { + Dna, + Protein, +} + +impl InputMolType {} + +impl fmt::Display for InputMolType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + InputMolType::Dna => write!(f, "DNA"), + InputMolType::Protein => write!(f, "protein"), + } + } +} + +impl std::str::FromStr for InputMolType { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "dna" => Ok(InputMolType::Dna), + "protein" => Ok(InputMolType::Protein), + _ => Err(()), + } + } +} + +#[allow(dead_code)] +pub enum GenBankFileType { + Genomic, + Protein, + AssemblyReport, + Checksum, +} + +impl GenBankFileType { + pub fn suffix(&self) -> &'static str { + match self { + GenBankFileType::Genomic => "_genomic.fna.gz", + GenBankFileType::Protein => "_protein.faa.gz", + GenBankFileType::AssemblyReport => "_assembly_report.txt", + GenBankFileType::Checksum => "md5checksums.txt", + } + } + + //use for checksums + pub fn server_filename(&self, full_name: &str) -> String { + format!("{}{}", full_name, self.suffix()) + } + + pub fn filename_to_write(&self, accession: &str) -> String { + match self { + GenBankFileType::Checksum => format!("{}_{}", accession, self.suffix()), + _ => format!("{}{}", accession, self.suffix()), + } + } + + pub fn url(&self, base_url: &Url, full_name: &str) -> Url { + match self { + GenBankFileType::Checksum => base_url + .join(&format!("{}/{}", full_name, self.suffix())) + .unwrap(), + _ => base_url + .join(&format!("{}/{}{}", full_name, full_name, self.suffix())) + .unwrap(), + } + } + + pub fn moltype(&self) -> String { + match self { + GenBankFileType::Genomic => "DNA".to_string(), + GenBankFileType::Protein => "protein".to_string(), + _ => "".to_string(), + } + } +} +#[allow(dead_code)] +#[derive(Clone)] +pub struct AccessionData { + pub accession: String, + pub name: String, + pub moltype: InputMolType, + pub url: reqwest::Url, + pub expected_md5sum: Option, + pub download_filename: Option, // need to require this if --keep-fastas are used +} + +#[derive(Clone)] +pub struct GBAssemblyData { + pub accession: String, + pub name: String, + pub url: Option, +} + +pub fn load_gbassembly_info(input_csv: String) -> Result<(Vec, usize)> { + let mut results = Vec::new(); + let mut row_count = 0; + let mut processed_rows = std::collections::HashSet::new(); + let mut duplicate_count = 0; + let mut url_count = 0; // Counter for entries with URL + // to do - maybe use HashSet for accessions too to avoid incomplete dupes + let mut rdr = csv::Reader::from_path(input_csv)?; + + // Check column names + let header = rdr.headers()?; + let expected_header = vec!["accession", "name", "ftp_path"]; + if header != expected_header { + return Err(anyhow!( + "Invalid column names in CSV file. Columns should be: {:?}", + expected_header + )); + } + + for result in rdr.records() { + let record = result?; + let row_string = record.iter().collect::>().join(","); + if processed_rows.contains(&row_string) { + duplicate_count += 1; + continue; + } + processed_rows.insert(row_string.clone()); + row_count += 1; + + // require acc, name + let acc = record + .get(0) + .ok_or_else(|| anyhow!("Missing 'accession' field"))? + .to_string(); + let name = record + .get(1) + .ok_or_else(|| anyhow!("Missing 'name' field"))? + .to_string(); + // optionally get url + let url = record.get(2).and_then(|s| { + if s.is_empty() { + None + } else { + let trimmed_s = s.trim_end_matches('/'); + reqwest::Url::parse(trimmed_s).map_err(|_| ()).ok() + } + }); + + if url.is_some() { + url_count += 1; + } + // store accession data + results.push(GBAssemblyData { + accession: acc.to_string(), + name: name.to_string(), + url, + }); + } + + // Print warning if there were duplicated rows. + if duplicate_count > 0 { + println!("Warning: {} duplicated rows were skipped.", duplicate_count); + } + println!( + "Loaded {} rows (including {} rows with valid URL).", + row_count, url_count + ); + + Ok((results, row_count)) +} + +pub fn load_accession_info( + input_csv: String, + keep_fasta: bool, +) -> Result<(Vec, usize)> { + let mut results = Vec::new(); + let mut row_count = 0; + let mut processed_rows = std::collections::HashSet::new(); + let mut duplicate_count = 0; + let mut md5sum_count = 0; // Counter for entries with MD5sum + // to do - maybe use HashSet for accessions too to avoid incomplete dupes + let mut rdr = csv::Reader::from_path(input_csv)?; + + // Check column names + let header = rdr.headers()?; + let expected_header = vec![ + "accession", + "name", + "moltype", + "md5sum", + "download_filename", + "url", + ]; + if header != expected_header { + return Err(anyhow!( + "Invalid column names in CSV file. Columns should be: {:?}", + expected_header + )); + } + + for result in rdr.records() { + let record = result?; + let row_string = record.iter().collect::>().join(","); + if processed_rows.contains(&row_string) { + duplicate_count += 1; + continue; + } + processed_rows.insert(row_string.clone()); + row_count += 1; + + // require acc, name + let acc = record + .get(0) + .ok_or_else(|| anyhow!("Missing 'accession' field"))? + .to_string(); + let name = record + .get(1) + .ok_or_else(|| anyhow!("Missing 'name' field"))? + .to_string(); + let moltype = record + .get(2) + .ok_or_else(|| anyhow!("Missing 'moltype' field"))? + .parse::() + .map_err(|_| anyhow!("Invalid 'moltype' value"))?; + let expected_md5sum = record.get(3).map(|s| s.to_string()); + let download_filename = record.get(4).map(|s| s.to_string()); + if keep_fasta && download_filename.is_none() { + return Err(anyhow!("Missing 'download_filename' field")); + } + let url = record + .get(5) + .ok_or_else(|| anyhow!("Missing 'url' field"))? + .split(',') + .filter_map(|s| { + if s.starts_with("http://") || s.starts_with("https://") || s.starts_with("ftp://") + { + reqwest::Url::parse(s).ok() + } else { + None + } + }) + .next() + .ok_or_else(|| anyhow!("Invalid 'url' value"))?; + // count entries with url and md5sum + if expected_md5sum.is_some() { + md5sum_count += 1; + } + // store accession data + results.push(AccessionData { + accession: acc, + name, + moltype, + url, + expected_md5sum, + download_filename, + }); + } + + // Print warning if there were duplicated rows. + if duplicate_count > 0 { + println!("Warning: {} duplicated rows were skipped.", duplicate_count); + } + println!( + "Loaded {} rows (including {} rows with MD5sum).", + row_count, md5sum_count + ); + + Ok((results, row_count)) +} + +// this should be replaced with branchwater's MultiCollection when it's ready +#[derive(Clone)] +pub struct MultiCollection { + collections: Vec, +} + +impl MultiCollection { + pub fn new(collections: Vec) -> Self { + Self { collections } + } + + pub fn is_empty(&self) -> bool { + self.collections.is_empty() + } + + pub fn buildparams_hashmap(&self) -> HashMap> { + let mut name_params_map = HashMap::new(); + + // Iterate over all collections in MultiCollection + for collection in &self.collections { + // Iterate over all records in the current collection + for (_, record) in collection.iter() { + // Get the record's name or fasta filename + let record_name = record.name().clone(); + + // Calculate the hash of the Params for the current record + let params_hash = BuildParams::from_record(record).calculate_hash(); + + // If the name is already in the HashMap, extend the existing HashSet + // Otherwise, create a new HashSet and insert the hashed Params + name_params_map + .entry(record_name) + .or_insert_with(HashSet::new) // Create a new HashSet if the key doesn't exist + .insert(params_hash); // Insert the hashed Params into the HashSet + } + } + + name_params_map + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::buildutils::Abundance; + use camino::Utf8PathBuf; + #[test] + fn test_buildparams_hashmap() { + // read in zipfiles to build a MultiCollection + // load signature + build record + let mut filename = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")); + filename.push("tests/test-data/GCA_000961135.2.sig.zip"); + let path = filename.clone(); + + let mut collections = Vec::new(); + let coll: Collection = Collection::from_zipfile(&path).unwrap(); + collections.push(coll); + let mc = MultiCollection::new(collections); + + // Call build_params_hashmap + let name_params_map = mc.buildparams_hashmap(); + + // Check that the HashMap contains the correct names + assert_eq!( + name_params_map.len(), + 1, + "There should be 1 unique names in the map" + ); + + let mut hashed_params = Vec::new(); + for (name, params_set) in name_params_map.iter() { + eprintln!("Name: {}", name); + for param_hash in params_set { + eprintln!(" Param Hash: {}", param_hash); + hashed_params.push(param_hash); + } + } + let expected_params1 = BuildParams { + ksize: 31, + abundance: Abundance::Abund, + ..Default::default() + }; + + let expected_params2 = BuildParams { + ksize: 21, + abundance: Abundance::Abund, + ..Default::default() + }; + + let expected_hash1 = expected_params1.calculate_hash(); + let expected_hash2 = expected_params2.calculate_hash(); + + assert!( + hashed_params.contains(&&expected_hash1), + "Expected hash1 should be in the hashed_params" + ); + assert!( + hashed_params.contains(&&expected_hash2), + "Expected hash2 should be in the hashed_params" + ); + } +} diff --git a/tests/test-data/GCA_000961135.2.sig.zip b/tests/test-data/GCA_000961135.2.sig.zip new file mode 100644 index 0000000000000000000000000000000000000000..3cdf34bee0739c080f45eda22819762e8ed4b26f GIT binary patch literal 31713 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 H|9bjgg^nEE literal 0 HcmV?d00001