Skip to main content

holodeck_lib/vcf/
mod.rs

1//! VCF input/output for holodeck.
2//!
3//! Reads variants from a multi-sample VCF (resolving GT against a chosen
4//! sample) for use by `simulate` and `methylate`, and houses the methylation
5//! classifier and `MT`/`MB` FORMAT reader/writer that carries per-haplotype
6//! per-strand CpG methylation truth between the two commands. Submodules:
7//! - [`genotype`] — `Genotype` and `VariantRecord` types plus GT parsing.
8//! - [`methylation`] — per-CpG classifier, `MT`/`MB` writer/reader, and
9//!   header probe.
10
11pub mod genotype;
12/// Per-CpG methylation classifier and MT/MB FORMAT VCF reader/writer used by
13/// the `methylate` subcommand and the methylation-aware `simulate` path.
14pub mod methylation;
15
16use std::collections::HashMap;
17use std::path::Path;
18
19use anyhow::{Context, Result, bail};
20use noodles::vcf;
21use noodles::vcf::variant::record::AlternateBases;
22
23use crate::sequence_dict::SequenceDictionary;
24use genotype::{Genotype, VariantRecord};
25
26/// Result of a single-pass VCF scan: per-contig ALT-bearing variants plus
27/// the sample's ploidy resolved from *unfiltered* genotypes.
28#[derive(Debug, Default)]
29pub struct ParsedVariants {
30    /// ALT-bearing variant records, partitioned by contig name, each sorted
31    /// by position. Homozygous-reference / all-missing records are dropped
32    /// from this map (downstream haplotype construction only needs ALT
33    /// calls) but still counted toward [`Self::sample_ploidy`]. Contigs with
34    /// no ALT-bearing records do not appear; treat a missing key as "no
35    /// variants on that contig".
36    pub by_contig: HashMap<String, Vec<VariantRecord>>,
37    /// Max GT ploidy across every record with a parseable genotype for the
38    /// selected sample — **including** hom-ref / all-missing records that are
39    /// excluded from `by_contig`. Falls back to `2` only when the VCF has no
40    /// genotyped records at all. This is the authoritative MT/MB entry count:
41    /// deriving ploidy from `by_contig` alone would undercount on a sample
42    /// whose only calls on some contig are hom-ref (e.g. a haploid sample
43    /// with a hom-ref-only contig would otherwise fall back to diploid).
44    pub sample_ploidy: usize,
45}
46
47/// Read every variant from a VCF in a single pass and partition by contig.
48///
49/// This is the one-pass replacement for the per-contig loader pattern that
50/// re-scanned the entire VCF for every contig. Mirrors
51/// [`crate::vcf::methylation::parse_methylation_vcf`]: read the file once
52/// before the contig loop, then look up per-contig records by name. Trades
53/// I/O for memory — every selected variant is held in the returned map until
54/// the caller drops it — which is the same trade-off the methylation loader
55/// already makes.
56///
57/// Ploidy is captured during this scan *before* the hom-ref/all-missing
58/// filter, so [`ParsedVariants::sample_ploidy`] reflects the true sample
59/// ploidy even on contigs (or whole samples) with no ALT calls.
60///
61/// # Arguments
62/// * `path` — Path to the VCF file (plain or BGZF; noodles autodetects).
63/// * `sample_name` — Sample to resolve genotypes for, or `None` to use the
64///   only sample in a single-sample VCF.
65/// * `_dict` — Sequence dictionary (reserved for future cross-validation
66///   against the reference).
67///
68/// # Errors
69/// Returns an error if the VCF cannot be read, the sample is not found, or a
70/// GT field is malformed.
71pub fn parse_variants_by_contig(
72    path: &Path,
73    sample_name: Option<&str>,
74    _dict: &SequenceDictionary,
75) -> Result<ParsedVariants> {
76    let mut reader = vcf::io::reader::Builder::default()
77        .build_from_path(path)
78        .with_context(|| format!("Failed to open VCF: {}", path.display()))?;
79
80    let header = reader.read_header()?;
81    let sample_index = resolve_sample_index(&header, sample_name)?;
82
83    let mut by_contig: HashMap<String, Vec<VariantRecord>> = HashMap::new();
84    // Track ploidy across ALL genotyped records, including hom-ref ones that
85    // are filtered out of `by_contig` below.
86    let mut max_ploidy: Option<usize> = None;
87
88    for result in reader.record_bufs(&header) {
89        let record = result.with_context(|| "Failed to read VCF record")?;
90
91        // Parse position (VCF is 1-based, convert to 0-based).
92        let Some(pos_1based) = record.variant_start() else {
93            continue;
94        };
95        #[expect(clippy::cast_possible_truncation, reason = "genomic positions fit in u32")]
96        let position = (usize::from(pos_1based) - 1) as u32;
97
98        // Extract alleles.
99        let ref_allele: Vec<u8> = record.reference_bases().as_bytes().to_vec();
100        let alt_alleles: Vec<Vec<u8>> = record
101            .alternate_bases()
102            .iter()
103            .filter_map(Result::ok)
104            .map(|a| a.as_bytes().to_vec())
105            .collect();
106
107        // Parse genotype for the selected sample.
108        let gt_str = extract_gt_string(&record, sample_index);
109        let Some(gt_str) = gt_str else {
110            continue;
111        };
112        let genotype = Genotype::parse(&gt_str)?;
113
114        // Record ploidy from the unfiltered GT, BEFORE dropping hom-ref /
115        // all-missing records — those still carry the sample's ploidy.
116        let ploidy = genotype.ploidy();
117        max_ploidy = Some(max_ploidy.map_or(ploidy, |m| m.max(ploidy)));
118
119        if !genotype.has_alt() {
120            continue;
121        }
122
123        let chrom = record.reference_sequence_name().to_string();
124        by_contig.entry(chrom).or_default().push(VariantRecord {
125            position,
126            ref_allele,
127            alt_alleles,
128            genotype,
129        });
130    }
131
132    // Sort each contig's variants by position. Valid VCFs are already sorted,
133    // but a stray unsorted file would break downstream invariants
134    // (`build_haplotypes`, overlap checks, etc.) silently if we trusted it.
135    for v in by_contig.values_mut() {
136        v.sort_by_key(|vr| vr.position);
137    }
138
139    Ok(ParsedVariants { by_contig, sample_ploidy: max_ploidy.unwrap_or(2) })
140}
141
142/// Validate that a VCF file has the expected sample configuration and
143/// return the resolved sample name.
144///
145/// Opens the VCF, reads the header, and checks:
146/// - If `sample_name` is `Some`, that the named sample exists.
147/// - If `sample_name` is `None`, that the VCF has exactly one sample.
148///
149/// Call this during argument validation to surface sample errors before
150/// the per-contig simulation loop begins.
151///
152/// # Errors
153/// Returns an error if the VCF cannot be read or the sample configuration
154/// is invalid.
155pub(crate) fn validate_vcf_sample(path: &Path, sample_name: Option<&str>) -> Result<String> {
156    let mut reader = vcf::io::reader::Builder::default()
157        .build_from_path(path)
158        .with_context(|| format!("Failed to open VCF: {}", path.display()))?;
159    let header = reader.read_header()?;
160    let idx = resolve_sample_index(&header, sample_name)?;
161    Ok(header
162        .sample_names()
163        .get_index(idx)
164        .expect("resolve_sample_index returned a valid index")
165        .clone())
166}
167
168/// Resolve the sample index from a VCF header.
169///
170/// If `sample_name` is `Some`, looks up that sample. If `None` and the VCF has
171/// exactly one sample, uses index 0. Errors if the VCF has multiple samples
172/// and no name was specified.
173fn resolve_sample_index(header: &vcf::Header, sample_name: Option<&str>) -> Result<usize> {
174    let sample_names = header.sample_names();
175
176    match sample_name {
177        Some(name) => {
178            let idx = sample_names.iter().position(|s| s == name).ok_or_else(|| {
179                if sample_names.is_empty() {
180                    anyhow::anyhow!("Sample '{name}' not found in VCF (VCF has no sample columns)")
181                } else {
182                    let available: Vec<&str> =
183                        sample_names.iter().map(String::as_str).take(10).collect();
184                    anyhow::anyhow!(
185                        "Sample '{name}' not found in VCF. Available samples: {}",
186                        available.join(", ")
187                    )
188                }
189            })?;
190            Ok(idx)
191        }
192        None => {
193            if sample_names.len() == 1 {
194                Ok(0)
195            } else if sample_names.is_empty() {
196                bail!("VCF has no sample columns")
197            } else {
198                bail!(
199                    "VCF has {} samples but no --sample was specified. \
200                     Available samples: {}",
201                    sample_names.len(),
202                    sample_names.iter().take(10).cloned().collect::<Vec<_>>().join(", ")
203                )
204            }
205        }
206    }
207}
208
209/// Extract the GT field as a string for a specific sample from a VCF record.
210///
211/// Returns `None` if the GT field is absent for this sample.
212fn extract_gt_string(record: &vcf::variant::RecordBuf, sample_index: usize) -> Option<String> {
213    use noodles::vcf::variant::record::samples::keys::key;
214    use noodles::vcf::variant::record_buf::samples::sample::Value;
215
216    let samples = record.samples();
217    let sample = samples.get_index(sample_index)?;
218    let gt_value = sample.get(key::GENOTYPE)?;
219
220    match gt_value {
221        Some(Value::Genotype(gt)) => Some(format_genotype(gt)),
222        _ => None,
223    }
224}
225
226/// Format a noodles `Genotype` value back to a VCF GT string (e.g. "0/1" or
227/// "1|0").
228fn format_genotype(
229    gt: &noodles::vcf::variant::record_buf::samples::sample::value::Genotype,
230) -> String {
231    use noodles::vcf::variant::record::samples::series::value::genotype::Phasing;
232
233    let alleles = gt.as_ref();
234    let mut parts = Vec::with_capacity(alleles.len());
235    let mut is_phased = false;
236
237    for (i, allele) in alleles.iter().enumerate() {
238        if i > 0 && allele.phasing() == Phasing::Phased {
239            is_phased = true;
240        }
241        match allele.position() {
242            Some(idx) => parts.push(idx.to_string()),
243            None => parts.push(".".to_string()),
244        }
245    }
246
247    let sep = if is_phased { "|" } else { "/" };
248    parts.join(sep)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use std::io::Write as _;
255
256    /// Write a temporary plain-text VCF (no BGZF) and return the
257    /// `NamedTempFile` so the caller controls its lifetime.
258    fn write_temp_vcf(body: &str) -> tempfile::NamedTempFile {
259        let mut f = tempfile::Builder::new().suffix(".vcf").tempfile().unwrap();
260        f.write_all(body.as_bytes()).unwrap();
261        f.flush().unwrap();
262        f
263    }
264
265    /// Build a minimal `SequenceDictionary` covering the test contigs.
266    fn dict_for(contigs: &[(&str, usize)]) -> SequenceDictionary {
267        let entries: Vec<_> = contigs
268            .iter()
269            .enumerate()
270            .map(|(i, &(name, len))| {
271                crate::sequence_dict::SequenceMetadata::new(i, name.to_string(), len)
272            })
273            .collect();
274        SequenceDictionary::from_entries(entries)
275    }
276
277    /// Variants on two contigs must be partitioned by contig name, sorted by
278    /// position, and stripped of homozygous-reference / no-alt records. A
279    /// caller that loops over contigs should be able to look up each
280    /// contig's slice in `O(1)` after a single full-file pass.
281    #[test]
282    fn parse_variants_by_contig_partitions_and_sorts() {
283        let vcf = "\
284##fileformat=VCFv4.4
285##contig=<ID=chr1,length=100>
286##contig=<ID=chr2,length=100>
287##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
288#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
289chr1\t30\t.\tA\tT\t.\t.\t.\tGT\t0|1
290chr2\t10\t.\tC\tG\t.\t.\t.\tGT\t1|0
291chr1\t10\t.\tG\tC\t.\t.\t.\tGT\t0|1
292chr1\t50\t.\tT\tA\t.\t.\t.\tGT\t0|0
293chr2\t20\t.\tA\tG\t.\t.\t.\tGT\t0|1
294";
295        let f = write_temp_vcf(vcf);
296        let dict = dict_for(&[("chr1", 100), ("chr2", 100)]);
297        let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
298        let by_contig = &parsed.by_contig;
299
300        // chr1: two records (POS 30 + POS 10), sorted by position. The
301        // hom-ref `0|0` at POS 50 must be dropped.
302        let chr1 = by_contig.get("chr1").expect("chr1 must be present");
303        assert_eq!(chr1.len(), 2, "expected 2 chr1 variants, got {chr1:?}");
304        assert_eq!(chr1[0].position, 9, "first chr1 variant should be POS 10 → 0-based 9");
305        assert_eq!(chr1[1].position, 29, "second chr1 variant should be POS 30 → 0-based 29");
306
307        // chr2: two records, sorted by position.
308        let chr2 = by_contig.get("chr2").expect("chr2 must be present");
309        assert_eq!(chr2.len(), 2, "expected 2 chr2 variants, got {chr2:?}");
310        assert_eq!(chr2[0].position, 9);
311        assert_eq!(chr2[1].position, 19);
312
313        // Diploid sample → ploidy 2 (resolved from the GTs).
314        assert_eq!(parsed.sample_ploidy, 2);
315    }
316
317    /// A VCF with no alt-bearing records on a given contig must produce no
318    /// entry for that contig — callers treat a missing key as "no variants
319    /// here", and an empty-vector value would be equally fine but wastes a
320    /// `HashMap` allocation per absent contig.
321    #[test]
322    fn parse_variants_by_contig_skips_contigs_with_no_alt_records() {
323        let vcf = "\
324##fileformat=VCFv4.4
325##contig=<ID=chr1,length=100>
326##contig=<ID=chr2,length=100>
327##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
328#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
329chr1\t10\t.\tA\tT\t.\t.\t.\tGT\t0|0
330chr2\t10\t.\tC\tG\t.\t.\t.\tGT\t1|0
331";
332        let f = write_temp_vcf(vcf);
333        let dict = dict_for(&[("chr1", 100), ("chr2", 100)]);
334        let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
335        assert!(!parsed.by_contig.contains_key("chr1"), "chr1 should be absent (only hom-ref)");
336        assert_eq!(parsed.by_contig.get("chr2").map(Vec::len), Some(1));
337    }
338
339    /// Sample ploidy must be resolved from *unfiltered* genotypes. A haploid
340    /// sample whose only calls are hom-ref (dropped from `by_contig`) must
341    /// still report ploidy 1 — not the diploid fallback. Before this fix,
342    /// deriving ploidy from the filtered map gave `2` and mis-shaped every
343    /// downstream MT/MB string.
344    #[test]
345    fn parse_variants_by_contig_resolves_haploid_ploidy_from_homref_only_records() {
346        let vcf = "\
347##fileformat=VCFv4.4
348##contig=<ID=chr1,length=100>
349##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
350#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
351chr1\t10\t.\tA\tT\t.\t.\t.\tGT\t0
352chr1\t20\t.\tC\tG\t.\t.\t.\tGT\t0
353";
354        let f = write_temp_vcf(vcf);
355        let dict = dict_for(&[("chr1", 100)]);
356        let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
357        // All records are hom-ref haploid → no ALT-bearing variants…
358        assert!(parsed.by_contig.is_empty(), "no ALT calls → empty map");
359        // …but the sample is unambiguously haploid.
360        assert_eq!(parsed.sample_ploidy, 1, "ploidy must come from unfiltered GTs");
361    }
362
363    /// A genotyped triploid ALT call resolves ploidy 3.
364    #[test]
365    fn parse_variants_by_contig_resolves_triploid_ploidy() {
366        let vcf = "\
367##fileformat=VCFv4.4
368##contig=<ID=chr1,length=100>
369##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
370#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
371chr1\t10\t.\tA\tT\t.\t.\t.\tGT\t0|0|1
372";
373        let f = write_temp_vcf(vcf);
374        let dict = dict_for(&[("chr1", 100)]);
375        let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
376        assert_eq!(parsed.sample_ploidy, 3);
377        assert_eq!(parsed.by_contig.get("chr1").map(Vec::len), Some(1));
378    }
379}