Skip to main content

vcf_reformatter/
essentials_fields.rs

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>, // was `total_depth` — INFO DP, unchanged extraction
34    pub t_ref_count: Option<u32>, // new — INFO RO / sample AD[0]
35    pub t_alt_count: Option<u32>, // was `depth` — INFO AO / sample AD[1], unchanged extraction
36    pub n_depth: Option<u32>, // matched normal, from --normal-id's FORMAT/DP
37    pub n_ref_count: Option<u32>, // matched normal, from --normal-id's AD[0]
38    pub n_alt_count: Option<u32>, // matched normal, from --normal-id's AD[1]
39    // Trailing custom columns (this tool's own additions, not part of vcf2maf's core 46):
40    pub filter_status: String,
41    pub qual: Option<f64>,
42    pub vaf: Option<f32>,
43    pub protein_position: Option<String>,
44}
45
46/// vcf2maf's `%biotype_priority` (vcf2maf.pl:144-227), transcribed verbatim. Lower is
47/// better. An unrecognized biotype gets 10, as vcf2maf.pl:228-232 does (it also warns;
48/// we stay silent — a per-variant warning on a 92k-variant file is unusable).
49const 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
134/// Transcript biotype rank. Lower is better; anything unlisted is 10.
135pub 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    /// Convert a single-ALT record to a MAF row, reading the depth columns from explicitly named
145    /// samples. `tumor` is the sample the t_* columns describe; `normal` populates the
146    /// matched-normal columns. Passing `None` for either keeps the historical behaviour of
147    /// using the first sample that declares `DP`.
148    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        // Everything positional is decided on the trimmed alleles, exactly as vcf2maf does.
157        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        // Kept character-for-character as vcf2maf.pl:769 writes it:
161        //   $inframe = ( abs( $ref_length - $var_length ) % 3 == 0 ? 1 : 0 );
162        // clippy would rewrite this as .is_multiple_of(3); that is semantically identical but
163        // breaks the line-for-line correspondence with the reference implementation.
164        #[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        // All three tumor counts must describe the SAME reads. They previously did not:
174        // t_depth came from the sample's FORMAT/DP (one sample) while t_ref_count/t_alt_count
175        // came from INFO RO/AO, which sum every sample in the VCF. On a tumor/normal file that
176        // credited the normal's reads to the tumor and produced t_ref + t_alt > t_depth on
177        // 98.4% of rows. Sample values now win for all three; INFO is only a fallback, and
178        // only when no sample was named.
179        let (sample_total, sample_ref, sample_alt) =
180            Self::extract_depth_for_sample(&record.format_sample_data, tumor);
181
182        // With a tumor named, its columns are the only source, as in vcf2maf. INFO is pooled
183        // over every sample, so falling back to it credits the normal's reads to a tumor that
184        // is absent from the VCF — or present with no call at all (".:.:.:."), which is how
185        // 75 rows of the B487 tumor/normal file reported the normal's depth as the tumor's.
186        let (t_depth, t_ref_count, t_alt_count) = if tumor.is_some() {
187            (sample_total, sample_ref, sample_alt)
188        } else {
189            (
190                // vcf2maf.pl:936 reads the sample's FORMAT/DP, not INFO/DP, which counts every
191                // read the caller saw at the locus and runs higher.
192                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        // The splice rule runs after the 3->1 conversion in vcf2maf.pl and assigns
215        // unconditionally, so it wins over a real HGVSp on the rare rows that have both.
216        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            // The VCF's own naming is passed through unchanged (user's call
230            // 2026-08-30, reversing the earlier strip-to-bare-name behaviour). vcf2maf
231            // does the same, so this also removes a whole diff class against it.
232            chromosome: record.chromosome.clone(),
233            start_position: start_pos,
234            end_position: end_pos,
235            // vcf2maf.pl:907 — per the MAF definition, only "+" is an accepted value here.
236            // VEP's transcript strand stays available in the TSV output's CSQ_STRAND column.
237            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            // Neither is derivable from a VCF; main.rs overwrites them when the user passes
253            // --mutation-status / --sequence-source.
254            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", // SnpEff primary
280                "CSQ_Feature",    // VEP
281                "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", // SnpEff format from your data
292                "ANN_Protein_position",
293                "CSQ_Protein_position", // VEP
294                "ANN_AA_pos",
295                "CSQ_AA_pos",
296            ],
297        )
298    }
299
300    /// VEP's `EXON` and SnpEff's `Rank` are both formatted `<rank>/<total>` and are
301    /// vcf2maf's source for the MAF `Exon_Number` column.
302    /// vcf2maf.pl:854-860 — dbSNP_RS comes from VEP's `Existing_variation`, keeping only real
303    /// rs IDs; a variant known only to COSMIC et al. leaves the column blank, and "novel" means
304    /// VEP looked it up and found nothing. SnpEff's ANN carries no equivalent field, so on that
305    /// path the VCF ID column is all there is.
306    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        // VEP joins co-located variants with "&"; vcf2maf.pl:784 rewrites those to "," first.
314        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    /// vcf2maf never leaves Hugo_Symbol blank: upstream/downstream/intronic-lncRNA/IGR variants
329    /// often carry a Gene ID with no HGNC symbol mapping, so VEP's SYMBOL field is empty. vcf2maf
330    /// falls back to the transcript ID when one is associated, or the literal "Unknown" when
331    /// there's no transcript either (pure IGR) — matched here for exact parity.
332    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    /// A VCF ALT that is not a literal base sequence: a symbolic allele (`<DEL>`, `<NON_REF>`,
342    /// `<*>`) or a breakend (`A[chr2:456[`, `]chr2:456]A`). No MAF column can hold one — before
343    /// 2026-09-09 `<DEL>` came out typed `INS`, an insertion of the five characters `<DEL>` —
344    /// so such an allele produces no MAF row at all. The TSV path still carries it verbatim.
345    ///
346    /// `*` (spanning deletion) is deliberately **not** included: vcf2maf drops those lines and
347    /// we keep them, user's call 2026-09-09.
348    pub fn is_symbolic_allele(alt: &str) -> bool {
349        alt.starts_with('<') || alt.contains('[') || alt.contains(']')
350    }
351
352    /// One MAF row per alternate allele, with the depth columns read from explicitly named
353    /// tumor and normal samples.
354    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            // A structural or non-base ALT has no honest MAF representation; skip it rather
367            // than type it as an insertion of its own literal text.
368            if Self::is_symbolic_allele(alternate) {
369                continue;
370            }
371
372            // The annotation we kept belongs to one specific allele. On a multiallelic line,
373            // carrying it onto the other ALTs would report a gene and consequence for an allele
374            // the annotator never described, so those rows go out unannotated instead.
375            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            // Create a record for each alternate allele
389            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            // Use the unified conversion logic
403            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            // Tumor_Seq_Allele1 needs the sibling ALTs a single-ALT record no longer carries:
413            // a 1/2 genotype reports the *other* ALT (vcf2maf.pl:918-921).
414            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            // Per-allele alt count. With a tumor named it comes from that sample's AD only;
425            // INFO AO pools every sample, so on a tumor/normal VCF it credits the normal's
426            // reads to the tumor.
427            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                // Recalculate VAF with allele-specific depth
437                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    // Extract tumor depth for specific allele index
451    /// The alt-observation count for one specific ALT, from a sample's `AD` — which carries
452    /// one entry per allele (`ref,alt1,alt2,...`), so ALT number `n` is `AD[n + 1]`.
453    /// This is the per-sample counterpart of `extract_tumor_depth_for_allele`, which reads
454    /// INFO `AO` and therefore sums every sample in a multi-sample VCF.
455    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        // Fallback to total tumor depth
486        Self::extract_tumor_depth(info_fields)
487    }
488
489    // Extract Entrez Gene ID from HGNC if available
490    fn get_entrez_gene_id(info_fields: &HashMap<String, String>) -> Option<u32> {
491        // Try HGNC first (VEP style)
492        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    /// Depth, ref count and alt count from one sample. With `sample` set, only that sample is
503    /// considered (and an absent name yields nothing); with `None`, the historical behaviour
504    /// of taking the first sample that declares `DP` is kept.
505    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            // Check each candidate sample for depth information
515            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                // Total depth (DP field)
521                let dp = sample
522                    .format_fields
523                    .get("DP")
524                    .and_then(|dp| dp.parse::<u32>().ok());
525
526                // Reference/alternative depth (AD field - usually comma-separated: ref,alt)
527                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                // All three columns must describe one sample, so the counts are only
538                // kept alongside the DP that selected it. Without this, a sample with
539                // AD but no DP leaves its counts next to a later sample's depth.
540                if dp.is_some() {
541                    total_depth = dp;
542                    ref_depth = ad_ref;
543                    alt_depth = ad_alt;
544                    break;
545                }
546
547                // No sample has declared DP yet: remember the first AD seen, in case
548                // none ever does.
549                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            // Handle comma-separated values by taking the first one
572            let first_value = s.split(',').next().unwrap_or(&s);
573            first_value.parse().ok()
574        })
575    }
576
577    /// freebayes-style INFO `RO` (Reference Observation count) is this tool's only
578    /// direct source for t_ref_count; sample-level `AD[0]` is the fallback (see
579    /// `extract_depth_from_sample_data`), mirroring how `extract_tumor_depth` already
580    /// falls back from INFO `AO` to sample `AD[1]`.
581    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    /// Extract sequencing platform or variant calling tool info
586    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        // If no specific field found, return None instead of defaulting
596        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    /// Does the annotation we kept actually describe this ALT?
614    ///
615    /// VEP's `ALLELE_NUM` settles it outright when present — that is the key vcf2maf.pl:867
616    /// uses — but VEP only emits it under `--allele_number`, which plenty of real files were
617    /// not annotated with. Without it, fall back to the allele name in the first CSQ/ANN
618    /// subfield, which VEP writes in any of three forms: the ALT verbatim, the ALT with its
619    /// anchor base removed, or the fully prefix-trimmed allele ("-" when that leaves nothing).
620    /// No allele named at all — unannotated input — counts as a match, so such records are
621    /// left exactly as they were.
622    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    /// Strip the bases REF and ALT share at the front, moving the position along with them.
648    /// vcf2maf.pl:749-752 does this for *every* variant type, not just indels — so an
649    /// un-normalized `CCCCA>CCCCC` is reported as the A>C SNP it actually is.
650    /// Note: VCF alleles are ASCII (A/C/G/T), so char count == byte length.
651    fn shared_prefix_len(reference: &str, alternate: &str) -> usize {
652        // vcf2maf's loop is guarded on `$ref ne $var`, so identical alleles are left alone.
653        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    /// Takes the *trimmed* REF. An empty REF is an insertion, which MAF anchors between the
677    /// two flanking bases; everything else spans the reference bases it replaces or deletes.
678    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    /// Takes the *trimmed* alleles. MAF writes a bare "-" where a trimmed allele is empty.
687    ///
688    /// vcf2maf.pl:913-921 picks Tumor_Seq_Allele1 as the first genotype allele that isn't the
689    /// variant, so a homozygous-alt call reports the ALT twice; with no usable GT it assumes a
690    /// ref/var heterozygote. A `1/2` call needs the sibling ALTs, which only
691    /// `from_reformatted_record_multi` still has — it patches the result afterwards.
692    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    /// Tumor_Seq_Allele1 for one ALT of a multiallelic line, ported from vcf2maf.pl:918-921:
708    /// the first GT allele that isn't this row's variant, so `1/2` reports the *sibling* ALT.
709    /// Alleles are trimmed by the prefix this row's REF/ALT share (vcf2maf.pl:749-752 trims the
710    /// whole allele list together), and a sibling shorter than that trim collapses to "-", the
711    /// same as Perl's `substr` past the end of the string.
712    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        // "If GT was monoploid, then $idx2 will be undefined, and we should set it equal to $idx1"
732        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    /// The tumor sample's GT, taken from the first sample that declares one — the same
743    /// "first sample is the tumor" assumption `extract_depth_from_sample_data` makes.
744    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    /// Sequence Ontology term severity ranks, ported from vcf2maf's `%effectPriority`
784    /// (mskcc/vcf2maf, GetEffectPriority) so consequence resolution matches the reference
785    /// tool exactly rather than picking whichever `&`-joined term happens to appear first.
786    /// Lower number = more severe. Unrecognized terms default to 20, same as vcf2maf.
787    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    /// Resolve a (possibly `&`/`,`/`|`-joined) multi-term consequence string down to the
878    /// single most severe term, mirroring vcf2maf's sort-by-priority-then-take-first.
879    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    /// Ported from vcf2maf's `GetVariantClassification`: classifies the single most-severe
890    /// consequence term, using variant_type/inframe only to disambiguate the terms whose MAF
891    /// classification depends on them (frameshift_variant, protein_altering_variant).
892    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        // Everything else (TFBS/regulatory ablation/amplification, feature_elongation/
1000        // truncation, coding_transcript_variant, sequence_variant, ...): vcf2maf's own
1001        // catch-all.
1002        "Targeted_Region".to_string()
1003    }
1004
1005    /// 3-letter → 1-letter amino acid code table, verbatim from vcf2maf's `%aa3to1`
1006    /// (mskcc/vcf2maf, vcf2maf.pl) so HGVSp_Short matches the reference tool exactly.
1007    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    /// Convert an HGVSp protein-change string to its short form, e.g.
1035    /// "p.Val600Glu" -> "p.V600E". Ported from vcf2maf's `%aa3to1` substitution.
1036    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    /// Drop the reference-sequence accession from an HGVS string, as vcf2maf.pl:786-787 does
1045    /// with `s/^.*://`. VEP writes full HGVS ("ENST00000641515.2:c.760T>A"); the MAF column
1046    /// carries only the change, the accession being already present in Transcript_ID. SnpEff
1047    /// writes the change bare, with no colon, so it passes through untouched.
1048    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    /// The synthetic `p.X{codon}_splice` protein change vcf2maf.pl:824-834 builds for splice
1056    /// acceptor/donor variants. They sit in an intron, so VEP reports no protein change at all;
1057    /// vcf2maf derives a codon number from the cDNA position so the MAF still carries a protein
1058    /// coordinate. Returns None for every other consequence, and for an HGVSc whose position is
1059    /// not a plain number (a 5' UTR `c.-14+1G>T` or a 3' UTR `c.*91G>T` never matches
1060    /// vcf2maf's `m/^c.(\d+)/`, so it leaves HGVSp_Short alone).
1061    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        // vcf2maf gates on One_Consequence — the most severe term, not the first one listed.
1068        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        // vcf2maf.pl:828 guards against cDNA positions below 1 before dividing.
1082        let c_pos = digits.parse::<u64>().ok()?.max(1);
1083        // vcf2maf.pl:829 — sprintf( "%.0f", ( $c_pos + $c_pos % 3 ) / 3 ). Perl divides in
1084        // floating point and rounds; integer division would truncate instead. The quotient is
1085        // always whole, x.333 or x.667, never a .5 tie, so the rounding mode does not matter.
1086        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            // vcf2maf.pl:1050 — "Targeted_Region" when there is no effect to go on at all.
1097            _ => "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        // vcf2maf leaves a cell it has no data for empty. A "." there was our own invention
1161        // and the single largest diff class against it. Cells whose "." came from the VCF
1162        // itself (FILTER, ID) are untouched — that dot is content, not absence.
1163        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, // 18 Match_Norm_Seq_Allele1 — the normal's GT is not read, only its depths
1195            empty, // 19 Match_Norm_Seq_Allele2
1196            empty, // 20 Tumor_Validation_Allele1
1197            empty, // 21 Tumor_Validation_Allele2
1198            empty, // 22 Match_Norm_Validation_Allele1
1199            empty, // 23 Match_Norm_Validation_Allele2
1200            empty, // 24 Verification_Status
1201            self.validation_status.as_deref().unwrap_or(empty),
1202            self.mutation_status.as_str(),
1203            empty, // 27 Sequencing_Phase
1204            self.sequence_source.as_str(),
1205            empty, // 29 Validation_Method
1206            empty, // 30 Score
1207            empty, // 31 BAM_File
1208            self.sequencer.as_deref().unwrap_or(empty),
1209            empty, // 33 Tumor_Sample_UUID
1210            empty, // 34 Matched_Norm_Sample_UUID
1211            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, // 46 all_effects — see Global Constraints: deferred, needs full transcript list
1223            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        // `*` is a spanning deletion, not a symbolic allele: those rows are kept on purpose.
1243        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        // vcf2maf.pl:755 — ( $start, $stop ) = ( $pos, $pos + $var_length - 1 )
1275        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        // Real un-normalized freebayes site: chr1:8324505 CCCCA>CCCCC is an A>C SNP at 8324509.
1290        // vcf2maf.pl:749-752 strips shared leading bases for every variant type, not just indels.
1291        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        // vcf2maf.pl:907 — "Per MAF definition, only the positive strand is an accepted value".
1346        // The MAF Strand column is genomic; VEP's transcript strand does not belong in it.
1347        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        // Nothing in a VCF says the calls are somatic or that the library was an exome.
1361        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        // vcf2maf.pl:1050 — return "Targeted_Region" if( not defined $effect or not $effect );
1369        // "Unknown" is not a value the MAF spec allows in this column.
1370        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        // VEP annotated GCCCC only; CCCCC must not inherit its gene and consequence.
1377        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        // VEP reports deletions as "-": REF=AT ALT=A is a deletion of T.
1405        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        // Real site: REF=TGGAGGA ALT=T,TGGAGGAGGA — VEP names the insertion allele
1421        // "GGAGGAGGA", i.e. the ALT with only its anchor base removed.
1422        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        // vcf2maf.pl:867 skips effects whose ALLELE_NUM is not this ALT's 1-based index.
1438        // Present only when VEP ran with --allele_number, and authoritative when it is.
1439        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        // Guard: allele filtering must not touch the ordinary one-ALT case.
1455        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    /// Build a MafRecord from just the annotation fields the HGVS columns are derived from.
1479    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        // vcf2maf.pl:786-787 — s/^.*:// on both. VEP writes full HGVS ("ENST...:c.760T>A");
1498        // the MAF column carries only the change, since Transcript_ID sits right beside it.
1499        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        // The 3->1 conversion must run on the change alone, or the accession is carried along.
1511        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        // SnpEff's ANN writes the change bare, with no accession and no colon.
1522        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        // vcf2maf.pl:824-834 — splice variants are intronic, so VEP reports no protein change.
1531        // vcf2maf synthesizes one from the cDNA position: p.X{codon}_splice.
1532        // c.756+1G>T -> c_pos 756, 756 % 3 == 0 -> 756 / 3 = 252.
1533        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        // ( c_pos + c_pos % 3 ) / 3 rounded: 757 -> 758/3 = 252.67 -> 253; 758 -> 760/3 -> 253.
1545        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        // vcf2maf gates on One_Consequence, i.e. after sorting by severity — not on whichever
1554        // term VEP happened to list first. splice_donor (2) outranks intron_variant (14).
1555        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        // vcf2maf.pl:826 matches /^c.(\d+)/, which a 5' UTR position like c.-14+1 never
1566        // satisfies; the rewrite is skipped and HGVSp_Short stays empty.
1567        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        // vcf2maf.pl:913-921 — Tumor_Seq_Allele1 is the first GT allele that isn't the variant,
1658        // so a 1/1 call reports the ALT twice rather than pretending the site is heterozygous.
1659        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        // Guard: only hom-alt changes. vcf2maf assumes ref/var het when GT is absent or "./.".
1683        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        // Real site: chr1:240207640 REF=CT ALT=TC,CC GT=1/2. vcf2maf.pl:921 takes the first GT
1698        // allele that isn't this row's variant, so the TC row reports CC and vice versa.
1699        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        // The CC row trims the C it shares with REF=CT, and its siblings trim with it.
1708        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        // REF=TGGAGGA ALT=T,TGGAGGAGGA GT=1/2: for the insertion row the trim eats 7 bases, more
1730        // than the sibling deletion allele has, and vcf2maf's substr loop leaves it as "-".
1731        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        // vcf2maf.pl:936 takes t_depth from the tumor sample's FORMAT/DP. INFO/DP counts reads
1744        // the caller saw at the locus, which on mutect2 output is consistently higher.
1745        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    /// A sample carrying AD but no usable DP must not leave its counts beside a later
1803    /// sample's depth: all three columns describe one sample or none.
1804    #[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        // The first sample declares AD and no DP, the second declares DP and no AD.
1815        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    /// Two samples with the pooled INFO counts a real tumor/normal freebayes VCF carries.
1836    /// Modelled on the first variant of B487_1_V_vs_B487_1_cOM: INFO DP=7 RO=5 AO=2 is the
1837    /// SUM over both samples, while the tumor itself has DP=1, AD=1,0.
1838    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        // The multi path overrode t_alt_count from INFO/AO after conversion, which on a
1873        // tumor/normal VCF pools every sample. The tumor's own AD carries one entry per
1874        // allele (ref,alt1,alt2), and that is what each row must report.
1875        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        // freebayes writes ".:.:.:." for a sample it made no call in. INFO DP/RO/AO still
1939        // carry the other sample's reads, and falling back to them reported the normal's
1940        // depth as the tumor's on 75 rows of B487_1_V_vs_B487_1_cOM. vcf2maf leaves the
1941        // whole depth family empty here, and so must we.
1942        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        // The bug: t_depth was read from the sample while t_ref_count/t_alt_count came from
1993        // INFO RO/AO, which sum every sample. That produced t_ref + t_alt = 7 against a
1994        // t_depth of 1, and credited the normal's 2 alt reads to the tumor.
1995        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        // Falling back to sample 1 would reproduce the guess this parameter exists to remove.
2077        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        // Existing single-sample behaviour must not change; only naming a sample changes it.
2095        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    // Same shape as `tests/common::record`; that helper lives in the integration-test crates
2103    // and isn't reachable from here.
2104    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        // Match_Norm_Seq_Allele1 (idx 17), Verification_Status (idx 23), Score (idx 29),
2203        // n_depth (idx 42), all_effects (idx 45) — no data source, so empty as vcf2maf
2204        // writes them. A "." here was our own invention and the largest diff class.
2205        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        // Real CSQ value from B505_1_V.mutect2.filtered_VEP.ann.vcf.gz. VEP joins co-located
2237        // variants with "&"; vcf2maf.pl:784 rewrites those to "," before filtering on /^rs\d+$/.
2238        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        // vcf2maf.pl:856 — join( ",", grep{m/^rs\d+$/} ... )
2247        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        // vcf2maf.pl:855 — "If seen in a DB other than dbSNP, this field will remain blank"
2256        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        // vcf2maf.pl:858-860 — VEP looked and came back empty, which is a result, not missing data.
2262        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        // SnpEff's ANN has no Existing_variation equivalent, so the ID column is all we have.
2268        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}