Skip to main content

rust_lib_reference_genome/
reference_genome.rs

1
2use anyhow::ensure;
3use bio::io::fasta;
4use flate2::bufread::MultiGzDecoder;
5use indexmap::IndexMap;
6use log::{debug, warn};
7use std::io::{BufRead, BufReader};
8use std::path::{Path, PathBuf};
9
10/// Wrapper structure for a reference genome
11pub struct ReferenceGenome {
12    /// The filename we loaded 
13    filename: PathBuf,
14    /// Map where keys are contig names and value is ASCII formatted sequence
15    contig_map: IndexMap<String, Vec<u8>>
16}
17
18impl ReferenceGenome {
19    /// Creates an empty reference genome, which we can be populated through `add_contig(...)`
20    pub fn empty_reference() -> Self {
21        Self {
22            filename: PathBuf::from(""),
23            contig_map: Default::default()
24        }
25    }
26
27    /// Loads a reference genome from a given FASTA file
28    /// # Arguments
29    /// * `fasta_fn` - the FASTA filename, gzip is allowed
30    /// # Errors
31    /// * if the FASTA file cannot be opened or read
32    /// * if a FASTA record cannot be parsed
33    /// * if a contig key is duplicated in the FASTA file
34    pub fn from_fasta(fasta_fn: &Path) -> anyhow::Result<ReferenceGenome> {
35        debug!("Loading {:?}...", fasta_fn);
36        
37        // needletail can technically read FASTA and FASTQ, not sure we can check for that easy though
38        let fasta_file: std::fs::File = std::fs::File::open(fasta_fn)?;
39        let file_reader = BufReader::new(fasta_file);
40        let fasta_reader: fasta::Reader<Box<dyn BufRead>> = if fasta_fn.extension().unwrap_or_default() == "gz" {
41            debug!("Detected gzip extension, loading reference with MultiGzDecoder...");
42            let gz_decoder = MultiGzDecoder::new(file_reader);
43            let bufreader = BufReader::new(gz_decoder);
44            fasta::Reader::from_bufread(Box::new(bufreader))
45        } else {
46            debug!("Loading reference as plain-text file...");
47            fasta::Reader::from_bufread(Box::new(file_reader))
48        };
49
50        // create a new reference genome where we will add contigs as we read them
51        let mut reference_genome = Self {
52            filename: fasta_fn.to_path_buf(),
53            contig_map: Default::default()
54        };
55
56        // read the FASTA file and add contigs to the reference genome
57        for entry in fasta_reader.records() {
58            let record: fasta::Record = entry?;
59            let seq_id = record.id().to_string();
60            let sequence = record.seq().to_vec();
61            reference_genome.add_contig(seq_id, sequence)?;
62        }
63        debug!("Finished loading {} contigs.", reference_genome.contig_map.len());
64
65        Ok(reference_genome)
66    }
67
68    /// Adds a new contig to the reference genome
69    /// # Arguments
70    /// * `contig_key` - the name of the contig
71    /// * `contig_sequence` - the sequence to add; all sequence is automatically upper-cased in place
72    /// # Errors
73    /// * if `contig_key` is already in the reference genome
74    pub fn add_contig(&mut self, contig_key: String, mut contig_sequence: Vec<u8>) -> anyhow::Result<()> {
75        // check for a duplicate contig key
76        ensure!(
77            !self.contig_map.contains_key(&contig_key),
78            "Duplicate contig key detected: {contig_key}"
79        );
80
81        // make the sequence uppercase in place and then add it to the lookup map
82        contig_sequence.make_ascii_uppercase();
83        self.contig_map.insert(contig_key, contig_sequence);
84        Ok(())
85    }
86
87    /// Adds a new contig from a UTF-8 string by converting it to bytes and calling [`Self::add_contig`].
88    /// # Arguments
89    /// * `contig_key` - the name of the contig
90    /// * `contig_sequence` - the sequence to add; all sequence is automatically upper-cased
91    /// # Errors
92    /// * if `contig_key` is already in the reference genome
93    pub fn add_contig_string(&mut self, contig_key: String, contig_sequence: String) -> anyhow::Result<()> {
94        self.add_contig(contig_key, contig_sequence.into_bytes())
95    }
96
97    /// Returns the filename of the reference genome.
98    pub fn filename(&self) -> &Path {
99        &self.filename
100    }
101
102    /// Returns an iterator over the contig keys in the reference genome.
103    /// The order of the contig keys is guaranteed to be the same as the order as they were added to the reference genome.
104    pub fn contig_keys(&self) -> impl Iterator<Item = &String> {
105        self.contig_map.keys()
106    }
107
108    /// Retrieves a reference slice from a given 0-based coordinates.
109    /// If `start` or `end` goes past the full contig length, it will be truncated to the full contig length.
110    /// # Arguments
111    /// * `chromosome` - the chromosome to slice from
112    /// * `start` - the 0-based start index (included)
113    /// * `end` - the 0-based end index (excluded)
114    /// # Errors
115    /// * if `chromosome` was not in the FASTA file
116    /// * if `start` > `end`
117    pub fn get_slice(&self, chromosome: &str, start: usize, end: usize) -> anyhow::Result<&[u8]> {
118        // check the inputs
119        let full_contig = self.contig_map.get(chromosome)
120            .ok_or_else(|| anyhow::anyhow!("chromosome {chromosome:?} was not in the reference file"))?;
121        ensure!(start <= end, "start > end: {start} > {end}");
122
123        // truncate the start and end if they go past the full contig length
124        let truncated_start = if start <= full_contig.len() { start } else {
125            warn!("Received get_slice({:?}, {}, {}), truncated start to {}", chromosome, start, end, full_contig.len());
126            full_contig.len()
127        };
128        let truncated_end = if end <= full_contig.len() { end } else {
129            warn!("Received get_slice({:?}, {}, {}), truncated end to {}", chromosome, start, end, full_contig.len());
130            full_contig.len()
131        };
132        Ok(&full_contig[truncated_start..truncated_end])
133    }
134
135    /// Retrieves a full chromosome by name
136    /// # Arguments
137    /// * `chromosome` - the chromosome to slice from
138    /// # Errors
139    /// * if `chromosome` was not in the FASTA file
140    pub fn get_full_chromosome(&self, chromosome: &str) -> anyhow::Result<&[u8]> {
141        self.contig_map.get(chromosome)
142            .map(|s| s.as_slice())
143            .ok_or_else(|| anyhow::anyhow!("chromosome {chromosome:?} was not in the reference file"))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::path::PathBuf;
151
152    #[test]
153    fn test_simple_reference() {
154        let references = vec![
155            "./test_data/test_reference.fa",
156            "./test_data/test_reference.fa.gz"
157        ];
158        for &reference_fn in references.iter() {
159            let simple_reference_fn: PathBuf = PathBuf::from(reference_fn);
160            let reference_genome = ReferenceGenome::from_fasta(&simple_reference_fn).unwrap();
161
162            assert_eq!(
163                reference_genome.contig_keys().map(String::as_str).collect::<Vec<_>>(),
164                ["chr1", "chr2"]
165            );
166
167            //chr1 = ACGTACGT
168            let chr1_string: Vec<u8> = "ACGTACGT".as_bytes().to_vec();
169            for i in 0..8 {
170                assert_eq!(reference_genome.get_slice(&"chr1", i, 8).unwrap(), &chr1_string[i..]);
171            }
172
173            //chr2 = ACCATGTA
174            let chr2_string: Vec<u8> = "ACCATGTA".as_bytes().to_vec();
175            assert_eq!(reference_genome.get_slice(&"chr2", 0, 8).unwrap(), chr2_string);
176        }
177    }
178
179    #[test]
180    fn test_add_contig() {
181        let mut reference_genome = ReferenceGenome::empty_reference();
182        reference_genome.add_contig_string("test".to_string(), "Acgt".to_string()).unwrap();
183        reference_genome.add_contig_string("test2".to_string(), "TGNA".to_string()).unwrap();
184
185        assert_eq!(
186            reference_genome.contig_keys().map(String::as_str).collect::<Vec<_>>(),
187            ["test", "test2"]
188        );
189        assert_eq!(reference_genome.get_full_chromosome("test").unwrap(), b"ACGT");
190        assert_eq!(reference_genome.get_full_chromosome("test2").unwrap(), b"TGNA");
191    }
192
193    #[test]
194    fn test_lookup_errors() {
195        let mut reference_genome = ReferenceGenome::empty_reference();
196        reference_genome.add_contig("chr1".to_string(), b"ACGT".to_vec()).unwrap();
197
198        assert!(reference_genome.get_full_chromosome("chrX").is_err());
199        assert!(reference_genome.get_slice("chrX", 0, 1).is_err());
200        assert!(reference_genome.get_slice("chr1", 3, 1).is_err());
201        assert!(reference_genome.add_contig("chr1".to_string(), b"AAAA".to_vec()).is_err());
202    }
203}