use rand::SeedableRng;
use crate::haplotype::build_haplotypes;
use crate::vcf::genotype::VariantRecord;
#[derive(Debug, thiserror::Error)]
pub enum ClassifyError {
#[error("methylation requires phased genotypes; variant at position {pos} has unphased GT")]
UnphasedGenotype {
pos: u32,
},
#[error(
"variants at positions {a_pos} and {b_pos} have overlapping REF spans on a shared haplotype"
)]
OverlappingVariants {
a_pos: u32,
b_pos: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CpgPlacement {
Standalone { ref_pos: u32 },
OnVariant { variant_index: usize, hap_offset: u32 },
}
pub fn classify_cpgs(
reference: &[u8],
variants: &[VariantRecord],
) -> Result<Vec<CpgPlacement>, ClassifyError> {
let sample_ploidy = variants.iter().map(|v| v.genotype.ploidy()).max().unwrap_or(2);
let haplotypes = build_methylation_haplotypes(variants, sample_ploidy);
classify_cpgs_with_haplotypes(reference, variants, &haplotypes)
}
pub(crate) fn build_methylation_haplotypes(
variants: &[VariantRecord],
sample_ploidy: usize,
) -> Vec<crate::haplotype::Haplotype> {
let mut rng = rand::rngs::SmallRng::seed_from_u64(0);
build_haplotypes(variants, sample_ploidy, &mut rng)
}
fn classify_cpgs_with_haplotypes(
reference: &[u8],
variants: &[VariantRecord],
haplotypes: &[crate::haplotype::Haplotype],
) -> Result<Vec<CpgPlacement>, ClassifyError> {
for v in variants {
if !v.genotype.is_phased() {
return Err(ClassifyError::UnphasedGenotype { pos: v.position });
}
}
check_overlaps_on_shared_haplotypes(variants)?;
if variants.is_empty() {
return Ok(crate::meth::find_reference_cpgs(reference)
.into_iter()
.map(|ref_pos| CpgPlacement::Standalone { ref_pos })
.collect());
}
let mut placements: Vec<CpgPlacement> = Vec::new();
for haplotype in haplotypes {
#[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
let cap = haplotype.hap_position_for(reference.len() as u32) as usize;
let (hap_bases, ref_positions, _hap_start) = haplotype.extract_fragment(reference, 0, cap);
let len = hap_bases.len();
if len < 2 {
continue;
}
let hap_allele_index = haplotype.allele_index();
let mut var_hap_ranges: Vec<(usize, u32, u32)> = Vec::with_capacity(variants.len());
for (vi, v) in variants.iter().enumerate() {
let allele_num = v.genotype.alleles().get(hap_allele_index).copied().flatten();
let Some(allele_num) = allele_num else { continue };
if allele_num == 0 {
continue;
}
let Some(alt_bases) = v.allele_bases(allele_num) else { continue };
let hap_start = haplotype.hap_position_for(v.position);
#[expect(clippy::cast_possible_truncation, reason = "alt allele len fits u32")]
let hap_end = hap_start + alt_bases.len() as u32;
var_hap_ranges.push((vi, hap_start, hap_end));
}
for h in 0..len - 1 {
let c0 = hap_bases[h].to_ascii_uppercase();
let c1 = hap_bases[h + 1].to_ascii_uppercase();
if c0 != b'C' || c1 != b'G' {
continue;
}
#[expect(clippy::cast_possible_truncation, reason = "haplotype length fits in u32")]
let hpos = h as u32;
let src_h = base_source(hpos, &var_hap_ranges);
let src_h1 = base_source(hpos + 1, &var_hap_ranges);
let placement = match (src_h, src_h1) {
(BaseSource::Reference, BaseSource::Reference) => {
CpgPlacement::Standalone { ref_pos: ref_positions[h] }
}
(BaseSource::Variant { variant_index, hap_start }, BaseSource::Reference) => {
let hap_offset = hpos - hap_start;
CpgPlacement::OnVariant { variant_index, hap_offset }
}
(BaseSource::Reference, BaseSource::Variant { variant_index, hap_start }) => {
let hap_offset = (hpos + 1) - hap_start;
CpgPlacement::OnVariant { variant_index, hap_offset }
}
(
BaseSource::Variant { variant_index: vi0, hap_start: hs0 },
BaseSource::Variant { variant_index: vi1, hap_start: _ },
) if vi0 == vi1 => {
let hap_offset = hpos - hs0;
CpgPlacement::OnVariant { variant_index: vi0, hap_offset }
}
(
BaseSource::Variant { variant_index: vi0, hap_start: hs0 },
BaseSource::Variant { variant_index: vi1, hap_start: hs1 },
) => {
if vi0 < vi1 {
let hap_offset = hpos - hs0;
CpgPlacement::OnVariant { variant_index: vi0, hap_offset }
} else {
let hap_offset = (hpos + 1) - hs1;
CpgPlacement::OnVariant { variant_index: vi1, hap_offset }
}
}
};
placements.push(placement);
}
}
placements.sort_unstable_by_key(|p| sort_key(p, variants));
placements.dedup();
Ok(placements)
}
fn check_overlaps_on_shared_haplotypes(variants: &[VariantRecord]) -> Result<(), ClassifyError> {
let max_ploidy = variants.iter().map(|v| v.genotype.ploidy()).max().unwrap_or(0);
let mut last_alt_span: Vec<Option<(u32, u32)>> = vec![None; max_ploidy];
for v in variants {
#[expect(clippy::cast_possible_truncation, reason = "ref allele len fits u32")]
let v_end = v.position + v.ref_allele.len() as u32;
for (hi, slot) in last_alt_span.iter_mut().enumerate() {
let carries_alt =
v.genotype.alleles().get(hi).copied().flatten().is_some_and(|x| x != 0);
if !carries_alt {
continue;
}
if let Some((prev_end, prev_pos)) = *slot
&& prev_end > v.position
{
return Err(ClassifyError::OverlappingVariants {
a_pos: prev_pos,
b_pos: v.position,
});
}
*slot = Some((v_end, v.position));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
enum BaseSource {
Reference,
Variant { variant_index: usize, hap_start: u32 },
}
fn base_source(h: u32, var_hap_ranges: &[(usize, u32, u32)]) -> BaseSource {
for &(vi, hap_start, hap_end) in var_hap_ranges {
if h >= hap_start && h < hap_end {
return BaseSource::Variant { variant_index: vi, hap_start };
}
}
BaseSource::Reference
}
fn sort_key(placement: &CpgPlacement, variants: &[VariantRecord]) -> (u32, u8, u32) {
match placement {
CpgPlacement::Standalone { ref_pos } => (*ref_pos, 0, 0),
CpgPlacement::OnVariant { variant_index, hap_offset } => {
(variants[*variant_index].position, 1, *hap_offset)
}
}
}
pub const DEFAULT_METHYLATE_SAMPLE: &str = "METHYLATE";
pub fn write_vcf_header<W: std::io::Write>(
writer: &mut W,
dict: &crate::sequence_dict::SequenceDictionary,
sample: Option<&str>,
version: &str,
command_line: &str,
) -> anyhow::Result<()> {
let sample_name = sample.unwrap_or(DEFAULT_METHYLATE_SAMPLE);
writeln!(writer, "##fileformat=VCFv4.4")?;
writeln!(writer, "##holodeckVersion={version}")?;
writeln!(writer, "##holodeckCommand={command_line}")?;
writeln!(writer, "##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">")?;
writeln!(
writer,
"##FORMAT=<ID=MT,Number=.,Type=String,\
Description=\"Methylation state, top strand. \
Per-haplotype pipe-separated bitstring: 1=methylated, \
0=unmethylated, .=haplotype carries REF or no owned CpG.\">"
)?;
writeln!(
writer,
"##FORMAT=<ID=MB,Number=.,Type=String,\
Description=\"Methylation state, bottom strand. \
Per-haplotype pipe-separated bitstring: 1=methylated, \
0=unmethylated, .=haplotype carries REF or no owned CpG.\">"
)?;
for seq in dict.iter() {
writeln!(writer, "##contig=<ID={},length={}>", seq.name(), seq.length())?;
}
writeln!(writer, "#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\t{sample_name}")?;
Ok(())
}
enum WriteKind {
Standalone(u32),
Variant(usize),
}
pub fn write_contig<W: std::io::Write>(
writer: &mut W,
chrom: &str,
reference: &[u8],
variants: &[VariantRecord],
methylation: &crate::meth::ContigMethylation,
sample_ploidy: usize,
) -> anyhow::Result<()> {
let haplotypes = build_methylation_haplotypes(variants, sample_ploidy);
let placements = classify_cpgs_with_haplotypes(reference, variants, &haplotypes)?;
let var_hap_coords =
per_variant_per_hap_cpg_offsets_with_haplotypes(reference, variants, &haplotypes);
let mut records: Vec<(u32, u8, WriteKind)> = Vec::new();
let mut seen_variant: std::collections::HashSet<usize> = std::collections::HashSet::new();
for placement in &placements {
match placement {
CpgPlacement::Standalone { ref_pos } => {
records.push((*ref_pos + 1, 0, WriteKind::Standalone(*ref_pos)));
}
CpgPlacement::OnVariant { variant_index, .. } => {
if seen_variant.insert(*variant_index) {
let pos_1based = variants[*variant_index].position + 1;
records.push((pos_1based, 1, WriteKind::Variant(*variant_index)));
}
}
}
}
for (vi, v) in variants.iter().enumerate() {
if !seen_variant.contains(&vi) {
let pos_1based = v.position + 1;
records.push((pos_1based, 1, WriteKind::Variant(vi)));
}
}
records.sort_unstable_by_key(|(pos, disc, _)| (*pos, *disc));
for (pos_1based, _, kind) in &records {
match kind {
WriteKind::Standalone(ref_pos) => {
let mt = per_hap_top_bit_string(methylation, &haplotypes, *ref_pos);
let mb = per_hap_bot_bit_string(methylation, &haplotypes, *ref_pos);
writeln!(writer, "{chrom}\t{pos_1based}\t.\tC\t.\t.\t.\t.\tMT:MB\t{mt}:{mb}")?;
}
WriteKind::Variant(vi) => {
let v = &variants[*vi];
let ref_str = std::str::from_utf8(&v.ref_allele).unwrap_or("N");
let alt_str = v
.alt_alleles
.iter()
.map(|a| std::str::from_utf8(a).unwrap_or("N"))
.collect::<Vec<_>>()
.join(",");
let gt_str = format_genotype(&v.genotype);
let hap_coords_for_var = &var_hap_coords[*vi];
let top_meth = format_variant_meth_field(v, hap_coords_for_var, methylation, false);
let bot_meth = format_variant_meth_field(v, hap_coords_for_var, methylation, true);
writeln!(
writer,
"{chrom}\t{pos_1based}\t.\t{ref_str}\t{alt_str}\t.\t.\t.\tGT:MT:MB\t{gt_str}:{top_meth}:{bot_meth}"
)?;
}
}
}
Ok(())
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
struct VariantKey {
pos_1based: u32,
ref_allele: String,
alt_alleles: String,
gt: String,
}
fn variant_key_from_record(v: &VariantRecord) -> VariantKey {
let ref_allele = std::str::from_utf8(&v.ref_allele).unwrap_or("N").to_string();
let alt_alleles = v
.alt_alleles
.iter()
.map(|a| std::str::from_utf8(a).unwrap_or("N"))
.collect::<Vec<_>>()
.join(",");
VariantKey {
pos_1based: v.position + 1,
ref_allele,
alt_alleles,
gt: format_genotype(&v.genotype),
}
}
fn format_genotype(gt: &crate::vcf::genotype::Genotype) -> String {
gt.alleles()
.iter()
.map(|a| match a {
Some(idx) => idx.to_string(),
None => ".".to_string(),
})
.collect::<Vec<_>>()
.join("|")
}
fn format_variant_meth_field(
v: &VariantRecord,
hap_coords_for_var: &[Vec<u32>],
methylation: &crate::meth::ContigMethylation,
bottom_strand: bool,
) -> String {
(0..methylation.len())
.map(|hi| {
let allele = v.genotype.alleles().get(hi).copied().flatten();
match allele {
None | Some(0) => ".".to_string(), Some(_) => {
let hap_coords = if hi < hap_coords_for_var.len() {
hap_coords_for_var[hi].as_slice()
} else {
&[]
};
if hap_coords.is_empty() {
".".to_string()
} else {
let table = methylation.table_for(hi);
hap_coords
.iter()
.map(|&hap_coord| {
let query_coord =
if bottom_strand { hap_coord + 1 } else { hap_coord };
if table.is_methylated(query_coord, bottom_strand) {
'1'
} else {
'0'
}
})
.collect()
}
}
}
})
.collect::<Vec<_>>()
.join("|")
}
fn per_variant_per_hap_cpg_offsets_with_haplotypes(
reference: &[u8],
variants: &[VariantRecord],
haplotypes: &[crate::haplotype::Haplotype],
) -> Vec<Vec<Vec<u32>>> {
if variants.is_empty() {
return Vec::new();
}
let n_vars = variants.len();
let n_haps = haplotypes.len();
let mut hap_coords: Vec<Vec<Vec<u32>>> = vec![vec![Vec::new(); n_haps]; n_vars];
for haplotype in haplotypes {
let hi = haplotype.allele_index();
#[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
let cap = haplotype.hap_position_for(reference.len() as u32) as usize;
let (hap_bases, _ref_positions, _hap_start) = haplotype.extract_fragment(reference, 0, cap);
let len = hap_bases.len();
if len < 2 {
continue;
}
let mut var_hap_ranges: Vec<(usize, u32, u32)> = Vec::with_capacity(variants.len());
for (vi, v) in variants.iter().enumerate() {
let allele_num = v.genotype.alleles().get(hi).copied().flatten();
let Some(allele_num) = allele_num else { continue };
if allele_num == 0 {
continue;
}
let Some(alt_bases) = v.allele_bases(allele_num) else { continue };
let hap_start = haplotype.hap_position_for(v.position);
#[expect(clippy::cast_possible_truncation, reason = "alt allele len fits u32")]
let hap_end = hap_start + alt_bases.len() as u32;
var_hap_ranges.push((vi, hap_start, hap_end));
}
for h in 0..len - 1 {
let c0 = hap_bases[h].to_ascii_uppercase();
let c1 = hap_bases[h + 1].to_ascii_uppercase();
if c0 != b'C' || c1 != b'G' {
continue;
}
#[expect(clippy::cast_possible_truncation, reason = "haplotype length fits in u32")]
let hpos = h as u32;
let src_h = base_source(hpos, &var_hap_ranges);
let src_h1 = base_source(hpos + 1, &var_hap_ranges);
let owning_vi: Option<usize> = match (src_h, src_h1) {
(BaseSource::Reference, BaseSource::Reference) => None,
(BaseSource::Variant { variant_index, .. }, BaseSource::Reference)
| (BaseSource::Reference, BaseSource::Variant { variant_index, .. }) => {
Some(variant_index)
}
(
BaseSource::Variant { variant_index: vi0, .. },
BaseSource::Variant { variant_index: vi1, .. },
) if vi0 == vi1 => Some(vi0),
(
BaseSource::Variant { variant_index: vi0, .. },
BaseSource::Variant { variant_index: vi1, .. },
) => Some(vi0.min(vi1)), };
if let Some(vi) = owning_vi
&& vi < n_vars
&& hi < n_haps
{
hap_coords[vi][hi].push(hpos); }
}
}
for var_hap_coords in &mut hap_coords {
for hc in var_hap_coords.iter_mut() {
hc.sort_unstable();
}
}
hap_coords
}
#[derive(Debug, thiserror::Error)]
pub enum ReadError {
#[error("variant at pos {pos} hap {hap}: MT/MB length {actual} != expected {expected}")]
MtMbLengthMismatch {
pos: u32,
hap: usize,
actual: usize,
expected: usize,
},
#[error("variant at pos {pos} hap {hap}: invalid MT/MB character {ch:?}")]
InvalidMtMbChar {
pos: u32,
hap: usize,
ch: char,
},
#[error("variant at pos {pos}: {field} entry count {actual} != expected ploidy {expected}")]
PloidyEntryCountMismatch {
pos: u32,
field: &'static str,
actual: usize,
expected: usize,
},
#[error("malformed VCF record at line {line}: {message}")]
MalformedRecord {
line: usize,
message: String,
},
}
fn apply_standalone_bit(
tables: &mut [crate::meth::MethylationTable],
hi: usize,
hap_top_c_pos: usize,
bit_str: &str,
bottom_strand: bool,
pos_1based: u32,
) -> Result<(), ReadError> {
match bit_str {
"1" => {
if bottom_strand {
tables[hi].set_bottom(hap_top_c_pos + 1, true);
} else {
tables[hi].set_top(hap_top_c_pos, true);
}
}
"0" | "." => {} other => {
return Err(ReadError::InvalidMtMbChar {
pos: pos_1based,
hap: hi,
ch: other.chars().next().unwrap_or('?'),
});
}
}
Ok(())
}
fn apply_variant_bits(
tables: &mut [crate::meth::MethylationTable],
hi: usize,
bits: &str,
expected_coords: &[u32],
bottom_strand: bool,
pos_1based: u32,
) -> Result<(), ReadError> {
if bits == "." {
return if expected_coords.is_empty() {
Ok(())
} else {
Err(ReadError::MtMbLengthMismatch {
pos: pos_1based,
hap: hi,
actual: 0,
expected: expected_coords.len(),
})
};
}
for ch in bits.chars() {
if ch != '0' && ch != '1' {
return Err(ReadError::InvalidMtMbChar { pos: pos_1based, hap: hi, ch });
}
}
if bits.len() != expected_coords.len() {
return Err(ReadError::MtMbLengthMismatch {
pos: pos_1based,
hap: hi,
actual: bits.len(),
expected: expected_coords.len(),
});
}
for (bit_idx, ch) in bits.chars().enumerate() {
if ch == '1' {
let base_coord = expected_coords[bit_idx] as usize;
if bottom_strand {
tables[hi].set_bottom(base_coord + 1, true);
} else {
tables[hi].set_top(base_coord, true);
}
}
}
Ok(())
}
pub fn read_contig_methylation(
vcf_bytes: &[u8],
chrom: &str,
reference: &[u8],
variants: &[VariantRecord],
sample_ploidy: usize,
) -> Result<crate::meth::ContigMethylation, ReadError> {
use crate::meth::{ContigMethylation, MethylationTable};
let haplotypes = build_methylation_haplotypes(variants, sample_ploidy);
let mut tables: Vec<MethylationTable> = if haplotypes.is_empty() {
(0..sample_ploidy).map(|_| MethylationTable::with_len(reference.len())).collect()
} else {
#[expect(clippy::cast_possible_truncation, reason = "reference length fits in u32")]
let ref_len_u32 = reference.len() as u32;
haplotypes
.iter()
.map(|hap| MethylationTable::with_len(hap.hap_position_for(ref_len_u32) as usize))
.collect()
};
let var_hap_coords =
per_variant_per_hap_cpg_offsets_with_haplotypes(reference, variants, &haplotypes);
let pos_to_vi: std::collections::HashMap<VariantKey, usize> =
variants.iter().enumerate().map(|(i, v)| (variant_key_from_record(v), i)).collect();
let text = std::str::from_utf8(vcf_bytes).map_err(|e| ReadError::MalformedRecord {
line: 0,
message: format!("VCF body is not valid UTF-8: {e}"),
})?;
for (line_idx, line) in text.lines().enumerate() {
let line_no = line_idx + 1;
if line.is_empty() || line.starts_with('#') {
continue;
}
let cols: Vec<&str> = line.splitn(10, '\t').collect();
if cols.len() < 10 {
return Err(ReadError::MalformedRecord {
line: line_no,
message: format!("expected 10 tab-separated columns, found {}", cols.len()),
});
}
if cols[0] != chrom {
continue; }
let pos_1based: u32 = cols[1].parse().map_err(|_| ReadError::MalformedRecord {
line: line_no,
message: format!("invalid POS field {:?}", cols[1]),
})?;
if pos_1based == 0 {
return Err(ReadError::MalformedRecord {
line: line_no,
message: "POS must be 1-based; got 0".to_string(),
});
}
let ref_str = cols[3];
let alt = cols[4];
let format_col = cols[8];
let sample = cols[9];
if alt == "." {
apply_standalone_record(
&mut tables,
&haplotypes,
pos_1based,
format_col,
sample,
line_no,
)?;
} else {
apply_variant_record(
&mut tables,
pos_1based,
ref_str,
alt,
format_col,
sample,
&pos_to_vi,
&var_hap_coords,
line_no,
)?;
}
}
Ok(ContigMethylation::from_tables(tables))
}
fn apply_standalone_record(
tables: &mut [crate::meth::MethylationTable],
haplotypes: &[crate::haplotype::Haplotype],
pos_1based: u32,
format_col: &str,
sample: &str,
line_no: usize,
) -> Result<(), ReadError> {
if format_col != "MT:MB" {
return Err(ReadError::MalformedRecord {
line: line_no,
message: format!("standalone record expected FORMAT=MT:MB, got {format_col:?}"),
});
}
let mut parts = sample.splitn(2, ':');
let top_field = parts.next().unwrap_or("");
let bot_field = parts.next().unwrap_or("");
let top_parts: Vec<&str> = top_field.split('|').collect();
let bot_parts: Vec<&str> = bot_field.split('|').collect();
if top_parts.len() != tables.len() {
return Err(ReadError::PloidyEntryCountMismatch {
pos: pos_1based,
field: "MT",
actual: top_parts.len(),
expected: tables.len(),
});
}
if bot_parts.len() != tables.len() {
return Err(ReadError::PloidyEntryCountMismatch {
pos: pos_1based,
field: "MB",
actual: bot_parts.len(),
expected: tables.len(),
});
}
let ref_pos = pos_1based - 1;
for (hi, &bit_str) in top_parts.iter().enumerate() {
let hap_top_c_pos =
haplotypes.get(hi).map_or(ref_pos, |h| h.hap_position_for(ref_pos)) as usize;
apply_standalone_bit(tables, hi, hap_top_c_pos, bit_str, false, pos_1based)?;
}
for (hi, &bit_str) in bot_parts.iter().enumerate() {
let hap_top_c_pos =
haplotypes.get(hi).map_or(ref_pos, |h| h.hap_position_for(ref_pos)) as usize;
apply_standalone_bit(tables, hi, hap_top_c_pos, bit_str, true, pos_1based)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)] fn apply_variant_record(
tables: &mut [crate::meth::MethylationTable],
pos_1based: u32,
ref_str: &str,
alt_str: &str,
format_col: &str,
sample: &str,
pos_to_vi: &std::collections::HashMap<VariantKey, usize>,
var_hap_coords: &[Vec<Vec<u32>>],
line_no: usize,
) -> Result<(), ReadError> {
if format_col != "GT:MT:MB" {
return Err(ReadError::MalformedRecord {
line: line_no,
message: format!("variant record expected FORMAT=GT:MT:MB, got {format_col:?}"),
});
}
let mut parts = sample.splitn(3, ':');
let gt_str = parts.next().unwrap_or("");
let top_field = parts.next().unwrap_or("");
let bot_field = parts.next().unwrap_or("");
let key = VariantKey {
pos_1based,
ref_allele: ref_str.to_string(),
alt_alleles: alt_str.to_string(),
gt: gt_str.to_string(),
};
let vi = pos_to_vi.get(&key).copied().ok_or_else(|| ReadError::MalformedRecord {
line: line_no,
message: format!(
"variant at POS {pos_1based} (REF={ref_str}, ALT={alt_str}, GT={gt_str}) \
not found in provided variants slice"
),
})?;
let top_parts: Vec<&str> = top_field.split('|').collect();
let bot_parts: Vec<&str> = bot_field.split('|').collect();
if top_parts.len() != tables.len() {
return Err(ReadError::PloidyEntryCountMismatch {
pos: pos_1based,
field: "MT",
actual: top_parts.len(),
expected: tables.len(),
});
}
if bot_parts.len() != tables.len() {
return Err(ReadError::PloidyEntryCountMismatch {
pos: pos_1based,
field: "MB",
actual: bot_parts.len(),
expected: tables.len(),
});
}
let hap_coords_for_var =
if vi < var_hap_coords.len() { var_hap_coords[vi].as_slice() } else { &[] };
for (hi, &bits) in top_parts.iter().enumerate() {
let expected: &[u32] =
if hi < hap_coords_for_var.len() { &hap_coords_for_var[hi] } else { &[] };
apply_variant_bits(tables, hi, bits, expected, false, pos_1based)?;
}
for (hi, &bits) in bot_parts.iter().enumerate() {
let expected: &[u32] =
if hi < hap_coords_for_var.len() { &hap_coords_for_var[hi] } else { &[] };
apply_variant_bits(tables, hi, bits, expected, true, pos_1based)?;
}
Ok(())
}
fn per_hap_top_bit_string(
cm: &crate::meth::ContigMethylation,
haplotypes: &[crate::haplotype::Haplotype],
ref_pos: u32,
) -> String {
(0..cm.len())
.map(|i| {
let hap_pos = haplotypes.get(i).map_or(ref_pos, |h| h.hap_position_for(ref_pos));
if cm.table_for(i).is_methylated(hap_pos, false) { "1" } else { "0" }
})
.collect::<Vec<_>>()
.join("|")
}
fn per_hap_bot_bit_string(
cm: &crate::meth::ContigMethylation,
haplotypes: &[crate::haplotype::Haplotype],
ref_pos: u32,
) -> String {
(0..cm.len())
.map(|i| {
let hap_pos = haplotypes.get(i).map_or(ref_pos, |h| h.hap_position_for(ref_pos));
if cm.table_for(i).is_methylated(hap_pos + 1, true) { "1" } else { "0" }
})
.collect::<Vec<_>>()
.join("|")
}
fn open_vcf_buf_reader(path: &std::path::Path) -> std::io::Result<Box<dyn std::io::BufRead>> {
use std::io::{BufReader, Read as _};
let mut peek_buf = [0u8; 2];
{
let mut f = std::fs::File::open(path)?;
f.read_exact(&mut peek_buf)?;
}
let file = std::fs::File::open(path)?;
if peek_buf == [0x1f, 0x8b] {
Ok(Box::new(BufReader::new(flate2::read::MultiGzDecoder::new(file))))
} else {
Ok(Box::new(BufReader::new(file)))
}
}
pub fn vcf_has_mt_mb_records(path: &std::path::Path) -> std::io::Result<bool> {
use std::io::BufRead as _;
let mut reader = open_vcf_buf_reader(path)?;
let mut saw_top_strand_field = false;
let mut saw_bot_strand_field = false;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
break; }
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.starts_with("##") {
if trimmed.contains("ID=MT,") {
saw_top_strand_field = true;
}
if trimmed.contains("ID=MB,") {
saw_bot_strand_field = true;
}
continue;
}
if !(saw_top_strand_field && saw_bot_strand_field) {
return Ok(false);
}
if trimmed.starts_with('#') {
continue; }
if let Some(format_col) = trimmed.split('\t').nth(8)
&& format_col.split(':').any(|k| k == "MT" || k == "MB")
{
return Ok(true);
}
}
Ok(false)
}
#[derive(Debug, Default)]
pub struct MethylationVcfRecords {
pub has_mt_mb: bool,
pub per_contig: std::collections::HashMap<String, Vec<u8>>,
}
pub fn parse_methylation_vcf(path: &std::path::Path) -> std::io::Result<MethylationVcfRecords> {
use std::io::BufRead as _;
let mut reader = open_vcf_buf_reader(path)?;
let mut saw_top_strand_field = false;
let mut saw_bot_strand_field = false;
let mut per_contig: std::collections::HashMap<String, Vec<u8>> =
std::collections::HashMap::new();
let mut past_header = false;
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line)?;
if n == 0 {
break; }
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.starts_with("##") {
if trimmed.contains("ID=MT,") {
saw_top_strand_field = true;
}
if trimmed.contains("ID=MB,") {
saw_bot_strand_field = true;
}
} else if trimmed.starts_with('#') {
past_header = true;
if !(saw_top_strand_field && saw_bot_strand_field) {
return Ok(MethylationVcfRecords::default());
}
} else if past_header {
let Some(chrom) = trimmed.split('\t').next() else {
log::debug!("skipping malformed VCF line: {trimmed}");
continue;
};
let entry = per_contig.entry(chrom.to_string()).or_default();
entry.extend_from_slice(trimmed.as_bytes());
entry.push(b'\n');
}
}
let has_mt_mb = saw_top_strand_field && saw_bot_strand_field;
Ok(MethylationVcfRecords {
has_mt_mb,
per_contig: if has_mt_mb { per_contig } else { std::collections::HashMap::new() },
})
}
pub fn load_contig_methylation_from_records(
records: &MethylationVcfRecords,
contig_name: &str,
reference: &[u8],
variants: &[crate::vcf::genotype::VariantRecord],
sample_ploidy: usize,
) -> anyhow::Result<Option<crate::meth::ContigMethylation>> {
if !records.has_mt_mb {
return Ok(None);
}
let empty: Vec<u8> = Vec::new();
let bytes = records.per_contig.get(contig_name).unwrap_or(&empty);
let cm = read_contig_methylation(bytes, contig_name, reference, variants, sample_ploidy)
.map_err(|e| anyhow::anyhow!("failed to parse MT/MB for {contig_name}: {e}"))?;
log::debug!("Loaded methylation truth for {contig_name} from VCF MT/MB");
Ok(Some(cm))
}
#[cfg(test)]
mod roundtrip_tests {
use super::*;
use crate::meth::{ContigMethylation, MethylationTable};
#[test]
fn standalone_record_round_trip() {
let reference = b"ACGT";
let mut h0 = MethylationTable::empty(4);
h0.set_top(1, true);
let h1 = MethylationTable::empty(4);
let cm_in = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", reference, &[], &cm_in, 2)
.unwrap();
let cm_out = read_contig_methylation(&buf, "chr1", reference, &[], 2).unwrap();
assert_eq!(cm_out.len(), 2);
assert!(cm_out.table_for(0).is_methylated(1, false), "hap0 top should be methylated");
assert!(!cm_out.table_for(1).is_methylated(1, false), "hap1 top should not be methylated");
assert!(
!cm_out.table_for(0).is_methylated(2, true),
"hap0 bottom should not be methylated"
);
assert!(
!cm_out.table_for(1).is_methylated(2, true),
"hap1 bottom should not be methylated"
);
}
}
#[cfg(test)]
mod fuzz_tests {
use rand::Rng as _;
use rand::SeedableRng;
use rand::rngs::SmallRng;
use super::{read_contig_methylation, write_contig};
use crate::haplotype::build_haplotypes;
use crate::meth::{ContigMethylation, MethylationTable};
use crate::vcf::genotype::{Genotype, VariantRecord};
fn assert_methylation_eq(
cm_in: &ContigMethylation,
cm_out: &ContigMethylation,
hap_lengths: &[usize],
) {
assert_eq!(cm_in.len(), cm_out.len(), "haplotype count mismatch");
for (hap, &len) in hap_lengths.iter().enumerate().take(cm_in.len()) {
#[expect(clippy::cast_possible_truncation, reason = "hap length fits u32")]
for pos in 0..len as u32 {
assert_eq!(
cm_in.table_for(hap).is_methylated(pos, false),
cm_out.table_for(hap).is_methylated(pos, false),
"top mismatch at hap {hap} pos {pos}",
);
assert_eq!(
cm_in.table_for(hap).is_methylated(pos, true),
cm_out.table_for(hap).is_methylated(pos, true),
"bottom mismatch at hap {hap} pos {pos}",
);
}
}
}
#[test]
fn roundtrip_random_bitmap_no_variants() {
let mut rng = SmallRng::seed_from_u64(42);
let reference: Vec<u8> =
(0..10_000).map(|_| b"ACGT"[rng.random_range(0..4usize)]).collect();
let mut h0 = MethylationTable::with_len(reference.len());
let mut h1 = MethylationTable::with_len(reference.len());
for i in 0..reference.len() - 1 {
if reference[i] == b'C' && reference[i + 1] == b'G' {
h0.set_top(i, rng.random_bool(0.7));
h0.set_bottom(i + 1, rng.random_bool(0.7));
h1.set_top(i, rng.random_bool(0.7));
h1.set_bottom(i + 1, rng.random_bool(0.7));
}
}
let cm_in = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &[], &cm_in, 2)
.unwrap();
let cm_out = read_contig_methylation(&buf, "chr1", &reference, &[], 2).unwrap();
assert_methylation_eq(&cm_in, &cm_out, &[reference.len(), reference.len()]);
}
#[test]
fn roundtrip_random_bitmap_with_phased_variants() {
let reference: Vec<u8> = b"ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT"
.to_vec();
let variants: Vec<VariantRecord> = vec![
VariantRecord {
position: 10,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"T".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
},
VariantRecord {
position: 50,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"A".to_vec()],
genotype: Genotype::parse("0|1").unwrap(),
},
VariantRecord {
position: 100,
ref_allele: b"G".to_vec(),
alt_alleles: vec![b"C".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
},
VariantRecord {
position: 200,
ref_allele: b"C".to_vec(),
alt_alleles: vec![b"A".to_vec()],
genotype: Genotype::parse("0|1").unwrap(),
},
VariantRecord {
position: 350,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"G".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
},
];
let haplotypes = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(0));
#[expect(clippy::cast_possible_truncation, reason = "reference length fits u32")]
let ref_len_u32 = reference.len() as u32;
let hap_lengths: Vec<usize> =
haplotypes.iter().map(|h| h.hap_position_for(ref_len_u32) as usize).collect();
let mut rng = SmallRng::seed_from_u64(7);
let tables: Vec<MethylationTable> = haplotypes
.iter()
.zip(hap_lengths.iter())
.map(|(hap, &len)| {
let cap = len;
let (hap_bases, _ref_positions, _hap_start) =
hap.extract_fragment(&reference, 0, cap);
let mut table = MethylationTable::with_len(len);
for i in 0..hap_bases.len().saturating_sub(1) {
let c0 = hap_bases[i].to_ascii_uppercase();
let c1 = hap_bases[i + 1].to_ascii_uppercase();
if c0 == b'C' && c1 == b'G' {
table.set_top(i, rng.random_bool(0.6));
table.set_bottom(i + 1, rng.random_bool(0.6));
}
}
table
})
.collect();
let cm_in = ContigMethylation::from_tables(tables);
let mut buf = Vec::new();
write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 2)
.unwrap();
let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 2).unwrap();
assert_methylation_eq(&cm_in, &cm_out, &hap_lengths);
}
#[test]
fn roundtrip_triploid_with_phased_variants() {
let reference: Vec<u8> = b"ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT\
ACGTCGATCGATCGCGATCGACGT"
.to_vec();
let variants: Vec<VariantRecord> = vec![
VariantRecord {
position: 12,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"T".to_vec()],
genotype: Genotype::parse("1|0|1").unwrap(),
},
VariantRecord {
position: 40,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"A".to_vec()],
genotype: Genotype::parse("0|1|0").unwrap(),
},
VariantRecord {
position: 70,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"AGGG".to_vec()],
genotype: Genotype::parse("0|0|1").unwrap(),
},
];
let haplotypes = build_haplotypes(&variants, 3, &mut SmallRng::seed_from_u64(0));
#[expect(clippy::cast_possible_truncation, reason = "reference length fits u32")]
let ref_len_u32 = reference.len() as u32;
let hap_lengths: Vec<usize> =
haplotypes.iter().map(|h| h.hap_position_for(ref_len_u32) as usize).collect();
let mut rng = SmallRng::seed_from_u64(11);
let tables: Vec<MethylationTable> = haplotypes
.iter()
.zip(hap_lengths.iter())
.map(|(hap, &len)| {
let (hap_bases, _ref_positions, _hap_start) =
hap.extract_fragment(&reference, 0, len);
let mut table = MethylationTable::with_len(len);
for i in 0..hap_bases.len().saturating_sub(1) {
if hap_bases[i].eq_ignore_ascii_case(&b'C')
&& hap_bases[i + 1].eq_ignore_ascii_case(&b'G')
{
table.set_top(i, rng.random_bool(0.6));
table.set_bottom(i + 1, rng.random_bool(0.6));
}
}
table
})
.collect();
let cm_in = ContigMethylation::from_tables(tables);
assert_eq!(cm_in.len(), 3, "fixture must be triploid");
let mut buf = Vec::new();
write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 3)
.unwrap();
let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 3).unwrap();
assert_methylation_eq(&cm_in, &cm_out, &hap_lengths);
}
#[test]
fn standalone_cpg_downstream_of_indel_round_trips_per_haplotype() {
let reference: Vec<u8> = b"ATATATCGATACGATACGATACGCG".to_vec();
let variants: Vec<VariantRecord> = vec![VariantRecord {
position: 3,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"TGGG".to_vec()],
genotype: Genotype::parse("0|1").unwrap(),
}];
let haplotypes = build_haplotypes(&variants, 2, &mut SmallRng::seed_from_u64(0));
#[expect(clippy::cast_possible_truncation, reason = "reference length fits u32")]
let ref_len_u32 = reference.len() as u32;
let hap_lengths: Vec<usize> =
haplotypes.iter().map(|h| h.hap_position_for(ref_len_u32) as usize).collect();
let mut tables: Vec<MethylationTable> = haplotypes
.iter()
.zip(hap_lengths.iter())
.map(|(hap, &len)| {
let (hap_bases, _ref_positions, _hap_start) =
hap.extract_fragment(&reference, 0, len);
let mut table = MethylationTable::with_len(len);
for i in 0..hap_bases.len().saturating_sub(1) {
if hap_bases[i].eq_ignore_ascii_case(&b'C')
&& hap_bases[i + 1].eq_ignore_ascii_case(&b'G')
{
table.set_top(i, true);
table.set_bottom(i + 1, true);
}
}
table
})
.collect();
let hap1_first_cpg_hap_pos = haplotypes[1].hap_position_for(6) as usize;
tables[1].set_top(hap1_first_cpg_hap_pos, false);
let cm_in = ContigMethylation::from_tables(tables);
let mut buf = Vec::new();
write_contig(&mut std::io::Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 2)
.unwrap();
let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 2).unwrap();
assert_methylation_eq(&cm_in, &cm_out, &hap_lengths);
let hap1_table = cm_out.table_for(1);
let cpg_ref_pos: u32 = 11; let cpg_hap_pos = haplotypes[1].hap_position_for(cpg_ref_pos);
assert_ne!(cpg_hap_pos, cpg_ref_pos, "test setup assumes indel shifts hap 1");
assert!(
hap1_table.is_methylated(cpg_hap_pos, false),
"hap 1 CpG at hap-coord {cpg_hap_pos} (ref pos {cpg_ref_pos}) should be methylated",
);
assert!(
!hap1_table.is_methylated(cpg_ref_pos, false),
"hap 1 should NOT have a top-strand bit at ref pos {cpg_ref_pos} \
(that would indicate ref_pos was used as a hap coord)",
);
}
}
#[cfg(test)]
mod writer_tests {
use super::*;
use crate::meth::{ContigMethylation, MethylationTable};
use std::io::Cursor;
#[test]
fn writes_methylation_only_record_for_reference_cpg() {
let reference = b"ACGT";
let mut h0 = MethylationTable::empty(4);
h0.set_top(1, true);
let h1 = MethylationTable::empty(4);
let cm = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_contig(&mut Cursor::new(&mut buf), "chr1", reference, &[], &cm, 2).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t1|0:0|0"), "got: {s}");
}
#[test]
fn writes_variant_record_with_mt_mb_for_alt_cpg() {
use crate::vcf::genotype::Genotype;
let reference = b"AAATTAA";
let variants = vec![
VariantRecord {
position: 3,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"C".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
},
VariantRecord {
position: 4,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"G".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
},
];
let mut h0 = MethylationTable::empty(7);
h0.set_top(3, true);
h0.set_bottom(4, true);
let h1 = MethylationTable::empty(7);
let cm = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_contig(&mut Cursor::new(&mut buf), "chr1", reference, &variants, &cm, 2).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("chr1\t4\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:1|.:1|."),
"expected variant 0 row, got:\n{s}"
);
assert!(
s.contains("chr1\t5\t.\tT\tG\t.\t.\t.\tGT:MT:MB\t1|0:.|.:.|."),
"expected variant 1 row, got:\n{s}"
);
}
}
#[cfg(test)]
mod reader_error_tests {
use super::*;
use crate::vcf::genotype::{Genotype, VariantRecord};
#[test]
fn invalid_utf8_body_is_rejected() {
let buf: &[u8] = &[0xFFu8, 0xFE, b'\n'];
let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
let msg = format!("{err}");
assert!(matches!(err, ReadError::MalformedRecord { .. }), "got {err:?}");
assert!(msg.contains("not valid UTF-8"), "unexpected message: {msg}");
}
#[test]
fn standalone_record_with_pos_zero_is_rejected() {
let buf = b"chr1\t0\t.\tC\t.\t.\t.\t.\tMT:MB\t0|0:0|0\n";
let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
let msg = format!("{err}");
assert!(matches!(err, ReadError::MalformedRecord { .. }), "got {err:?}");
assert!(msg.contains("POS must be 1-based"), "unexpected message: {msg}");
}
#[test]
fn malformed_record_with_too_few_columns_is_rejected() {
let buf = b"chr1\t2\t.\tC\t.\t.\n";
let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
assert!(
matches!(err, ReadError::MalformedRecord { .. }),
"expected MalformedRecord, got {err:?}"
);
}
#[test]
fn invalid_mtmb_character_in_standalone_record_is_rejected() {
let buf = b"chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t2|0:0|0\n";
let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
assert!(
matches!(err, ReadError::InvalidMtMbChar { ch: '2', .. }),
"expected InvalidMtMbChar for '2', got {err:?}"
);
}
#[test]
fn standalone_record_with_wrong_mt_entry_count_is_rejected() {
let buf = b"chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t1|0|0:0|0\n";
let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
assert!(
matches!(err, ReadError::PloidyEntryCountMismatch { actual: 3, expected: 2, .. }),
"expected PloidyEntryCountMismatch{{actual:3,expected:2}}, got {err:?}"
);
}
#[test]
fn standalone_record_with_too_few_mb_entries_is_rejected() {
let buf = b"chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t1|0:0\n";
let err = read_contig_methylation(buf, "chr1", b"ACGT", &[], 2).unwrap_err();
assert!(
matches!(err, ReadError::PloidyEntryCountMismatch { actual: 1, expected: 2, .. }),
"expected PloidyEntryCountMismatch{{actual:1,expected:2}}, got {err:?}"
);
}
#[test]
fn variant_record_with_wrong_mt_entry_count_is_rejected() {
use crate::vcf::genotype::{Genotype, VariantRecord};
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"C".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
}];
let buf = b"chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:.|.|.:.|.\n";
let err = read_contig_methylation(buf, "chr1", b"ATTT", &variants, 2).unwrap_err();
assert!(
matches!(err, ReadError::PloidyEntryCountMismatch { actual: 3, expected: 2, .. }),
"expected PloidyEntryCountMismatch{{actual:3,expected:2}}, got {err:?}"
);
}
#[test]
fn variant_record_with_too_few_mb_entries_is_rejected() {
use crate::vcf::genotype::{Genotype, VariantRecord};
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"C".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
}];
let buf = b"chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:.|.:.\n";
let err = read_contig_methylation(buf, "chr1", b"ATTT", &variants, 2).unwrap_err();
assert!(
matches!(err, ReadError::PloidyEntryCountMismatch { actual: 1, expected: 2, .. }),
"expected PloidyEntryCountMismatch{{actual:1,expected:2}}, got {err:?}"
);
}
#[test]
fn variant_record_mtmb_length_mismatch_is_rejected() {
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"T".to_vec(),
alt_alleles: vec![b"C".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
}];
let buf = b"chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:1|.:.|.\n";
let err = read_contig_methylation(buf, "chr1", b"ATTT", &variants, 2).unwrap_err();
assert!(
matches!(err, ReadError::MtMbLengthMismatch { actual: 1, expected: 0, .. }),
"expected MtMbLengthMismatch{{actual:1,expected:0}}, got {err:?}"
);
}
#[test]
fn variant_record_dot_when_owned_cpgs_expected_is_rejected() {
let variants = vec![VariantRecord {
position: 1,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"ACG".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
}];
let buf = b"chr1\t2\t.\tA\tACG\t.\t.\t.\tGT:MT:MB\t1|0:.|.:.|.\n";
let err = read_contig_methylation(buf, "chr1", b"AAAAAAAA", &variants, 2).unwrap_err();
assert!(
matches!(err, ReadError::MtMbLengthMismatch { actual: 0, expected: 1, .. }),
"expected MtMbLengthMismatch{{actual:0,expected:1}}, got {err:?}"
);
}
#[test]
fn round_trip_disambiguates_two_variants_sharing_pos() {
use crate::meth::{ContigMethylation, MethylationTable};
use crate::vcf::genotype::Genotype;
use std::io::Cursor;
let reference = b"AAAAAAAA".to_vec();
let variants = vec![
VariantRecord {
position: 1,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"ACG".to_vec()],
genotype: Genotype::parse("1|0").unwrap(),
},
VariantRecord {
position: 1,
ref_allele: b"A".to_vec(),
alt_alleles: vec![b"ACG".to_vec()],
genotype: Genotype::parse("0|1").unwrap(),
},
];
let mut h0 = MethylationTable::with_len(reference.len() + 2);
h0.set_top(2, true);
h0.set_bottom(3, true);
let h1 = MethylationTable::with_len(reference.len() + 2);
let cm_in = ContigMethylation::from_tables(vec![h0, h1]);
let mut buf = Vec::new();
write_contig(&mut Cursor::new(&mut buf), "chr1", &reference, &variants, &cm_in, 2).unwrap();
let cm_out = read_contig_methylation(&buf, "chr1", &reference, &variants, 2)
.expect("round-trip must succeed when records share POS but differ in GT");
assert!(cm_out.table_for(0).is_methylated(2, false), "hap0 top should be set");
assert!(cm_out.table_for(0).is_methylated(3, true), "hap0 bottom should be set");
assert!(!cm_out.table_for(1).is_methylated(2, false), "hap1 top must stay unset");
assert!(!cm_out.table_for(1).is_methylated(3, true), "hap1 bottom must stay unset");
}
#[test]
fn haploid_variant_free_contig_writes_single_entry_mt_mb() {
use crate::meth::{ContigMethylation, MethylationTable};
use std::io::Cursor;
let reference = b"ACGT"; let cm = ContigMethylation::from_tables(vec![MethylationTable::with_len(4)]);
let mut buf = Vec::new();
write_contig(&mut Cursor::new(&mut buf), "chr1", reference, &[], &cm, 1).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t0:0\n"), "got:\n{s}");
let cm_out = read_contig_methylation(s.as_bytes(), "chr1", reference, &[], 1).unwrap();
assert_eq!(cm_out.len(), 1, "haploid round-trip must yield 1 table");
}
}
#[cfg(test)]
mod record_probe_tests {
use super::*;
use std::io::Write as _;
fn write_temp_vcf(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn header_and_record_with_mt_mb_returns_true() {
let vcf = "##fileformat=VCFv4.4\n\
##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
chr1\t2\t.\tC\t.\t.\t.\t.\tMT:MB\t0:0\n";
let f = write_temp_vcf(vcf);
assert!(vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn variant_record_with_mt_mb_returns_true() {
let vcf = "##fileformat=VCFv4.4\n\
##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
chr1\t2\t.\tT\tC\t.\t.\t.\tGT:MT:MB\t1|0:1|.:1|.\n";
let f = write_temp_vcf(vcf);
assert!(vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn header_only_no_records_returns_false() {
let vcf = "##fileformat=VCFv4.4\n\
##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n";
let f = write_temp_vcf(vcf);
assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn record_without_mt_mb_format_returns_false() {
let vcf = "##fileformat=VCFv4.4\n\
##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
chr1\t1\t.\tA\tT\t.\t.\t.\tGT\t0|1\n";
let f = write_temp_vcf(vcf);
assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn header_with_only_mt_returns_false() {
let vcf = "##fileformat=VCFv4.4\n\
##FORMAT=<ID=MT,Number=.,Type=String,Description=\"top\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
chr1\t2\t.\tC\t.\t.\t.\t.\tMT\t0\n";
let f = write_temp_vcf(vcf);
assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn header_with_only_mb_returns_false() {
let vcf = "##fileformat=VCFv4.4\n\
##FORMAT=<ID=MB,Number=.,Type=String,Description=\"bottom\">\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n\
chr1\t2\t.\tC\t.\t.\t.\t.\tMB\t0\n";
let f = write_temp_vcf(vcf);
assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn header_with_neither_returns_false() {
let vcf = "##fileformat=VCFv4.4\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n";
let f = write_temp_vcf(vcf);
assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
}
#[test]
fn no_mt_mb_header_does_not_read_body() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(
b"##fileformat=VCFv4.4\n\
#CHROM\tPOS\tID\tREF\tALT\tQUAL\tFILTER\tINFO\tFORMAT\tSAMPLE\n",
)
.unwrap();
f.write_all(&[0xFFu8; 64]).unwrap();
f.flush().unwrap();
assert!(!vcf_has_mt_mb_records(f.path()).unwrap());
}
}