pub mod genotype;
pub mod methylation;
use std::collections::HashMap;
use std::path::Path;
use anyhow::{Context, Result, bail};
use noodles::vcf;
use noodles::vcf::variant::record::AlternateBases;
use crate::sequence_dict::SequenceDictionary;
use genotype::{Genotype, VariantRecord};
#[derive(Debug, Default)]
pub struct ParsedVariants {
pub by_contig: HashMap<String, Vec<VariantRecord>>,
pub sample_ploidy: usize,
}
pub fn parse_variants_by_contig(
path: &Path,
sample_name: Option<&str>,
_dict: &SequenceDictionary,
) -> Result<ParsedVariants> {
let mut reader = vcf::io::reader::Builder::default()
.build_from_path(path)
.with_context(|| format!("Failed to open VCF: {}", path.display()))?;
let header = reader.read_header()?;
let sample_index = resolve_sample_index(&header, sample_name)?;
let mut by_contig: HashMap<String, Vec<VariantRecord>> = HashMap::new();
let mut max_ploidy: Option<usize> = None;
for result in reader.record_bufs(&header) {
let record = result.with_context(|| "Failed to read VCF record")?;
let Some(pos_1based) = record.variant_start() else {
continue;
};
#[expect(clippy::cast_possible_truncation, reason = "genomic positions fit in u32")]
let position = (usize::from(pos_1based) - 1) as u32;
let ref_allele: Vec<u8> = record.reference_bases().as_bytes().to_vec();
let alt_alleles: Vec<Vec<u8>> = record
.alternate_bases()
.iter()
.filter_map(Result::ok)
.map(|a| a.as_bytes().to_vec())
.collect();
let gt_str = extract_gt_string(&record, sample_index);
let Some(gt_str) = gt_str else {
continue;
};
let genotype = Genotype::parse(>_str)?;
let ploidy = genotype.ploidy();
max_ploidy = Some(max_ploidy.map_or(ploidy, |m| m.max(ploidy)));
if !genotype.has_alt() {
continue;
}
let chrom = record.reference_sequence_name().to_string();
by_contig.entry(chrom).or_default().push(VariantRecord {
position,
ref_allele,
alt_alleles,
genotype,
});
}
for v in by_contig.values_mut() {
v.sort_by_key(|vr| vr.position);
}
Ok(ParsedVariants { by_contig, sample_ploidy: max_ploidy.unwrap_or(2) })
}
pub(crate) fn validate_vcf_sample(path: &Path, sample_name: Option<&str>) -> Result<String> {
let mut reader = vcf::io::reader::Builder::default()
.build_from_path(path)
.with_context(|| format!("Failed to open VCF: {}", path.display()))?;
let header = reader.read_header()?;
let idx = resolve_sample_index(&header, sample_name)?;
Ok(header
.sample_names()
.get_index(idx)
.expect("resolve_sample_index returned a valid index")
.clone())
}
fn resolve_sample_index(header: &vcf::Header, sample_name: Option<&str>) -> Result<usize> {
let sample_names = header.sample_names();
match sample_name {
Some(name) => {
let idx = sample_names.iter().position(|s| s == name).ok_or_else(|| {
if sample_names.is_empty() {
anyhow::anyhow!("Sample '{name}' not found in VCF (VCF has no sample columns)")
} else {
let available: Vec<&str> =
sample_names.iter().map(String::as_str).take(10).collect();
anyhow::anyhow!(
"Sample '{name}' not found in VCF. Available samples: {}",
available.join(", ")
)
}
})?;
Ok(idx)
}
None => {
if sample_names.len() == 1 {
Ok(0)
} else if sample_names.is_empty() {
bail!("VCF has no sample columns")
} else {
bail!(
"VCF has {} samples but no --sample was specified. \
Available samples: {}",
sample_names.len(),
sample_names.iter().take(10).cloned().collect::<Vec<_>>().join(", ")
)
}
}
}
}
fn extract_gt_string(record: &vcf::variant::RecordBuf, sample_index: usize) -> Option<String> {
use noodles::vcf::variant::record::samples::keys::key;
use noodles::vcf::variant::record_buf::samples::sample::Value;
let samples = record.samples();
let sample = samples.get_index(sample_index)?;
let gt_value = sample.get(key::GENOTYPE)?;
match gt_value {
Some(Value::Genotype(gt)) => Some(format_genotype(gt)),
_ => None,
}
}
fn format_genotype(
gt: &noodles::vcf::variant::record_buf::samples::sample::value::Genotype,
) -> String {
use noodles::vcf::variant::record::samples::series::value::genotype::Phasing;
let alleles = gt.as_ref();
let mut parts = Vec::with_capacity(alleles.len());
let mut is_phased = false;
for (i, allele) in alleles.iter().enumerate() {
if i > 0 && allele.phasing() == Phasing::Phased {
is_phased = true;
}
match allele.position() {
Some(idx) => parts.push(idx.to_string()),
None => parts.push(".".to_string()),
}
}
let sep = if is_phased { "|" } else { "/" };
parts.join(sep)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_temp_vcf(body: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new().suffix(".vcf").tempfile().unwrap();
f.write_all(body.as_bytes()).unwrap();
f.flush().unwrap();
f
}
fn dict_for(contigs: &[(&str, usize)]) -> SequenceDictionary {
let entries: Vec<_> = contigs
.iter()
.enumerate()
.map(|(i, &(name, len))| {
crate::sequence_dict::SequenceMetadata::new(i, name.to_string(), len)
})
.collect();
SequenceDictionary::from_entries(entries)
}
#[test]
fn parse_variants_by_contig_partitions_and_sorts() {
let vcf = "\
##fileformat=VCFv4.4
##contig=<ID=chr1,length=100>
##contig=<ID=chr2,length=100>
##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
chr1\t30\t.\tA\tT\t.\t.\t.\tGT\t0|1
chr2\t10\t.\tC\tG\t.\t.\t.\tGT\t1|0
chr1\t10\t.\tG\tC\t.\t.\t.\tGT\t0|1
chr1\t50\t.\tT\tA\t.\t.\t.\tGT\t0|0
chr2\t20\t.\tA\tG\t.\t.\t.\tGT\t0|1
";
let f = write_temp_vcf(vcf);
let dict = dict_for(&[("chr1", 100), ("chr2", 100)]);
let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
let by_contig = &parsed.by_contig;
let chr1 = by_contig.get("chr1").expect("chr1 must be present");
assert_eq!(chr1.len(), 2, "expected 2 chr1 variants, got {chr1:?}");
assert_eq!(chr1[0].position, 9, "first chr1 variant should be POS 10 → 0-based 9");
assert_eq!(chr1[1].position, 29, "second chr1 variant should be POS 30 → 0-based 29");
let chr2 = by_contig.get("chr2").expect("chr2 must be present");
assert_eq!(chr2.len(), 2, "expected 2 chr2 variants, got {chr2:?}");
assert_eq!(chr2[0].position, 9);
assert_eq!(chr2[1].position, 19);
assert_eq!(parsed.sample_ploidy, 2);
}
#[test]
fn parse_variants_by_contig_skips_contigs_with_no_alt_records() {
let vcf = "\
##fileformat=VCFv4.4
##contig=<ID=chr1,length=100>
##contig=<ID=chr2,length=100>
##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
chr1\t10\t.\tA\tT\t.\t.\t.\tGT\t0|0
chr2\t10\t.\tC\tG\t.\t.\t.\tGT\t1|0
";
let f = write_temp_vcf(vcf);
let dict = dict_for(&[("chr1", 100), ("chr2", 100)]);
let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
assert!(!parsed.by_contig.contains_key("chr1"), "chr1 should be absent (only hom-ref)");
assert_eq!(parsed.by_contig.get("chr2").map(Vec::len), Some(1));
}
#[test]
fn parse_variants_by_contig_resolves_haploid_ploidy_from_homref_only_records() {
let vcf = "\
##fileformat=VCFv4.4
##contig=<ID=chr1,length=100>
##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
chr1\t10\t.\tA\tT\t.\t.\t.\tGT\t0
chr1\t20\t.\tC\tG\t.\t.\t.\tGT\t0
";
let f = write_temp_vcf(vcf);
let dict = dict_for(&[("chr1", 100)]);
let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
assert!(parsed.by_contig.is_empty(), "no ALT calls → empty map");
assert_eq!(parsed.sample_ploidy, 1, "ploidy must come from unfiltered GTs");
}
#[test]
fn parse_variants_by_contig_resolves_triploid_ploidy() {
let vcf = "\
##fileformat=VCFv4.4
##contig=<ID=chr1,length=100>
##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE
chr1\t10\t.\tA\tT\t.\t.\t.\tGT\t0|0|1
";
let f = write_temp_vcf(vcf);
let dict = dict_for(&[("chr1", 100)]);
let parsed = parse_variants_by_contig(f.path(), None, &dict).unwrap();
assert_eq!(parsed.sample_ploidy, 3);
assert_eq!(parsed.by_contig.get("chr1").map(Vec::len), Some(1));
}
}