1pub mod genotype;
12pub 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#[derive(Debug, Default)]
29pub struct ParsedVariants {
30 pub by_contig: HashMap<String, Vec<VariantRecord>>,
37 pub sample_ploidy: usize,
45}
46
47pub 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 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 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 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 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(>_str)?;
113
114 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 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
142pub(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
168fn 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
209fn 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
226fn 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 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 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 #[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 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 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 assert_eq!(parsed.sample_ploidy, 2);
315 }
316
317 #[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 #[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 assert!(parsed.by_contig.is_empty(), "no ALT calls → empty map");
359 assert_eq!(parsed.sample_ploidy, 1, "ploidy must come from unfiltered GTs");
361 }
362
363 #[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}