1use crate::extract_sample_info::ParsedFormatSample;
2use crate::reformat_vcf::ReformattedVcfRecord;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq)]
6pub struct MafRecord {
7 pub hugo_symbol: String,
8 pub entrez_gene_id: Option<u32>,
9 pub center: String,
10 pub ncbi_build: String,
11 pub chromosome: String,
12 pub start_position: u64,
13 pub end_position: u64,
14 pub strand: String,
15 pub variant_classification: String,
16 pub variant_type: String,
17 pub reference_allele: String,
18 pub tumor_seq_allele1: String,
19 pub tumor_seq_allele2: String,
20 pub dbsnp_rs: Option<String>,
21 pub dbsnp_val_status: Option<String>,
22 pub tumor_sample_barcode: String,
23 pub matched_norm_sample_barcode: Option<String>,
24 pub validation_status: Option<String>,
25 pub mutation_status: String,
26 pub sequence_source: String,
27 pub sequencer: Option<String>,
28 pub hgvsc: Option<String>,
29 pub hgvsp: Option<String>,
30 pub hgvsp_short: Option<String>,
31 pub transcript_id: Option<String>,
32 pub exon_number: Option<String>,
33 pub t_depth: Option<u32>, pub t_ref_count: Option<u32>, pub t_alt_count: Option<u32>, pub n_depth: Option<u32>, pub n_ref_count: Option<u32>, pub n_alt_count: Option<u32>, pub filter_status: String,
41 pub qual: Option<f64>,
42 pub vaf: Option<f32>,
43 pub protein_position: Option<String>,
44}
45
46const BIOTYPE_PRIORITY: &[(&str, u8)] = &[
50 ("protein_coding", 1),
51 ("LRG_gene", 2),
52 ("IG_C_gene", 2),
53 ("IG_D_gene", 2),
54 ("IG_J_gene", 2),
55 ("IG_LV_gene", 2),
56 ("IG_V_gene", 2),
57 ("TR_C_gene", 2),
58 ("TR_D_gene", 2),
59 ("TR_J_gene", 2),
60 ("TR_V_gene", 2),
61 ("miRNA", 3),
62 ("snRNA", 3),
63 ("snoRNA", 3),
64 ("ribozyme", 3),
65 ("tRNA", 3),
66 ("sRNA", 3),
67 ("scaRNA", 3),
68 ("rRNA", 3),
69 ("scRNA", 3),
70 ("lincRNA", 3),
71 ("lncRNA", 3),
72 ("bidirectional_promoter_lncrna", 3),
73 ("bidirectional_promoter_lncRNA", 3),
74 ("known_ncrna", 4),
75 ("vaultRNA", 4),
76 ("vault_RNA", 4),
77 ("macro_lncRNA", 4),
78 ("Mt_tRNA", 4),
79 ("Mt_rRNA", 4),
80 ("antisense", 5),
81 ("antisense_RNA", 5),
82 ("sense_intronic", 5),
83 ("sense_overlapping", 5),
84 ("3prime_overlapping_ncrna", 5),
85 ("3prime_overlapping_ncRNA", 5),
86 ("misc_RNA", 5),
87 ("non_coding", 5),
88 ("regulatory_region", 6),
89 ("disrupted_domain", 6),
90 ("processed_transcript", 6),
91 ("protein_coding_CDS_not_defined", 6),
92 ("TEC", 6),
93 ("TF_binding_site", 7),
94 ("CTCF_binding_site", 7),
95 ("promoter_flanking_region", 7),
96 ("enhancer", 7),
97 ("promoter", 7),
98 ("open_chromatin_region", 7),
99 ("retained_intron", 7),
100 ("nonsense_mediated_decay", 7),
101 ("non_stop_decay", 7),
102 ("ambiguous_orf", 7),
103 ("pseudogene", 8),
104 ("processed_pseudogene", 8),
105 ("polymorphic_pseudogene", 8),
106 ("protein_coding_LoF", 8),
107 ("retrotransposed", 8),
108 ("translated_processed_pseudogene", 8),
109 ("translated_unprocessed_pseudogene", 8),
110 ("transcribed_processed_pseudogene", 8),
111 ("transcribed_unprocessed_pseudogene", 8),
112 ("transcribed_unitary_pseudogene", 8),
113 ("unitary_pseudogene", 8),
114 ("unprocessed_pseudogene", 8),
115 ("Mt_tRNA_pseudogene", 8),
116 ("tRNA_pseudogene", 8),
117 ("snoRNA_pseudogene", 8),
118 ("snRNA_pseudogene", 8),
119 ("scRNA_pseudogene", 8),
120 ("rRNA_pseudogene", 8),
121 ("misc_RNA_pseudogene", 8),
122 ("miRNA_pseudogene", 8),
123 ("IG_pseudogene", 8),
124 ("IG_C_pseudogene", 8),
125 ("IG_D_pseudogene", 8),
126 ("IG_J_pseudogene", 8),
127 ("IG_V_pseudogene", 8),
128 ("TR_J_pseudogene", 8),
129 ("TR_V_pseudogene", 8),
130 ("artifact", 9),
131 ("", 10),
132];
133
134pub fn biotype_priority(biotype: &str) -> u8 {
136 BIOTYPE_PRIORITY
137 .iter()
138 .find(|(name, _)| *name == biotype)
139 .map(|(_, priority)| *priority)
140 .unwrap_or(10)
141}
142
143impl MafRecord {
144 pub fn from_reformatted_record_for_samples(
149 record: &ReformattedVcfRecord,
150 center: &str,
151 ncbi_build: &str,
152 sample_barcode: &str,
153 tumor: Option<&str>,
154 normal: Option<&str>,
155 ) -> Result<Self, Box<dyn std::error::Error>> {
156 let (pos, vcf_ref, vcf_alt) =
158 Self::trim_shared_prefix(record.position, &record.reference, &record.alternate);
159 let variant_type = Self::determine_variant_type(&vcf_ref, &vcf_alt);
160 #[allow(clippy::manual_is_multiple_of)]
165 let inframe = vcf_ref.len().abs_diff(vcf_alt.len()) % 3 == 0;
166 let (start_pos, end_pos) = Self::calculate_maf_positions(pos, &vcf_ref);
167 let (ref_allele, tumor_seq_allele1, tumor_seq_allele2) = Self::get_maf_alleles(
168 &vcf_ref,
169 &vcf_alt,
170 Self::tumor_genotype(&record.format_sample_data, tumor).as_deref(),
171 );
172
173 let (sample_total, sample_ref, sample_alt) =
180 Self::extract_depth_for_sample(&record.format_sample_data, tumor);
181
182 let (t_depth, t_ref_count, t_alt_count) = if tumor.is_some() {
187 (sample_total, sample_ref, sample_alt)
188 } else {
189 (
190 sample_total.or_else(|| Self::extract_total_depth(&record.info_fields)),
193 sample_ref.or_else(|| Self::extract_ref_depth(&record.info_fields)),
194 sample_alt.or_else(|| Self::extract_tumor_depth(&record.info_fields)),
195 )
196 };
197
198 let (n_depth, n_ref_count, n_alt_count) = match normal {
199 Some(name) => Self::extract_depth_for_sample(&record.format_sample_data, Some(name)),
200 None => (None, None, None),
201 };
202
203 let vaf = match (t_alt_count, t_depth) {
204 (Some(alt), Some(total)) if total > 0 => Some(alt as f32 / total as f32),
205 _ => None,
206 };
207
208 let hgvsc = Self::get_annotation_field(&record.info_fields, &["CSQ_HGVSc", "ANN_HGVS_c"])
209 .filter(|s| s != ".")
210 .map(|s| Self::strip_accession(&s));
211 let hgvsp = Self::get_annotation_field(&record.info_fields, &["CSQ_HGVSp", "ANN_HGVS_p"])
212 .filter(|s| s != ".")
213 .map(|s| Self::strip_accession(&s));
214 let hgvsp_short = Self::splice_hgvsp_short(&record.info_fields, hgvsc.as_deref())
217 .or_else(|| hgvsp.as_deref().map(Self::hgvsp_to_short));
218 let transcript_id = Self::get_transcript_id(&record.info_fields);
219
220 Ok(MafRecord {
221 hugo_symbol: Self::get_hugo_symbol(&record.info_fields, &transcript_id),
222 entrez_gene_id: Self::get_entrez_gene_id(&record.info_fields),
223 center: if center.trim().is_empty() {
224 "Unknown_Center".to_string()
225 } else {
226 center.to_string()
227 },
228 ncbi_build: ncbi_build.to_string(),
229 chromosome: record.chromosome.clone(),
233 start_position: start_pos,
234 end_position: end_pos,
235 strand: "+".to_string(),
238 variant_classification: Self::get_variant_classification(
239 &record.info_fields,
240 &variant_type,
241 inframe,
242 ),
243 variant_type,
244 reference_allele: ref_allele,
245 tumor_seq_allele1,
246 tumor_seq_allele2,
247 dbsnp_rs: Self::get_dbsnp_rs(&record.info_fields, record.id.as_deref()),
248 dbsnp_val_status: None,
249 tumor_sample_barcode: sample_barcode.to_string(),
250 matched_norm_sample_barcode: normal.map(str::to_string),
251 validation_status: None,
252 mutation_status: String::new(),
255 sequence_source: String::new(),
256 sequencer: Self::extract_sequencing_info(&record.info_fields),
257 hgvsc,
258 hgvsp,
259 hgvsp_short,
260 transcript_id: transcript_id.clone(),
261 exon_number: Self::get_exon_number(&record.info_fields),
262 t_depth,
263 t_ref_count,
264 t_alt_count,
265 n_depth,
266 n_ref_count,
267 n_alt_count,
268 filter_status: record.filter.clone(),
269 qual: record.quality,
270 vaf,
271 protein_position: Self::get_protein_position(&record.info_fields),
272 })
273 }
274
275 fn get_transcript_id(info_fields: &HashMap<String, String>) -> Option<String> {
276 Self::get_annotation_field(
277 info_fields,
278 &[
279 "ANN_Feature_ID", "CSQ_Feature", "CSQ_Transcript_ID",
282 "ANN_Transcript_ID",
283 ],
284 )
285 }
286
287 fn get_protein_position(info_fields: &HashMap<String, String>) -> Option<String> {
288 Self::get_annotation_field(
289 info_fields,
290 &[
291 "ANN_AA_pos___AA_length", "ANN_Protein_position",
293 "CSQ_Protein_position", "ANN_AA_pos",
295 "CSQ_AA_pos",
296 ],
297 )
298 }
299
300 fn get_dbsnp_rs(info_fields: &HashMap<String, String>, id: Option<&str>) -> Option<String> {
307 let Some(existing) = info_fields.get("CSQ_Existing_variation") else {
308 return id.filter(|id| *id != ".").map(str::to_string);
309 };
310 if existing.is_empty() || existing == "." {
311 return Some("novel".to_string());
312 }
313 let rs_ids: Vec<&str> = existing
315 .split(['&', ','])
316 .filter(|t| {
317 t.strip_prefix("rs")
318 .is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
319 })
320 .collect();
321 (!rs_ids.is_empty()).then(|| rs_ids.join(","))
322 }
323
324 fn get_exon_number(info_fields: &HashMap<String, String>) -> Option<String> {
325 Self::get_annotation_field(info_fields, &["CSQ_EXON", "ANN_Rank"])
326 }
327
328 fn get_hugo_symbol(
333 info_fields: &HashMap<String, String>,
334 transcript_id: &Option<String>,
335 ) -> String {
336 Self::get_annotation_field(info_fields, &["ANN_Gene_Name", "CSQ_SYMBOL", "ANN_SYMBOL"])
337 .or_else(|| transcript_id.clone())
338 .unwrap_or_else(|| "Unknown".to_string())
339 }
340
341 pub fn is_symbolic_allele(alt: &str) -> bool {
349 alt.starts_with('<') || alt.contains('[') || alt.contains(']')
350 }
351
352 pub fn from_reformatted_record_multi_for_samples(
355 record: &ReformattedVcfRecord,
356 center: &str,
357 ncbi_build: &str,
358 sample_barcode: &str,
359 tumor: Option<&str>,
360 normal: Option<&str>,
361 ) -> Result<Vec<Self>, Box<dyn std::error::Error>> {
362 let alternates: Vec<&str> = record.alternate.split(',').collect();
363 let mut maf_records = Vec::new();
364
365 for (alt_index, alternate) in alternates.iter().enumerate() {
366 if Self::is_symbolic_allele(alternate) {
369 continue;
370 }
371
372 let mut info_fields = record.info_fields.clone();
376 if alternates.len() > 1
377 && !Self::annotation_describes_allele(
378 &info_fields,
379 record.position,
380 &record.reference,
381 alternate,
382 alt_index,
383 )
384 {
385 info_fields.retain(|k, _| !(k.starts_with("CSQ_") || k.starts_with("ANN_")));
386 }
387
388 let single_alt_record = ReformattedVcfRecord {
390 chromosome: record.chromosome.clone(),
391 position: record.position,
392 id: record.id.clone(),
393 reference: record.reference.clone(),
394 alternate: alternate.to_string(),
395 quality: record.quality,
396 filter: record.filter.clone(),
397 info_fields,
398 format_sample_data: record.format_sample_data.clone(),
399 annotation_field_type: record.annotation_field_type,
400 };
401
402 let mut maf_record = Self::from_reformatted_record_for_samples(
404 &single_alt_record,
405 center,
406 ncbi_build,
407 sample_barcode,
408 tumor,
409 normal,
410 )?;
411
412 if alternates.len() > 1 {
415 if let Some(genotype) = Self::tumor_genotype(&record.format_sample_data, tumor) {
416 if let Some(allele1) =
417 Self::genotype_allele1(&record.reference, &alternates, alt_index, &genotype)
418 {
419 maf_record.tumor_seq_allele1 = allele1;
420 }
421 }
422 }
423
424 let allele_alt_count = if tumor.is_some() {
428 Self::sample_alt_depth_for_allele(&record.format_sample_data, tumor, alt_index)
429 } else {
430 Self::extract_tumor_depth_for_allele(&record.info_fields, alt_index)
431 };
432
433 if let Some(allele_depth) = allele_alt_count {
434 maf_record.t_alt_count = Some(allele_depth);
435
436 if let Some(total) = maf_record.t_depth {
438 if total > 0 {
439 maf_record.vaf = Some(allele_depth as f32 / total as f32);
440 }
441 }
442 }
443
444 maf_records.push(maf_record);
445 }
446
447 Ok(maf_records)
448 }
449
450 fn sample_alt_depth_for_allele(
456 format_sample_data: &Option<ParsedFormatSample>,
457 sample: Option<&str>,
458 allele_index: usize,
459 ) -> Option<u32> {
460 format_sample_data
461 .as_ref()?
462 .samples
463 .iter()
464 .filter(|s| sample.is_none_or(|name| s.sample_name == name))
465 .find_map(|s| s.format_fields.get("AD"))?
466 .split(',')
467 .nth(allele_index + 1)?
468 .parse()
469 .ok()
470 }
471
472 fn extract_tumor_depth_for_allele(
473 info_fields: &HashMap<String, String>,
474 allele_index: usize,
475 ) -> Option<u32> {
476 if let Some(ao_str) = Self::get_annotation_field(info_fields, &["INFO_AO", "AO"]) {
477 let depths: Vec<&str> = ao_str.split(',').collect();
478 if let Some(depth_str) = depths.get(allele_index) {
479 if let Ok(depth) = depth_str.parse::<u32>() {
480 return Some(depth);
481 }
482 }
483 }
484
485 Self::extract_tumor_depth(info_fields)
487 }
488
489 fn get_entrez_gene_id(info_fields: &HashMap<String, String>) -> Option<u32> {
491 if let Some(hgnc) = Self::get_annotation_field(info_fields, &["CSQ_HGNC_ID"]) {
493 if let Some(id) = hgnc.strip_prefix("HGNC:").and_then(|id| id.parse().ok()) {
494 return Some(id);
495 }
496 }
497
498 Self::get_annotation_field(info_fields, &["ANN_Entrez_ID", "CSQ_Gene"])
499 .and_then(|id| id.parse().ok())
500 }
501
502 fn extract_depth_for_sample(
506 format_sample_data: &Option<ParsedFormatSample>,
507 sample: Option<&str>,
508 ) -> (Option<u32>, Option<u32>, Option<u32>) {
509 if let Some(sample_data) = format_sample_data {
510 let mut total_depth = None;
511 let mut ref_depth = None;
512 let mut alt_depth = None;
513
514 let candidates = sample_data
516 .samples
517 .iter()
518 .filter(|s| sample.is_none_or(|name| s.sample_name == name));
519 for sample in candidates {
520 let dp = sample
522 .format_fields
523 .get("DP")
524 .and_then(|dp| dp.parse::<u32>().ok());
525
526 let mut ad_ref = None;
528 let mut ad_alt = None;
529 if let Some(ad) = sample.format_fields.get("AD") {
530 let depths: Vec<&str> = ad.split(',').collect();
531 ad_ref = depths.first().and_then(|d| d.parse::<u32>().ok());
532 if depths.len() >= 2 {
533 ad_alt = depths[1].parse::<u32>().ok();
534 }
535 }
536
537 if dp.is_some() {
541 total_depth = dp;
542 ref_depth = ad_ref;
543 alt_depth = ad_alt;
544 break;
545 }
546
547 if ref_depth.is_none() && alt_depth.is_none() {
550 ref_depth = ad_ref;
551 alt_depth = ad_alt;
552 }
553 }
554
555 (total_depth, ref_depth, alt_depth)
556 } else {
557 (None, None, None)
558 }
559 }
560
561 fn extract_total_depth(info_fields: &HashMap<String, String>) -> Option<u32> {
562 Self::get_annotation_field(
563 info_fields,
564 &["INFO_DP", "INFO_DEPTH", "ANN_DP", "ANN_TotalDepth"],
565 )
566 .and_then(|s| s.parse().ok())
567 }
568
569 fn extract_tumor_depth(info_fields: &HashMap<String, String>) -> Option<u32> {
570 Self::get_annotation_field(info_fields, &["INFO_AO", "ANN_AO", "ANN_AD"]).and_then(|s| {
571 let first_value = s.split(',').next().unwrap_or(&s);
573 first_value.parse().ok()
574 })
575 }
576
577 fn extract_ref_depth(info_fields: &HashMap<String, String>) -> Option<u32> {
582 Self::get_annotation_field(info_fields, &["INFO_RO"]).and_then(|s| s.parse().ok())
583 }
584
585 fn extract_sequencing_info(info_fields: &HashMap<String, String>) -> Option<String> {
587 for field_name in &["INFO_source", "INFO_caller", "INFO_platform"] {
588 if let Some(value) = info_fields.get(*field_name) {
589 if !value.is_empty() && value != "." {
590 return Some(value.clone());
591 }
592 }
593 }
594
595 None
597 }
598
599 fn get_annotation_field(
600 info_fields: &HashMap<String, String>,
601 field_names: &[&str],
602 ) -> Option<String> {
603 for field_name in field_names {
604 if let Some(value) = info_fields.get(*field_name) {
605 if !value.is_empty() && value != "." {
606 return Some(value.clone());
607 }
608 }
609 }
610 None
611 }
612
613 fn annotation_describes_allele(
623 info_fields: &HashMap<String, String>,
624 position: u64,
625 reference: &str,
626 alternate: &str,
627 alt_index: usize,
628 ) -> bool {
629 if let Some(allele_num) = Self::get_annotation_field(info_fields, &["CSQ_ALLELE_NUM"]) {
630 return allele_num.parse::<usize>() == Ok(alt_index + 1);
631 }
632 let Some(annotated) =
633 Self::get_annotation_field(info_fields, &["CSQ_Allele", "ANN_Allele"])
634 else {
635 return true;
636 };
637 let (_, _, trimmed) = Self::trim_shared_prefix(position, reference, alternate);
638 let dash = |s: &str| if s.is_empty() { "-" } else { s }.to_string();
639 [
640 alternate.to_string(),
641 dash(&trimmed),
642 dash(&alternate[1.min(alternate.len())..]),
643 ]
644 .contains(&annotated)
645 }
646
647 fn shared_prefix_len(reference: &str, alternate: &str) -> usize {
652 if reference == alternate {
654 return 0;
655 }
656 reference
657 .chars()
658 .zip(alternate.chars())
659 .take_while(|(r, a)| r == a)
660 .count()
661 }
662
663 fn trim_shared_prefix(
664 position: u64,
665 reference: &str,
666 alternate: &str,
667 ) -> (u64, String, String) {
668 let shared = Self::shared_prefix_len(reference, alternate);
669 (
670 position + shared as u64,
671 reference[shared..].to_string(),
672 alternate[shared..].to_string(),
673 )
674 }
675
676 fn calculate_maf_positions(position: u64, reference: &str) -> (u64, u64) {
679 if reference.is_empty() {
680 (position.saturating_sub(1), position)
681 } else {
682 (position, position + reference.len() as u64 - 1)
683 }
684 }
685
686 fn get_maf_alleles(
693 reference: &str,
694 alternate: &str,
695 tumor_genotype: Option<&str>,
696 ) -> (String, String, String) {
697 let dash = |s: &str| if s.is_empty() { "-" } else { s }.to_string();
698 let hom_alt = tumor_genotype.is_some_and(|gt| {
699 let mut indices = gt.split(['/', '|']).peekable();
700 indices.peek().is_some()
701 && indices.all(|i| matches!(i.parse::<u32>(), Ok(index) if index > 0))
702 });
703 let allele1 = if hom_alt { alternate } else { reference };
704 (dash(reference), dash(allele1), dash(alternate))
705 }
706
707 fn genotype_allele1(
713 reference: &str,
714 alternates: &[&str],
715 alt_index: usize,
716 genotype: &str,
717 ) -> Option<String> {
718 let variant = alternates.get(alt_index)?;
719 let shared = Self::shared_prefix_len(reference, variant);
720 let trim = |allele: &str| match allele.get(shared..) {
721 Some(trimmed) if !trimmed.is_empty() => trimmed.to_string(),
722 _ => "-".to_string(),
723 };
724 let allele_at = |index: usize| match index.checked_sub(1) {
725 None => Some(trim(reference)),
726 Some(alt) => alternates.get(alt).map(|a| trim(a)),
727 };
728
729 let mut indices = genotype.split(['/', '|']).map(|i| i.parse::<usize>().ok());
730 let first = indices.next().flatten()?;
731 let second = indices.next().flatten().unwrap_or(first);
733
734 let allele1 = allele_at(first)?;
735 if allele1 != trim(variant) {
736 Some(allele1)
737 } else {
738 allele_at(second)
739 }
740 }
741
742 fn tumor_genotype(
745 format_sample_data: &Option<ParsedFormatSample>,
746 sample: Option<&str>,
747 ) -> Option<String> {
748 format_sample_data
749 .as_ref()?
750 .samples
751 .iter()
752 .filter(|s| sample.is_none_or(|name| s.sample_name == name))
753 .find_map(|s| s.format_fields.get("GT").cloned())
754 }
755
756 fn determine_variant_type(reference: &str, alternate: &str) -> String {
757 if reference.len() < alternate.len() {
758 "INS".to_string()
759 } else if reference.len() > alternate.len() {
760 "DEL".to_string()
761 } else {
762 match reference.len() {
763 1 => "SNP",
764 2 => "DNP",
765 3 => "TNP",
766 _ => "ONP",
767 }
768 .to_string()
769 }
770 }
771
772 fn get_variant_classification(
773 info_fields: &HashMap<String, String>,
774 variant_type: &str,
775 inframe: bool,
776 ) -> String {
777 match Self::get_annotation_field(info_fields, &["CSQ_Consequence", "ANN_Annotation"]) {
778 Some(consequence) => Self::map_consequence_to_maf(&consequence, variant_type, inframe),
779 None => Self::classify_by_impact(info_fields),
780 }
781 }
782
783 const EFFECT_PRIORITY: &'static [(&'static str, u8)] = &[
788 ("transcript_ablation", 1),
789 ("exon_loss_variant", 1),
790 ("sequence_feature + exon_loss_variant", 1),
791 ("feature_ablation", 1),
792 ("chromosome_number_variation", 1),
793 ("bidirectional_gene_fusion", 2),
794 ("duplication", 2),
795 ("gene_fusion", 2),
796 ("inversion", 2),
797 ("splice_donor_variant", 2),
798 ("splice_acceptor_variant", 2),
799 ("stop_gained", 3),
800 ("frameshift_variant", 3),
801 ("stop_lost", 3),
802 ("initiator_codon_variant+non_canonical_start_codon", 4),
803 ("rearranged_at_dna_level", 4),
804 ("start_lost", 4),
805 ("initiator_codon_variant", 4),
806 ("transcript_amplification", 4),
807 ("feature_elongation", 4),
808 ("feature_truncation", 4),
809 ("disruptive_inframe_insertion", 5),
810 ("disruptive_inframe_deletion", 5),
811 ("conservative_inframe_insertion", 5),
812 ("conservative_inframe_deletion", 5),
813 ("inframe_insertion", 5),
814 ("inframe_deletion", 5),
815 ("protein_altering_variant", 5),
816 ("missense_variant", 6),
817 ("conservative_missense_variant", 6),
818 ("rare_amino_acid_variant", 6),
819 ("5_prime_utr_truncation + exon_loss_variant", 8),
820 ("protein_protein_contact", 8),
821 ("3_prime_utr_truncation + exon_loss", 8),
822 ("structural_interaction_variant", 8),
823 ("splice_branch_variant", 8),
824 ("splice_region_variant", 8),
825 ("splice_donor_5th_base_variant", 8),
826 ("splice_donor_region_variant", 8),
827 ("splice_polypyrimidine_tract_variant", 8),
828 ("start_retained_variant", 9),
829 ("stop_retained_variant", 9),
830 ("synonymous_variant", 9),
831 ("start_retained", 9),
832 ("incomplete_terminal_codon_variant", 10),
833 ("coding_sequence_variant", 11),
834 ("mature_mirna_variant", 11),
835 ("exon_variant", 11),
836 ("transcript_variant", 11),
837 ("5_prime_utr_variant", 12),
838 ("5_prime_utr_premature_start_codon_gain_variant", 12),
839 ("3_prime_utr_variant", 12),
840 ("non_coding_exon_variant", 13),
841 ("non_coding_transcript_exon_variant", 13),
842 ("non_coding_transcript_variant", 14),
843 ("nc_transcript_variant", 14),
844 ("intron_variant", 14),
845 ("intragenic_variant", 14),
846 ("intragenic", 14),
847 ("nmd_transcript_variant", 15),
848 ("coding_transcript_variant", 15),
849 ("upstream_gene_variant", 16),
850 ("downstream_gene_variant", 16),
851 ("tfbs_ablation", 17),
852 ("tfbs_amplification", 17),
853 ("tf_binding_site_variant", 17),
854 ("regulatory_region_ablation", 17),
855 ("regulatory_region_amplification", 17),
856 ("regulatory_region_variant", 17),
857 ("regulatory_region", 17),
858 ("mirna", 17),
859 ("intergenic_variant", 19),
860 ("intergenic_region", 19),
861 ("sequence_feature", 19),
862 ("conserved_intron_variant", 19),
863 ("gene_variant", 19),
864 ("conserved_intergenic_variant", 20),
865 ("sequence_variant", 20),
866 ("custom", 20),
867 ];
868
869 pub fn effect_priority(term: &str) -> u8 {
870 Self::EFFECT_PRIORITY
871 .iter()
872 .find(|(name, _)| *name == term)
873 .map(|(_, priority)| *priority)
874 .unwrap_or(20)
875 }
876
877 pub fn resolve_one_consequence(consequence: &str) -> String {
880 consequence
881 .split(&['&', '|', ','][..])
882 .map(|s| s.trim())
883 .filter(|s| !s.is_empty())
884 .min_by_key(|term| Self::effect_priority(term))
885 .unwrap_or("intergenic_variant")
886 .to_string()
887 }
888
889 fn map_consequence_to_maf(consequence: &str, variant_type: &str, inframe: bool) -> String {
893 let term = Self::resolve_one_consequence(&consequence.to_lowercase());
894
895 if matches!(
896 term.as_str(),
897 "splice_acceptor_variant" | "splice_donor_variant"
898 ) {
899 return "Splice_Site".to_string();
900 }
901 if term == "stop_gained" {
902 return "Nonsense_Mutation".to_string();
903 }
904 let is_frameshift_like =
905 term == "frameshift_variant" || (term == "protein_altering_variant" && !inframe);
906 if is_frameshift_like && variant_type == "DEL" {
907 return "Frame_Shift_Del".to_string();
908 }
909 if is_frameshift_like && variant_type == "INS" {
910 return "Frame_Shift_Ins".to_string();
911 }
912 if term == "stop_lost" {
913 return "Nonstop_Mutation".to_string();
914 }
915 if matches!(term.as_str(), "initiator_codon_variant" | "start_lost") {
916 return "Translation_Start_Site".to_string();
917 }
918 let is_inframe_ins = term.ends_with("inframe_insertion")
919 || (term == "protein_altering_variant" && inframe && variant_type == "INS");
920 if is_inframe_ins {
921 return "In_Frame_Ins".to_string();
922 }
923 let is_inframe_del = term.ends_with("inframe_deletion")
924 || (term == "protein_altering_variant" && inframe && variant_type == "DEL");
925 if is_inframe_del {
926 return "In_Frame_Del".to_string();
927 }
928 if matches!(
929 term.as_str(),
930 "missense_variant"
931 | "coding_sequence_variant"
932 | "conservative_missense_variant"
933 | "rare_amino_acid_variant"
934 ) {
935 return "Missense_Mutation".to_string();
936 }
937 if matches!(
938 term.as_str(),
939 "transcript_amplification" | "intron_variant" | "intragenic" | "intragenic_variant"
940 ) {
941 return "Intron".to_string();
942 }
943 if matches!(
944 term.as_str(),
945 "splice_region_variant"
946 | "splice_donor_5th_base_variant"
947 | "splice_donor_region_variant"
948 | "splice_polypyrimidine_tract_variant"
949 ) {
950 return "Splice_Region".to_string();
951 }
952 if matches!(
953 term.as_str(),
954 "incomplete_terminal_codon_variant"
955 | "synonymous_variant"
956 | "stop_retained_variant"
957 | "start_retained_variant"
958 | "nmd_transcript_variant"
959 ) {
960 return "Silent".to_string();
961 }
962 if matches!(
963 term.as_str(),
964 "mature_mirna_variant"
965 | "exon_variant"
966 | "non_coding_exon_variant"
967 | "non_coding_transcript_exon_variant"
968 | "non_coding_transcript_variant"
969 | "nc_transcript_variant"
970 ) {
971 return "RNA".to_string();
972 }
973 if matches!(
974 term.as_str(),
975 "5_prime_utr_variant" | "5_prime_utr_premature_start_codon_gain_variant"
976 ) {
977 return "5'UTR".to_string();
978 }
979 if term == "3_prime_utr_variant" {
980 return "3'UTR".to_string();
981 }
982 if matches!(
983 term.as_str(),
984 "tf_binding_site_variant"
985 | "regulatory_region_variant"
986 | "regulatory_region"
987 | "intergenic_variant"
988 | "intergenic_region"
989 ) {
990 return "IGR".to_string();
991 }
992 if term == "upstream_gene_variant" {
993 return "5'Flank".to_string();
994 }
995 if term == "downstream_gene_variant" {
996 return "3'Flank".to_string();
997 }
998
999 "Targeted_Region".to_string()
1003 }
1004
1005 const AA_3_TO_1: &'static [(&'static str, &'static str)] = &[
1008 ("Ala", "A"),
1009 ("Arg", "R"),
1010 ("Asn", "N"),
1011 ("Asp", "D"),
1012 ("Asx", "B"),
1013 ("Cys", "C"),
1014 ("Glu", "E"),
1015 ("Gln", "Q"),
1016 ("Glx", "Z"),
1017 ("Gly", "G"),
1018 ("His", "H"),
1019 ("Ile", "I"),
1020 ("Leu", "L"),
1021 ("Lys", "K"),
1022 ("Met", "M"),
1023 ("Phe", "F"),
1024 ("Pro", "P"),
1025 ("Ser", "S"),
1026 ("Thr", "T"),
1027 ("Trp", "W"),
1028 ("Tyr", "Y"),
1029 ("Val", "V"),
1030 ("Xxx", "X"),
1031 ("Ter", "*"),
1032 ];
1033
1034 fn hgvsp_to_short(hgvsp: &str) -> String {
1037 let mut short = hgvsp.to_string();
1038 for (three, one) in Self::AA_3_TO_1 {
1039 short = short.replace(three, one);
1040 }
1041 short
1042 }
1043
1044 fn strip_accession(hgvs: &str) -> String {
1049 match hgvs.rfind(':') {
1050 Some(colon) => hgvs[colon + 1..].to_string(),
1051 None => hgvs.to_string(),
1052 }
1053 }
1054
1055 fn splice_hgvsp_short(
1062 info_fields: &HashMap<String, String>,
1063 hgvsc: Option<&str>,
1064 ) -> Option<String> {
1065 let consequence =
1066 Self::get_annotation_field(info_fields, &["CSQ_Consequence", "ANN_Annotation"])?;
1067 let term = Self::resolve_one_consequence(&consequence.to_lowercase());
1069 if !matches!(
1070 term.as_str(),
1071 "splice_acceptor_variant" | "splice_donor_variant"
1072 ) {
1073 return None;
1074 }
1075
1076 let digits: String = hgvsc?
1077 .strip_prefix("c.")?
1078 .chars()
1079 .take_while(char::is_ascii_digit)
1080 .collect();
1081 let c_pos = digits.parse::<u64>().ok()?.max(1);
1083 let p_pos = ((c_pos + c_pos % 3) as f64 / 3.0).round() as u64;
1087 Some(format!("p.X{p_pos}_splice"))
1088 }
1089
1090 fn classify_by_impact(info_fields: &HashMap<String, String>) -> String {
1091 let impact =
1092 Self::get_annotation_field(info_fields, &["CSQ_IMPACT", "ANN_Annotation_Impact"]);
1093 match impact.as_deref().map(|s| s.to_uppercase()).as_deref() {
1094 Some("HIGH") | Some("MODERATE") => "Missense_Mutation".to_string(),
1095 Some("LOW") | Some("MODIFIER") => "Silent".to_string(),
1096 _ => "Targeted_Region".to_string(),
1098 }
1099 }
1100
1101 pub fn get_maf_headers() -> Vec<String> {
1102 [
1103 "Hugo_Symbol",
1104 "Entrez_Gene_Id",
1105 "Center",
1106 "NCBI_Build",
1107 "Chromosome",
1108 "Start_Position",
1109 "End_Position",
1110 "Strand",
1111 "Variant_Classification",
1112 "Variant_Type",
1113 "Reference_Allele",
1114 "Tumor_Seq_Allele1",
1115 "Tumor_Seq_Allele2",
1116 "dbSNP_RS",
1117 "dbSNP_Val_Status",
1118 "Tumor_Sample_Barcode",
1119 "Matched_Norm_Sample_Barcode",
1120 "Match_Norm_Seq_Allele1",
1121 "Match_Norm_Seq_Allele2",
1122 "Tumor_Validation_Allele1",
1123 "Tumor_Validation_Allele2",
1124 "Match_Norm_Validation_Allele1",
1125 "Match_Norm_Validation_Allele2",
1126 "Verification_Status",
1127 "Validation_Status",
1128 "Mutation_Status",
1129 "Sequencing_Phase",
1130 "Sequence_Source",
1131 "Validation_Method",
1132 "Score",
1133 "BAM_File",
1134 "Sequencer",
1135 "Tumor_Sample_UUID",
1136 "Matched_Norm_Sample_UUID",
1137 "HGVSc",
1138 "HGVSp",
1139 "HGVSp_Short",
1140 "Transcript_ID",
1141 "Exon_Number",
1142 "t_depth",
1143 "t_ref_count",
1144 "t_alt_count",
1145 "n_depth",
1146 "n_ref_count",
1147 "n_alt_count",
1148 "all_effects",
1149 "FILTER",
1150 "QUAL",
1151 "VAF",
1152 "Protein_Position",
1153 ]
1154 .iter()
1155 .map(|s| s.to_string())
1156 .collect()
1157 }
1158
1159 pub fn to_tsv_line(&self) -> String {
1160 let empty = "";
1164 let entrez = self.entrez_gene_id.map(|id| id.to_string());
1165 let start = self.start_position.to_string();
1166 let end = self.end_position.to_string();
1167 let t_depth = self.t_depth.map(|d| d.to_string());
1168 let t_ref_count = self.t_ref_count.map(|d| d.to_string());
1169 let t_alt_count = self.t_alt_count.map(|d| d.to_string());
1170 let n_depth = self.n_depth.map(|d| d.to_string());
1171 let n_ref_count = self.n_ref_count.map(|d| d.to_string());
1172 let n_alt_count = self.n_alt_count.map(|d| d.to_string());
1173 let vaf = self.vaf.map(|v| format!("{:.4}", v));
1174 let qual = self.qual.map(|q| q.to_string());
1175
1176 [
1177 self.hugo_symbol.as_str(),
1178 entrez.as_deref().unwrap_or(empty),
1179 self.center.as_str(),
1180 self.ncbi_build.as_str(),
1181 self.chromosome.as_str(),
1182 start.as_str(),
1183 end.as_str(),
1184 self.strand.as_str(),
1185 self.variant_classification.as_str(),
1186 self.variant_type.as_str(),
1187 self.reference_allele.as_str(),
1188 self.tumor_seq_allele1.as_str(),
1189 self.tumor_seq_allele2.as_str(),
1190 self.dbsnp_rs.as_deref().unwrap_or(empty),
1191 self.dbsnp_val_status.as_deref().unwrap_or(empty),
1192 self.tumor_sample_barcode.as_str(),
1193 self.matched_norm_sample_barcode.as_deref().unwrap_or(empty),
1194 empty, empty, empty, empty, empty, empty, empty, self.validation_status.as_deref().unwrap_or(empty),
1202 self.mutation_status.as_str(),
1203 empty, self.sequence_source.as_str(),
1205 empty, empty, empty, self.sequencer.as_deref().unwrap_or(empty),
1209 empty, empty, self.hgvsc.as_deref().unwrap_or(empty),
1212 self.hgvsp.as_deref().unwrap_or(empty),
1213 self.hgvsp_short.as_deref().unwrap_or(empty),
1214 self.transcript_id.as_deref().unwrap_or(empty),
1215 self.exon_number.as_deref().unwrap_or(empty),
1216 t_depth.as_deref().unwrap_or(empty),
1217 t_ref_count.as_deref().unwrap_or(empty),
1218 t_alt_count.as_deref().unwrap_or(empty),
1219 n_depth.as_deref().unwrap_or(empty),
1220 n_ref_count.as_deref().unwrap_or(empty),
1221 n_alt_count.as_deref().unwrap_or(empty),
1222 empty, self.filter_status.as_str(),
1224 qual.as_deref().unwrap_or(empty),
1225 vaf.as_deref().unwrap_or(empty),
1226 self.protein_position.as_deref().unwrap_or(empty),
1227 ]
1228 .join("\t")
1229 }
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234 #[test]
1235 fn test_is_symbolic_allele() {
1236 for alt in ["<DEL>", "<NON_REF>", "<*>", "A[chr2:456[", "]chr2:456]A"] {
1237 assert!(
1238 MafRecord::is_symbolic_allele(alt),
1239 "{alt} is not a base sequence"
1240 );
1241 }
1242 for alt in ["A", "ACGT", "-", "*"] {
1244 assert!(
1245 !MafRecord::is_symbolic_allele(alt),
1246 "{alt} must still convert"
1247 );
1248 }
1249 }
1250
1251 use super::*;
1252
1253 fn maf_from(position: u64, reference: &str, alternate: &str) -> MafRecord {
1254 let record = ReformattedVcfRecord {
1255 chromosome: "chr1".to_string(),
1256 position,
1257 id: None,
1258 reference: reference.to_string(),
1259 alternate: alternate.to_string(),
1260 quality: Some(60.0),
1261 filter: "PASS".to_string(),
1262 info_fields: HashMap::new(),
1263 format_sample_data: None,
1264 annotation_field_type: crate::reformat_vcf::AnnotationFieldType::None,
1265 };
1266 MafRecord::from_reformatted_record_for_samples(
1267 &record, "test", "GRCh38", "sample", None, None,
1268 )
1269 .unwrap()
1270 }
1271
1272 #[test]
1273 fn test_dnp_end_position_spans_both_bases() {
1274 let maf = maf_from(100, "AC", "GT");
1276 assert_eq!(maf.variant_type, "DNP");
1277 assert_eq!((maf.start_position, maf.end_position), (100, 101));
1278 }
1279
1280 #[test]
1281 fn test_onp_end_position_spans_all_bases() {
1282 let maf = maf_from(100, "ACGT", "TGCA");
1283 assert_eq!(maf.variant_type, "ONP");
1284 assert_eq!((maf.start_position, maf.end_position), (100, 103));
1285 }
1286
1287 #[test]
1288 fn test_untrimmed_equal_length_alleles_reduce_to_snp() {
1289 let maf = maf_from(8324505, "CCCCA", "CCCCC");
1292 assert_eq!(maf.variant_type, "SNP");
1293 assert_eq!((maf.start_position, maf.end_position), (8324509, 8324509));
1294 assert_eq!(maf.reference_allele, "A");
1295 assert_eq!(maf.tumor_seq_allele2, "C");
1296 }
1297
1298 #[test]
1299 fn test_insertion_positions_and_alleles_unchanged() {
1300 let maf = maf_from(100, "A", "ATCG");
1301 assert_eq!(maf.variant_type, "INS");
1302 assert_eq!((maf.start_position, maf.end_position), (100, 101));
1303 assert_eq!(maf.reference_allele, "-");
1304 assert_eq!(maf.tumor_seq_allele2, "TCG");
1305 }
1306
1307 #[test]
1308 fn test_deletion_positions_and_alleles_unchanged() {
1309 let maf = maf_from(100, "ATCG", "A");
1310 assert_eq!(maf.variant_type, "DEL");
1311 assert_eq!((maf.start_position, maf.end_position), (101, 103));
1312 assert_eq!(maf.reference_allele, "TCG");
1313 assert_eq!(maf.tumor_seq_allele2, "-");
1314 }
1315
1316 fn annotated_record(
1317 position: u64,
1318 reference: &str,
1319 alternate: &str,
1320 csq_allele: &str,
1321 ) -> ReformattedVcfRecord {
1322 let mut info_fields = HashMap::new();
1323 info_fields.insert("CSQ_Allele".to_string(), csq_allele.to_string());
1324 info_fields.insert("CSQ_SYMBOL".to_string(), "SLC45A1".to_string());
1325 info_fields.insert(
1326 "CSQ_Consequence".to_string(),
1327 "missense_variant".to_string(),
1328 );
1329 ReformattedVcfRecord {
1330 chromosome: "chr1".to_string(),
1331 position,
1332 id: None,
1333 reference: reference.to_string(),
1334 alternate: alternate.to_string(),
1335 quality: Some(60.0),
1336 filter: "PASS".to_string(),
1337 info_fields,
1338 format_sample_data: None,
1339 annotation_field_type: crate::reformat_vcf::AnnotationFieldType::Csq,
1340 }
1341 }
1342
1343 #[test]
1344 fn test_strand_is_always_plus() {
1345 let mut record = annotated_record(100, "A", "G", "G");
1348 record
1349 .info_fields
1350 .insert("CSQ_STRAND".to_string(), "-1".to_string());
1351 let maf =
1352 MafRecord::from_reformatted_record_for_samples(&record, "c", "GRCh38", "s", None, None)
1353 .unwrap();
1354
1355 assert_eq!(maf.strand, "+");
1356 }
1357
1358 #[test]
1359 fn test_unasserted_metadata_columns_default_to_empty() {
1360 let maf = maf_from(100, "A", "G");
1362 assert_eq!(maf.mutation_status, "");
1363 assert_eq!(maf.sequence_source, "");
1364 }
1365
1366 #[test]
1367 fn test_unannotated_record_uses_vcf2maf_fallback_classification() {
1368 let maf = maf_from(100, "A", "G");
1371 assert_eq!(maf.variant_classification, "Targeted_Region");
1372 }
1373
1374 #[test]
1375 fn test_multiallelic_annotation_stays_on_its_own_allele() {
1376 let record = annotated_record(8324505, "CCCCA", "GCCCC,CCCCC", "GCCCC");
1378 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1379 &record, "c", "GRCh38", "s", None, None,
1380 )
1381 .unwrap();
1382
1383 assert_eq!(rows.len(), 2, "one row per ALT");
1384 assert_eq!(rows[0].hugo_symbol, "SLC45A1");
1385 assert_eq!(rows[0].variant_classification, "Missense_Mutation");
1386 assert_eq!(rows[1].hugo_symbol, "Unknown");
1387 assert_ne!(rows[1].variant_classification, "Missense_Mutation");
1388 }
1389
1390 #[test]
1391 fn test_multiallelic_annotation_kept_when_it_names_the_second_allele() {
1392 let record = annotated_record(8324505, "CCCCA", "GCCCC,CCCCC", "CCCCC");
1393 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1394 &record, "c", "GRCh38", "s", None, None,
1395 )
1396 .unwrap();
1397
1398 assert_eq!(rows[0].hugo_symbol, "Unknown");
1399 assert_eq!(rows[1].hugo_symbol, "SLC45A1");
1400 }
1401
1402 #[test]
1403 fn test_multiallelic_matches_vep_minimal_indel_allele() {
1404 let record = annotated_record(100, "AT", "A,ATT", "-");
1406 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1407 &record, "c", "GRCh38", "s", None, None,
1408 )
1409 .unwrap();
1410
1411 assert_eq!(
1412 rows[0].hugo_symbol, "SLC45A1",
1413 "deletion allele is the annotated one"
1414 );
1415 assert_eq!(rows[1].hugo_symbol, "Unknown");
1416 }
1417
1418 #[test]
1419 fn test_multiallelic_matches_vep_anchor_stripped_insertion_allele() {
1420 let record = annotated_record(73385903, "TGGAGGA", "T,TGGAGGAGGA", "GGAGGAGGA");
1423 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1424 &record, "c", "GRCh38", "s", None, None,
1425 )
1426 .unwrap();
1427
1428 assert_eq!(
1429 rows[0].hugo_symbol, "Unknown",
1430 "deletion allele was not annotated"
1431 );
1432 assert_eq!(rows[1].hugo_symbol, "SLC45A1");
1433 }
1434
1435 #[test]
1436 fn test_allele_num_decides_when_vep_provides_it() {
1437 let mut record = annotated_record(100, "A", "G,T", "does_not_match");
1440 record
1441 .info_fields
1442 .insert("CSQ_ALLELE_NUM".to_string(), "2".to_string());
1443 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1444 &record, "c", "GRCh38", "s", None, None,
1445 )
1446 .unwrap();
1447
1448 assert_eq!(rows[0].hugo_symbol, "Unknown");
1449 assert_eq!(rows[1].hugo_symbol, "SLC45A1");
1450 }
1451
1452 #[test]
1453 fn test_single_allele_annotation_is_never_stripped() {
1454 let record = annotated_record(100, "A", "G", "does_not_match");
1456 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1457 &record, "c", "GRCh38", "s", None, None,
1458 )
1459 .unwrap();
1460
1461 assert_eq!(rows.len(), 1);
1462 assert_eq!(rows[0].hugo_symbol, "SLC45A1");
1463 }
1464
1465 #[test]
1466 fn test_hgvsp_to_short_converts_three_letter_codes() {
1467 assert_eq!(MafRecord::hgvsp_to_short("p.Val600Glu"), "p.V600E");
1468 assert_eq!(MafRecord::hgvsp_to_short("p.Trp24Ter"), "p.W24*");
1469 assert_eq!(MafRecord::hgvsp_to_short("p.Gly12Asp"), "p.G12D");
1470 }
1471
1472 #[test]
1473 fn test_hgvsp_to_short_passes_through_non_matching_input() {
1474 assert_eq!(MafRecord::hgvsp_to_short(""), "");
1475 assert_eq!(MafRecord::hgvsp_to_short("."), ".");
1476 }
1477
1478 fn maf_with_hgvs(consequence: &str, hgvsc: Option<&str>, hgvsp: Option<&str>) -> MafRecord {
1480 let mut info_fields = HashMap::new();
1481 info_fields.insert("CSQ_Consequence".to_string(), consequence.to_string());
1482 if let Some(c) = hgvsc {
1483 info_fields.insert("CSQ_HGVSc".to_string(), c.to_string());
1484 }
1485 if let Some(p) = hgvsp {
1486 info_fields.insert("CSQ_HGVSp".to_string(), p.to_string());
1487 }
1488 let record = create_test_maf_record("chr1", 100, "A", "G", Some(60.0), "PASS", info_fields);
1489 MafRecord::from_reformatted_record_for_samples(
1490 &record, "test", "GRCh38", "sample", None, None,
1491 )
1492 .unwrap()
1493 }
1494
1495 #[test]
1496 fn test_hgvs_strips_the_reference_sequence_accession() {
1497 let maf = maf_with_hgvs(
1500 "missense_variant",
1501 Some("ENST00000641515.2:c.760T>A"),
1502 Some("ENSP00000493376.2:p.Leu254Met"),
1503 );
1504 assert_eq!(maf.hgvsc.as_deref(), Some("c.760T>A"));
1505 assert_eq!(maf.hgvsp.as_deref(), Some("p.Leu254Met"));
1506 }
1507
1508 #[test]
1509 fn test_hgvsp_short_is_built_from_the_stripped_hgvsp() {
1510 let maf = maf_with_hgvs(
1512 "missense_variant",
1513 Some("ENST00000288602.11:c.1799T>A"),
1514 Some("ENSP00000288602.6:p.Val600Glu"),
1515 );
1516 assert_eq!(maf.hgvsp_short.as_deref(), Some("p.V600E"));
1517 }
1518
1519 #[test]
1520 fn test_hgvs_without_an_accession_is_left_alone() {
1521 let maf = maf_with_hgvs("synonymous_variant", Some("c.*91G>T"), Some("p.Pro34Pro"));
1523 assert_eq!(maf.hgvsc.as_deref(), Some("c.*91G>T"));
1524 assert_eq!(maf.hgvsp.as_deref(), Some("p.Pro34Pro"));
1525 assert_eq!(maf.hgvsp_short.as_deref(), Some("p.P34P"));
1526 }
1527
1528 #[test]
1529 fn test_splice_site_gets_a_synthetic_hgvsp_short() {
1530 let maf = maf_with_hgvs(
1534 "splice_donor_variant",
1535 Some("ENST00000380152.8:c.756+1G>T"),
1536 None,
1537 );
1538 assert_eq!(maf.hgvsp_short.as_deref(), Some("p.X252_splice"));
1539 assert_eq!(maf.variant_classification, "Splice_Site");
1540 }
1541
1542 #[test]
1543 fn test_synthetic_splice_position_rounds_the_codon_up() {
1544 let a = maf_with_hgvs("splice_acceptor_variant", Some("c.757-2A>G"), None);
1546 assert_eq!(a.hgvsp_short.as_deref(), Some("p.X253_splice"));
1547 let b = maf_with_hgvs("splice_acceptor_variant", Some("c.758-1A>G"), None);
1548 assert_eq!(b.hgvsp_short.as_deref(), Some("p.X253_splice"));
1549 }
1550
1551 #[test]
1552 fn test_splice_rule_keys_on_the_most_severe_consequence() {
1553 let maf = maf_with_hgvs(
1556 "intron_variant&splice_donor_variant",
1557 Some("c.300+1G>A"),
1558 None,
1559 );
1560 assert_eq!(maf.hgvsp_short.as_deref(), Some("p.X100_splice"));
1561 }
1562
1563 #[test]
1564 fn test_splice_rule_needs_a_numeric_cdna_position() {
1565 let maf = maf_with_hgvs("splice_donor_variant", Some("c.-14+1G>T"), None);
1568 assert_eq!(maf.hgvsp_short, None);
1569 }
1570
1571 #[test]
1572 fn test_non_splice_consequences_keep_their_real_hgvsp_short() {
1573 let maf = maf_with_hgvs(
1574 "missense_variant",
1575 Some("ENST00000288602.11:c.1799T>A"),
1576 Some("ENSP00000288602.6:p.Val600Glu"),
1577 );
1578 assert_eq!(maf.hgvsp_short.as_deref(), Some("p.V600E"));
1579 }
1580
1581 #[test]
1582 fn test_get_exon_number_prefers_vep_exon_field() {
1583 let mut info = HashMap::new();
1584 info.insert("CSQ_EXON".to_string(), "3/10".to_string());
1585 info.insert("ANN_Rank".to_string(), "4/12".to_string());
1586 assert_eq!(MafRecord::get_exon_number(&info), Some("3/10".to_string()));
1587 }
1588
1589 #[test]
1590 fn test_get_exon_number_falls_back_to_snpeff_rank() {
1591 let mut info = HashMap::new();
1592 info.insert("ANN_Rank".to_string(), "4/12".to_string());
1593 assert_eq!(MafRecord::get_exon_number(&info), Some("4/12".to_string()));
1594 }
1595
1596 #[test]
1597 fn test_get_exon_number_none_when_absent() {
1598 let info = HashMap::new();
1599 assert_eq!(MafRecord::get_exon_number(&info), None);
1600 }
1601
1602 #[test]
1603 fn test_get_hugo_symbol_prefers_annotated_symbol() {
1604 let mut info = HashMap::new();
1605 info.insert("CSQ_SYMBOL".to_string(), "BRCA1".to_string());
1606 assert_eq!(
1607 MafRecord::get_hugo_symbol(&info, &Some("ENST00000123456".to_string())),
1608 "BRCA1"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_get_hugo_symbol_falls_back_to_transcript_id_when_symbol_missing() {
1614 let info = HashMap::new();
1615 assert_eq!(
1616 MafRecord::get_hugo_symbol(&info, &Some("ENST00000620188".to_string())),
1617 "ENST00000620188"
1618 );
1619 }
1620
1621 #[test]
1622 fn test_get_hugo_symbol_falls_back_to_unknown_when_no_symbol_or_transcript() {
1623 let info = HashMap::new();
1624 assert_eq!(MafRecord::get_hugo_symbol(&info, &None), "Unknown");
1625 }
1626
1627 #[test]
1628 fn test_extract_ref_depth_from_info_ro() {
1629 let mut info = HashMap::new();
1630 info.insert("INFO_RO".to_string(), "42".to_string());
1631 assert_eq!(MafRecord::extract_ref_depth(&info), Some(42));
1632 }
1633
1634 #[test]
1635 fn test_extract_ref_depth_none_when_absent() {
1636 let info = HashMap::new();
1637 assert_eq!(MafRecord::extract_ref_depth(&info), None);
1638 }
1639
1640 fn record_with_genotype(reference: &str, alternate: &str, gt: &str) -> ReformattedVcfRecord {
1641 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1642 let mut format_fields = HashMap::new();
1643 format_fields.insert("GT".to_string(), gt.to_string());
1644 let mut record = annotated_record(100, reference, alternate, alternate);
1645 record.format_sample_data = Some(ParsedFormatSample {
1646 format_keys: vec!["GT".to_string()],
1647 samples: vec![ParsedSample {
1648 sample_name: "TUMOR".to_string(),
1649 format_fields,
1650 }],
1651 });
1652 record
1653 }
1654
1655 #[test]
1656 fn test_tumor_seq_allele1_is_the_alt_when_genotype_is_hom_alt() {
1657 let record = record_with_genotype("T", "C", "1/1");
1660 let maf =
1661 MafRecord::from_reformatted_record_for_samples(&record, "c", "GRCh38", "s", None, None)
1662 .unwrap();
1663
1664 assert_eq!(maf.reference_allele, "T");
1665 assert_eq!(maf.tumor_seq_allele1, "C");
1666 assert_eq!(maf.tumor_seq_allele2, "C");
1667 }
1668
1669 #[test]
1670 fn test_tumor_seq_allele1_hom_alt_deletion_uses_the_dash_form() {
1671 let record = record_with_genotype("ATCG", "A", "1|1");
1672 let maf =
1673 MafRecord::from_reformatted_record_for_samples(&record, "c", "GRCh38", "s", None, None)
1674 .unwrap();
1675
1676 assert_eq!(maf.reference_allele, "TCG");
1677 assert_eq!(maf.tumor_seq_allele1, "-");
1678 }
1679
1680 #[test]
1681 fn test_tumor_seq_allele1_stays_reference_for_het_and_missing_genotypes() {
1682 for gt in ["0/1", "0|1", "./.", "."] {
1684 let record = record_with_genotype("T", "C", gt);
1685 let maf = MafRecord::from_reformatted_record_for_samples(
1686 &record, "c", "GRCh38", "s", None, None,
1687 )
1688 .unwrap();
1689 assert_eq!(maf.tumor_seq_allele1, "T", "GT was {gt}");
1690 }
1691 let maf = maf_from(100, "T", "C");
1692 assert_eq!(maf.tumor_seq_allele1, "T", "no sample columns at all");
1693 }
1694
1695 #[test]
1696 fn test_multiallelic_genotype_reports_the_sibling_allele() {
1697 let record = record_with_genotype("CT", "TC,CC", "1/2");
1700 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1701 &record, "c", "GRCh38", "s", None, None,
1702 )
1703 .unwrap();
1704
1705 assert_eq!(rows[0].tumor_seq_allele2, "TC");
1706 assert_eq!(rows[0].tumor_seq_allele1, "CC");
1707 assert_eq!(rows[1].tumor_seq_allele2, "C");
1709 assert_eq!(rows[1].tumor_seq_allele1, "C");
1710 }
1711
1712 #[test]
1713 fn test_multiallelic_genotype_with_reference_allele_reports_reference() {
1714 let record = record_with_genotype("CT", "TC,CC", "0/2");
1715 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1716 &record, "c", "GRCh38", "s", None, None,
1717 )
1718 .unwrap();
1719
1720 assert_eq!(rows[1].tumor_seq_allele2, "C");
1721 assert_eq!(
1722 rows[1].tumor_seq_allele1, "T",
1723 "GT names the reference allele"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_multiallelic_sibling_shorter_than_the_trim_becomes_dash() {
1729 let record = record_with_genotype("TGGAGGA", "T,TGGAGGAGGA", "1/2");
1732 let rows = MafRecord::from_reformatted_record_multi_for_samples(
1733 &record, "c", "GRCh38", "s", None, None,
1734 )
1735 .unwrap();
1736
1737 assert_eq!(rows[1].tumor_seq_allele2, "GGA", "insertion row");
1738 assert_eq!(rows[1].tumor_seq_allele1, "-");
1739 }
1740
1741 #[test]
1742 fn test_t_depth_prefers_the_sample_over_info_dp() {
1743 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1746 let mut format_fields = HashMap::new();
1747 format_fields.insert("DP".to_string(), "90".to_string());
1748 format_fields.insert("AD".to_string(), "60,30".to_string());
1749
1750 let mut record = annotated_record(100, "A", "G", "G");
1751 record
1752 .info_fields
1753 .insert("INFO_DP".to_string(), "100".to_string());
1754 record.format_sample_data = Some(ParsedFormatSample {
1755 format_keys: vec!["DP".to_string(), "AD".to_string()],
1756 samples: vec![ParsedSample {
1757 sample_name: "TUMOR".to_string(),
1758 format_fields,
1759 }],
1760 });
1761
1762 let maf =
1763 MafRecord::from_reformatted_record_for_samples(&record, "c", "GRCh38", "s", None, None)
1764 .unwrap();
1765 assert_eq!(maf.t_depth, Some(90));
1766 assert_eq!(maf.t_ref_count, Some(60));
1767 assert_eq!(maf.t_alt_count, Some(30));
1768 }
1769
1770 #[test]
1771 fn test_t_depth_falls_back_to_info_dp_without_sample_columns() {
1772 let mut record = annotated_record(100, "A", "G", "G");
1773 record
1774 .info_fields
1775 .insert("INFO_DP".to_string(), "100".to_string());
1776
1777 let maf =
1778 MafRecord::from_reformatted_record_for_samples(&record, "c", "GRCh38", "s", None, None)
1779 .unwrap();
1780 assert_eq!(maf.t_depth, Some(100));
1781 }
1782
1783 #[test]
1784 fn test_extract_depth_from_sample_data_returns_ref_and_alt() {
1785 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1786 let mut format_fields = HashMap::new();
1787 format_fields.insert("DP".to_string(), "50".to_string());
1788 format_fields.insert("AD".to_string(), "30,20".to_string());
1789 let sample_data = Some(ParsedFormatSample {
1790 format_keys: vec!["DP".to_string(), "AD".to_string()],
1791 samples: vec![ParsedSample {
1792 sample_name: "SAMPLE-001".to_string(),
1793 format_fields,
1794 }],
1795 });
1796 let (total, refc, alt) = MafRecord::extract_depth_for_sample(&sample_data, None);
1797 assert_eq!(total, Some(50));
1798 assert_eq!(refc, Some(30));
1799 assert_eq!(alt, Some(20));
1800 }
1801
1802 #[test]
1805 fn test_depth_counts_come_from_the_sample_that_supplied_dp() {
1806 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1807 let sample = |name: &str, fields: Vec<(&str, &str)>| ParsedSample {
1808 sample_name: name.to_string(),
1809 format_fields: fields
1810 .into_iter()
1811 .map(|(k, v)| (k.to_string(), v.to_string()))
1812 .collect(),
1813 };
1814 let sample_data = Some(ParsedFormatSample {
1816 format_keys: vec!["DP".to_string(), "AD".to_string()],
1817 samples: vec![
1818 sample("AD_ONLY", vec![("AD", "4,2")]),
1819 sample("DP_ONLY", vec![("DP", "30")]),
1820 ],
1821 });
1822
1823 let (total, refc, alt) = MafRecord::extract_depth_for_sample(&sample_data, None);
1824 assert_eq!(total, Some(30));
1825 assert_eq!(
1826 refc, None,
1827 "AD_ONLY's counts must not sit beside DP_ONLY's depth"
1828 );
1829 assert_eq!(
1830 alt, None,
1831 "AD_ONLY's counts must not sit beside DP_ONLY's depth"
1832 );
1833 }
1834
1835 fn tumor_normal_record() -> ReformattedVcfRecord {
1839 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1840 let sample = |name: &str, dp: &str, ad: &str| ParsedSample {
1841 sample_name: name.to_string(),
1842 format_fields: HashMap::from([
1843 ("DP".to_string(), dp.to_string()),
1844 ("AD".to_string(), ad.to_string()),
1845 ]),
1846 };
1847 let mut record = create_test_maf_record(
1848 "chr1",
1849 69787,
1850 "T",
1851 "A",
1852 Some(50.0),
1853 "PASS",
1854 HashMap::from([
1855 ("INFO_DP".to_string(), "7".to_string()),
1856 ("INFO_RO".to_string(), "5".to_string()),
1857 ("INFO_AO".to_string(), "2".to_string()),
1858 ]),
1859 );
1860 record.format_sample_data = Some(ParsedFormatSample {
1861 format_keys: vec!["DP".to_string(), "AD".to_string()],
1862 samples: vec![
1863 sample("B487_1_V", "1", "1,0"),
1864 sample("B487_1_cOM", "6", "4,2"),
1865 ],
1866 });
1867 record
1868 }
1869
1870 #[test]
1871 fn per_allele_alt_count_comes_from_the_tumor_sample_ad_not_pooled_info_ao() {
1872 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1876 let mut record = create_test_maf_record(
1877 "chr1",
1878 1000,
1879 "A",
1880 "G,T",
1881 Some(50.0),
1882 "PASS",
1883 HashMap::from([("INFO_AO".to_string(), "30,40".to_string())]),
1884 );
1885 record.format_sample_data = Some(ParsedFormatSample {
1886 format_keys: vec!["DP".to_string(), "AD".to_string()],
1887 samples: vec![
1888 ParsedSample {
1889 sample_name: "TUMOR".to_string(),
1890 format_fields: HashMap::from([
1891 ("DP".to_string(), "20".to_string()),
1892 ("AD".to_string(), "10,3,7".to_string()),
1893 ]),
1894 },
1895 ParsedSample {
1896 sample_name: "NORMAL".to_string(),
1897 format_fields: HashMap::from([
1898 ("DP".to_string(), "60".to_string()),
1899 ("AD".to_string(), "5,27,33".to_string()),
1900 ]),
1901 },
1902 ],
1903 });
1904
1905 let mafs = MafRecord::from_reformatted_record_multi_for_samples(
1906 &record,
1907 "c",
1908 "GRCh38",
1909 "s",
1910 Some("TUMOR"),
1911 None,
1912 )
1913 .unwrap();
1914
1915 assert_eq!(mafs.len(), 2);
1916 assert_eq!(
1917 mafs[0].t_alt_count,
1918 Some(3),
1919 "first ALT takes the tumor's AD[1]"
1920 );
1921 assert_eq!(
1922 mafs[1].t_alt_count,
1923 Some(7),
1924 "second ALT takes the tumor's AD[2]"
1925 );
1926 for m in &mafs {
1927 let (d, r, a) = (
1928 m.t_depth.unwrap(),
1929 m.t_ref_count.unwrap(),
1930 m.t_alt_count.unwrap(),
1931 );
1932 assert!(r + a <= d, "t_ref {r} + t_alt {a} exceeds t_depth {d}");
1933 }
1934 }
1935
1936 #[test]
1937 fn a_tumor_with_no_call_reports_no_depths_rather_than_the_pooled_info() {
1938 use crate::extract_sample_info::{ParsedFormatSample, ParsedSample};
1943 let mut record = create_test_maf_record(
1944 "chr1",
1945 21899250,
1946 "G",
1947 "C",
1948 Some(50.0),
1949 "PASS",
1950 HashMap::from([
1951 ("INFO_DP".to_string(), "2".to_string()),
1952 ("INFO_RO".to_string(), "0".to_string()),
1953 ("INFO_AO".to_string(), "2".to_string()),
1954 ]),
1955 );
1956 record.format_sample_data = Some(ParsedFormatSample {
1957 format_keys: vec!["DP".to_string(), "AD".to_string()],
1958 samples: vec![
1959 ParsedSample {
1960 sample_name: "B487_1_V".to_string(),
1961 format_fields: HashMap::from([
1962 ("DP".to_string(), ".".to_string()),
1963 ("AD".to_string(), ".".to_string()),
1964 ]),
1965 },
1966 ParsedSample {
1967 sample_name: "B487_1_cOM".to_string(),
1968 format_fields: HashMap::from([
1969 ("DP".to_string(), "2".to_string()),
1970 ("AD".to_string(), "0,2".to_string()),
1971 ]),
1972 },
1973 ],
1974 });
1975
1976 let maf = MafRecord::from_reformatted_record_for_samples(
1977 &record,
1978 "c",
1979 "GRCh38",
1980 "s",
1981 Some("B487_1_V"),
1982 None,
1983 )
1984 .unwrap();
1985 assert_eq!(maf.t_depth, None);
1986 assert_eq!(maf.t_ref_count, None);
1987 assert_eq!(maf.t_alt_count, None);
1988 }
1989
1990 #[test]
1991 fn tumor_counts_all_come_from_the_tumor_sample_not_pooled_info() {
1992 let record = tumor_normal_record();
1996 let maf = MafRecord::from_reformatted_record_for_samples(
1997 &record,
1998 "c",
1999 "GRCh38",
2000 "s",
2001 Some("B487_1_V"),
2002 None,
2003 )
2004 .unwrap();
2005
2006 assert_eq!(maf.t_depth, Some(1));
2007 assert_eq!(maf.t_ref_count, Some(1));
2008 assert_eq!(maf.t_alt_count, Some(0));
2009 }
2010
2011 #[test]
2012 fn tumor_ref_and_alt_counts_never_exceed_tumor_depth() {
2013 let record = tumor_normal_record();
2014 let maf = MafRecord::from_reformatted_record_for_samples(
2015 &record,
2016 "c",
2017 "GRCh38",
2018 "s",
2019 Some("B487_1_V"),
2020 None,
2021 )
2022 .unwrap();
2023 let (d, r, a) = (
2024 maf.t_depth.unwrap(),
2025 maf.t_ref_count.unwrap(),
2026 maf.t_alt_count.unwrap(),
2027 );
2028 assert!(
2029 r + a <= d,
2030 "t_ref_count {r} + t_alt_count {a} exceeds t_depth {d}"
2031 );
2032 }
2033
2034 #[test]
2035 fn naming_the_normal_sample_populates_the_matched_normal_columns() {
2036 let record = tumor_normal_record();
2037 let maf = MafRecord::from_reformatted_record_for_samples(
2038 &record,
2039 "c",
2040 "GRCh38",
2041 "s",
2042 Some("B487_1_V"),
2043 Some("B487_1_cOM"),
2044 )
2045 .unwrap();
2046
2047 assert_eq!(maf.n_depth, Some(6));
2048 assert_eq!(maf.n_ref_count, Some(4));
2049 assert_eq!(maf.n_alt_count, Some(2));
2050 assert_eq!(
2051 maf.matched_norm_sample_barcode.as_deref(),
2052 Some("B487_1_cOM")
2053 );
2054 }
2055
2056 #[test]
2057 fn matched_normal_columns_stay_empty_when_no_normal_is_named() {
2058 let record = tumor_normal_record();
2059 let maf = MafRecord::from_reformatted_record_for_samples(
2060 &record,
2061 "c",
2062 "GRCh38",
2063 "s",
2064 Some("B487_1_V"),
2065 None,
2066 )
2067 .unwrap();
2068
2069 assert_eq!(maf.n_depth, None);
2070 assert_eq!(maf.n_ref_count, None);
2071 assert_eq!(maf.n_alt_count, None);
2072 }
2073
2074 #[test]
2075 fn an_unknown_sample_name_yields_nothing_rather_than_the_first_sample() {
2076 let record = tumor_normal_record();
2078 let maf = MafRecord::from_reformatted_record_for_samples(
2079 &record,
2080 "c",
2081 "GRCh38",
2082 "s",
2083 Some("NOT_IN_THIS_VCF"),
2084 None,
2085 )
2086 .unwrap();
2087
2088 assert_eq!(maf.t_ref_count, None);
2089 assert_eq!(maf.t_alt_count, None);
2090 }
2091
2092 #[test]
2093 fn without_a_named_tumor_the_first_sample_is_still_used() {
2094 let record = tumor_normal_record();
2096 let maf =
2097 MafRecord::from_reformatted_record_for_samples(&record, "c", "GRCh38", "s", None, None)
2098 .unwrap();
2099 assert_eq!(maf.t_depth, Some(1));
2100 }
2101
2102 fn create_test_maf_record(
2105 chromosome: &str,
2106 position: u64,
2107 reference: &str,
2108 alternate: &str,
2109 quality: Option<f64>,
2110 filter: &str,
2111 info_fields: HashMap<String, String>,
2112 ) -> ReformattedVcfRecord {
2113 ReformattedVcfRecord {
2114 chromosome: chromosome.to_string(),
2115 position,
2116 id: Some("rs123456".to_string()),
2117 reference: reference.to_string(),
2118 alternate: alternate.to_string(),
2119 quality,
2120 filter: filter.to_string(),
2121 info_fields,
2122 format_sample_data: None,
2123 annotation_field_type: crate::reformat_vcf::AnnotationFieldType::None,
2124 }
2125 }
2126
2127 #[test]
2128 fn test_maf_headers_match_vcf2maf_core_46_plus_custom() {
2129 let headers = MafRecord::get_maf_headers();
2130 assert_eq!(headers.len(), 50);
2131 let expected = [
2132 "Hugo_Symbol",
2133 "Entrez_Gene_Id",
2134 "Center",
2135 "NCBI_Build",
2136 "Chromosome",
2137 "Start_Position",
2138 "End_Position",
2139 "Strand",
2140 "Variant_Classification",
2141 "Variant_Type",
2142 "Reference_Allele",
2143 "Tumor_Seq_Allele1",
2144 "Tumor_Seq_Allele2",
2145 "dbSNP_RS",
2146 "dbSNP_Val_Status",
2147 "Tumor_Sample_Barcode",
2148 "Matched_Norm_Sample_Barcode",
2149 "Match_Norm_Seq_Allele1",
2150 "Match_Norm_Seq_Allele2",
2151 "Tumor_Validation_Allele1",
2152 "Tumor_Validation_Allele2",
2153 "Match_Norm_Validation_Allele1",
2154 "Match_Norm_Validation_Allele2",
2155 "Verification_Status",
2156 "Validation_Status",
2157 "Mutation_Status",
2158 "Sequencing_Phase",
2159 "Sequence_Source",
2160 "Validation_Method",
2161 "Score",
2162 "BAM_File",
2163 "Sequencer",
2164 "Tumor_Sample_UUID",
2165 "Matched_Norm_Sample_UUID",
2166 "HGVSc",
2167 "HGVSp",
2168 "HGVSp_Short",
2169 "Transcript_ID",
2170 "Exon_Number",
2171 "t_depth",
2172 "t_ref_count",
2173 "t_alt_count",
2174 "n_depth",
2175 "n_ref_count",
2176 "n_alt_count",
2177 "all_effects",
2178 "FILTER",
2179 "QUAL",
2180 "VAF",
2181 "Protein_Position",
2182 ];
2183 assert_eq!(headers, expected.to_vec());
2184 }
2185
2186 #[test]
2187 fn test_to_tsv_line_leaves_unsupported_columns_empty() {
2188 let record =
2189 create_test_maf_record("chr1", 100, "A", "G", Some(60.0), "PASS", HashMap::new());
2190 let maf = MafRecord::from_reformatted_record_for_samples(
2191 &record,
2192 "TestCenter",
2193 "GRCh38",
2194 "SAMPLE-001",
2195 None,
2196 None,
2197 )
2198 .unwrap();
2199 let tsv = maf.to_tsv_line();
2200 let fields: Vec<&str> = tsv.split('\t').collect();
2201 assert_eq!(fields.len(), 50);
2202 for idx in [17, 23, 29, 42, 45] {
2206 assert_eq!(fields[idx], "", "column {idx} should be empty");
2207 }
2208 }
2209
2210 fn dbsnp_from(id: Option<&str>, existing_variation: Option<&str>) -> Option<String> {
2211 let mut info_fields = HashMap::new();
2212 if let Some(ev) = existing_variation {
2213 info_fields.insert("CSQ_Existing_variation".to_string(), ev.to_string());
2214 }
2215 let record = ReformattedVcfRecord {
2216 chromosome: "chr1".to_string(),
2217 position: 100,
2218 id: id.map(|s| s.to_string()),
2219 reference: "A".to_string(),
2220 alternate: "G".to_string(),
2221 quality: Some(60.0),
2222 filter: "PASS".to_string(),
2223 info_fields,
2224 format_sample_data: None,
2225 annotation_field_type: crate::reformat_vcf::AnnotationFieldType::None,
2226 };
2227 MafRecord::from_reformatted_record_for_samples(
2228 &record, "test", "GRCh38", "sample", None, None,
2229 )
2230 .unwrap()
2231 .dbsnp_rs
2232 }
2233
2234 #[test]
2235 fn test_dbsnp_rs_keeps_only_rs_ids_from_existing_variation() {
2236 assert_eq!(
2239 dbsnp_from(None, Some("rs992327&COSV57258734")),
2240 Some("rs992327".to_string())
2241 );
2242 }
2243
2244 #[test]
2245 fn test_dbsnp_rs_joins_multiple_rs_ids_with_commas() {
2246 assert_eq!(
2248 dbsnp_from(None, Some("rs123&COSV1&rs456")),
2249 Some("rs123,rs456".to_string())
2250 );
2251 }
2252
2253 #[test]
2254 fn test_dbsnp_rs_blank_when_variant_known_only_outside_dbsnp() {
2255 assert_eq!(dbsnp_from(None, Some("COSV57258734")), None);
2257 }
2258
2259 #[test]
2260 fn test_dbsnp_rs_is_novel_when_vep_found_no_existing_variation() {
2261 assert_eq!(dbsnp_from(None, Some(".")), Some("novel".to_string()));
2263 }
2264
2265 #[test]
2266 fn test_dbsnp_rs_falls_back_to_vcf_id_when_csq_absent() {
2267 assert_eq!(dbsnp_from(Some("rs123"), None), Some("rs123".to_string()));
2269 }
2270
2271 #[test]
2272 fn test_dbsnp_rs_none_without_annotation_or_id() {
2273 assert_eq!(dbsnp_from(None, None), None);
2274 }
2275}