1use rustc_hash::FxHashMap;
17use std::sync::LazyLock;
18
19#[derive(Debug, Clone, PartialEq)]
25pub enum SnpStatus {
26 NotApplicable,
28 Confirmed,
30 WildType,
32 NovelVariant(char),
34 NotCovered,
36 Unverified(String),
38}
39
40impl std::fmt::Display for SnpStatus {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 match self {
43 SnpStatus::NotApplicable => write!(f, "Acquired"),
44 SnpStatus::Confirmed => write!(f, "Confirmed"),
45 SnpStatus::WildType => write!(f, "WildType"),
46 SnpStatus::NovelVariant(aa) => write!(f, "Novel({})", aa),
47 SnpStatus::NotCovered => write!(f, "NotCovered"),
48 SnpStatus::Unverified(reason) => write!(f, "Unverified({})", reason),
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
59pub struct SnpInfo {
60 #[allow(dead_code)]
62 pub gene: String,
63 pub wildtype: char,
65 pub position: usize,
67 pub mutant: char,
69}
70
71impl SnpInfo {
72 pub fn nucleotide_range(&self) -> (usize, usize) {
75 let start = (self.position - 1) * 3;
76 let end = start + 3;
77 (start, end)
78 }
79}
80
81static CODON_TABLE: LazyLock<FxHashMap<&'static str, char>> = LazyLock::new(|| {
87 let mut table = FxHashMap::default();
88 table.insert("TTT", 'F'); table.insert("TTC", 'F');
90 table.insert("TTA", 'L'); table.insert("TTG", 'L');
92 table.insert("CTT", 'L'); table.insert("CTC", 'L');
93 table.insert("CTA", 'L'); table.insert("CTG", 'L');
94 table.insert("ATT", 'I'); table.insert("ATC", 'I'); table.insert("ATA", 'I');
96 table.insert("ATG", 'M');
98 table.insert("GTT", 'V'); table.insert("GTC", 'V');
100 table.insert("GTA", 'V'); table.insert("GTG", 'V');
101 table.insert("TCT", 'S'); table.insert("TCC", 'S');
103 table.insert("TCA", 'S'); table.insert("TCG", 'S');
104 table.insert("AGT", 'S'); table.insert("AGC", 'S');
105 table.insert("CCT", 'P'); table.insert("CCC", 'P');
107 table.insert("CCA", 'P'); table.insert("CCG", 'P');
108 table.insert("ACT", 'T'); table.insert("ACC", 'T');
110 table.insert("ACA", 'T'); table.insert("ACG", 'T');
111 table.insert("GCT", 'A'); table.insert("GCC", 'A');
113 table.insert("GCA", 'A'); table.insert("GCG", 'A');
114 table.insert("TAT", 'Y'); table.insert("TAC", 'Y');
116 table.insert("TAA", '*'); table.insert("TAG", '*'); table.insert("TGA", '*');
118 table.insert("CAT", 'H'); table.insert("CAC", 'H');
120 table.insert("CAA", 'Q'); table.insert("CAG", 'Q');
122 table.insert("AAT", 'N'); table.insert("AAC", 'N');
124 table.insert("AAA", 'K'); table.insert("AAG", 'K');
126 table.insert("GAT", 'D'); table.insert("GAC", 'D');
128 table.insert("GAA", 'E'); table.insert("GAG", 'E');
130 table.insert("TGT", 'C'); table.insert("TGC", 'C');
132 table.insert("TGG", 'W');
134 table.insert("CGT", 'R'); table.insert("CGC", 'R');
136 table.insert("CGA", 'R'); table.insert("CGG", 'R');
137 table.insert("AGA", 'R'); table.insert("AGG", 'R');
138 table.insert("GGT", 'G'); table.insert("GGC", 'G');
140 table.insert("GGA", 'G'); table.insert("GGG", 'G');
141 table
142});
143
144pub fn translate_codon(codon: &str) -> Option<char> {
146 let upper = codon.to_uppercase();
147 CODON_TABLE.get(upper.as_str()).copied()
148}
149
150#[allow(dead_code)]
159pub fn is_snp_gene(gene_name: &str) -> bool {
160 parse_snp_info(gene_name).is_some()
161}
162
163pub fn parse_snp_info(gene_name: &str) -> Option<SnpInfo> {
176 let name = gene_name.split('|').next().unwrap_or(gene_name);
178
179 let parts: Vec<&str> = name.rsplitn(2, '_').collect();
181 if parts.len() != 2 {
182 return None;
183 }
184
185 let snp_part = parts[0]; let gene = parts[1]; if snp_part.len() < 3 {
191 return None;
192 }
193
194 let chars: Vec<char> = snp_part.chars().collect();
195
196 let wildtype = chars[0].to_ascii_uppercase();
198 if !is_amino_acid(wildtype) {
199 return None;
200 }
201
202 let mutant = chars[chars.len() - 1].to_ascii_uppercase();
204 if !is_amino_acid(mutant) {
205 return None;
206 }
207
208 let pos_str: String = chars[1..chars.len()-1].iter().collect();
210 let position: usize = pos_str.parse().ok()?;
211
212 if position == 0 {
213 return None;
214 }
215
216 Some(SnpInfo {
217 gene: gene.to_string(),
218 wildtype,
219 position,
220 mutant,
221 })
222}
223
224fn is_amino_acid(c: char) -> bool {
226 matches!(c, 'A' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H' | 'I' | 'K' | 'L' |
227 'M' | 'N' | 'P' | 'Q' | 'R' | 'S' | 'T' | 'V' | 'W' | 'Y')
228}
229
230pub fn verify_snp(
248 contig_seq: &str,
249 gene_name: &str,
250 ref_start: usize,
251 ref_end: usize,
252 contig_start: usize,
253 contig_end: usize,
254 strand: char,
255) -> SnpStatus {
256 let snp_info = match parse_snp_info(gene_name) {
258 Some(info) => info,
259 None => return SnpStatus::NotApplicable,
260 };
261
262 let (snp_nt_start, snp_nt_end) = snp_info.nucleotide_range();
264
265 if snp_nt_start < ref_start || snp_nt_end > ref_end {
267 return SnpStatus::NotCovered;
268 }
269
270 let offset_in_ref = snp_nt_start - ref_start;
272 let contig_snp_start = if strand == '+' {
273 contig_start + offset_in_ref
274 } else {
275 contig_end - offset_in_ref - 3
277 };
278 let contig_snp_end = contig_snp_start + 3;
279
280 if contig_snp_end > contig_seq.len() {
282 return SnpStatus::NotCovered;
283 }
284
285 let codon_seq = &contig_seq[contig_snp_start..contig_snp_end];
287
288 let codon = if strand == '-' {
290 reverse_complement(codon_seq)
291 } else {
292 codon_seq.to_uppercase()
293 };
294
295 let amino_acid = match translate_codon(&codon) {
297 Some(aa) => aa,
298 None => return SnpStatus::Unverified(format!("invalid_codon:{}", codon)),
299 };
300
301 if amino_acid == snp_info.mutant {
303 SnpStatus::Confirmed
304 } else if amino_acid == snp_info.wildtype {
305 SnpStatus::WildType
306 } else {
307 SnpStatus::NovelVariant(amino_acid)
308 }
309}
310
311fn reverse_complement(seq: &str) -> String {
313 seq.chars()
314 .rev()
315 .map(|c| match c.to_ascii_uppercase() {
316 'A' => 'T',
317 'T' => 'A',
318 'G' => 'C',
319 'C' => 'G',
320 _ => 'N',
321 })
322 .collect()
323}
324
325#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn test_parse_snp_info() {
335 let info = parse_snp_info("rpoB_V146F").unwrap();
337 assert_eq!(info.gene, "rpoB");
338 assert_eq!(info.wildtype, 'V');
339 assert_eq!(info.position, 146);
340 assert_eq!(info.mutant, 'F');
341
342 let info = parse_snp_info("gyrA_S83L|QUINOLONE|QUINOLONE|J01M").unwrap();
344 assert_eq!(info.gene, "gyrA");
345 assert_eq!(info.wildtype, 'S');
346 assert_eq!(info.position, 83);
347 assert_eq!(info.mutant, 'L');
348
349 let info = parse_snp_info("test_A1G").unwrap();
351 assert_eq!(info.position, 1);
352
353 assert!(parse_snp_info("blaTEM-1").is_none());
355 assert!(parse_snp_info("tet(M)").is_none());
356 }
357
358 #[test]
359 fn test_is_snp_gene() {
360 assert!(is_snp_gene("rpoB_V146F"));
361 assert!(is_snp_gene("gyrA_S83L|QUINOLONE"));
362 assert!(!is_snp_gene("blaTEM-1"));
363 assert!(!is_snp_gene("tet(M)"));
364 }
365
366 #[test]
367 fn test_translate_codon() {
368 assert_eq!(translate_codon("ATG"), Some('M'));
369 assert_eq!(translate_codon("TTT"), Some('F'));
370 assert_eq!(translate_codon("TTC"), Some('F'));
371 assert_eq!(translate_codon("GTT"), Some('V'));
372 assert_eq!(translate_codon("TAA"), Some('*'));
373 assert_eq!(translate_codon("XXX"), None);
374 }
375
376 #[test]
377 fn test_nucleotide_range() {
378 let info = SnpInfo {
379 gene: "test".to_string(),
380 wildtype: 'V',
381 position: 146,
382 mutant: 'F',
383 };
384 assert_eq!(info.nucleotide_range(), (435, 438));
386
387 let info2 = SnpInfo {
388 gene: "test".to_string(),
389 wildtype: 'A',
390 position: 1,
391 mutant: 'B',
392 };
393 assert_eq!(info2.nucleotide_range(), (0, 3));
394 }
395
396 #[test]
397 fn test_verify_snp() {
398 let contig = "ATGTTTGGG"; let status = verify_snp(
405 contig,
406 "test_V2F", 0, 9, 0, 9, '+',
410 );
411 assert_eq!(status, SnpStatus::Confirmed);
412
413 let contig = "ATGGTTGGG"; let status = verify_snp(
416 contig,
417 "test_V2F",
418 0, 9,
419 0, 9,
420 '+',
421 );
422 assert_eq!(status, SnpStatus::WildType);
423
424 let contig = "ATGCTTGGG"; let status = verify_snp(
427 contig,
428 "test_V2F",
429 0, 9,
430 0, 9,
431 '+',
432 );
433 assert_eq!(status, SnpStatus::NovelVariant('L'));
434 }
435
436 #[test]
437 fn test_snp_status_display() {
438 assert_eq!(format!("{}", SnpStatus::Confirmed), "Confirmed");
439 assert_eq!(format!("{}", SnpStatus::WildType), "WildType");
440 assert_eq!(format!("{}", SnpStatus::NovelVariant('L')), "Novel(L)");
441 assert_eq!(format!("{}", SnpStatus::NotApplicable), "Acquired");
442 }
443}