use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use noodles::vcf::variant::record::samples::keys::key;
use noodles::vcf::variant::record_buf::RecordBuf;
use noodles::vcf::variant::record_buf::samples::sample::Value;
use noodles::vcf::variant::record::{AlternateBases, Ids};
use crate::cli::Sex;
pub struct PlinkWriter {
bed: BufWriter<File>,
bim: BufWriter<File>,
fam: BufWriter<File>,
samples_written: bool,
}
impl PlinkWriter {
pub fn new<P: AsRef<Path>>(prefix: P) -> io::Result<Self> {
let prefix = prefix.as_ref();
let stem = if prefix.extension().is_some_and(|e| e == "vcf" || e == "bcf") {
prefix.with_extension("")
} else {
prefix.to_path_buf()
};
let bed_path = stem.with_extension("bed");
let bim_path = stem.with_extension("bim");
let fam_path = stem.with_extension("fam");
let mut bed = BufWriter::new(File::create(bed_path)?);
let bim = BufWriter::new(File::create(bim_path)?);
let fam = BufWriter::new(File::create(fam_path)?);
bed.write_all(&[0x6C, 0x1B, 0x01])?;
Ok(Self {
bed,
bim,
fam,
samples_written: false,
})
}
pub fn write_fam(&mut self, sample_id: &str, sex: Sex) -> io::Result<()> {
if self.samples_written {
return Ok(());
}
let sex_code = match sex {
Sex::Male => "1",
Sex::Female => "2",
Sex::Unknown => "0",
};
writeln!(self.fam, "{0}\t{0}\t0\t0\t{1}\t-9", sample_id, sex_code)?;
self.samples_written = true;
Ok(())
}
pub fn write_variant(&mut self, record: &RecordBuf) -> io::Result<()> {
let Self { bed, bim, .. } = self;
write_plink_row(record, bim, bed)
}
pub fn append_chunk(&mut self, bim: &[u8], bed: &[u8]) -> io::Result<()> {
self.bim.write_all(bim)?;
self.bed.write_all(bed)?;
Ok(())
}
}
pub fn write_plink_row<B: Write, D: Write>(
record: &RecordBuf,
bim: &mut B,
bed: &mut D,
) -> io::Result<()> {
let alt_bases = record.alternate_bases();
if alt_bases.is_empty() {
return Ok(());
}
for (i, alt_allele) in alt_bases.as_ref().iter().enumerate() {
let alt_idx = i + 1;
let suffix = if alt_bases.len() > 1 {
Some(format!("_ALT{}", alt_idx))
} else {
None
};
write_biallelic_variant(record, alt_idx, suffix, alt_allele, bim, bed)?;
}
Ok(())
}
fn write_biallelic_variant<B: Write, D: Write>(
record: &RecordBuf,
target_alt_idx: usize,
id_suffix: Option<String>,
alt_allele_str: &str,
bim: &mut B,
bed: &mut D,
) -> io::Result<()> {
let chrom = record.reference_sequence_name();
let mut id = if record.ids().is_empty() {
".".to_string()
} else {
record
.ids()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(";")
};
if let Some(suffix) = id_suffix {
if id == "." {
let pos = record.variant_start().map(usize::from).unwrap_or(0);
id = format!("{}:{}{}", chrom, pos, suffix);
} else {
id.push_str(&suffix);
}
}
let pos = record.variant_start().map(usize::from).unwrap_or(0);
let ref_base = record.reference_bases();
writeln!(
bim,
"{}\t{}\t0\t{}\t{}\t{}",
chrom, id, pos, ref_base, alt_allele_str
)?;
let mut byte = 0u8;
let mut count = 0;
for sample in record.samples().values() {
let genotype_val = sample.get(key::GENOTYPE);
let bits: u8 = match genotype_val {
Some(Some(Value::String(s))) => {
let (a1, a2) = parse_gt_indices(s);
let mut copies = 0;
let mut missing = false;
let check = |idx_opt: Option<usize>| {
if let Some(idx) = idx_opt {
if idx == target_alt_idx { 1 } else { 0 }
} else {
2
}
};
let c1 = check(a1);
let c2 = check(a2);
if c1 == 2 || c2 == 2 {
missing = true;
} else {
copies = c1 + c2;
}
if missing {
1
} else {
match copies {
0 => 0,
1 => 2,
2 => 3,
_ => 1,
}
}
}
_ => 1,
};
byte |= bits << (2 * count);
count += 1;
if count == 4 {
bed.write_all(&[byte])?;
byte = 0;
count = 0;
}
}
if count > 0 {
bed.write_all(&[byte])?;
}
Ok(())
}
pub fn parse_gt_indices(gt: &str) -> (Option<usize>, Option<usize>) {
let alleles: Vec<&str> = gt.split(['/', '|']).collect();
match alleles.len() {
1 => {
let a1 = alleles[0].parse::<usize>().ok();
(a1, a1)
}
2 => {
let a1 = alleles[0].parse::<usize>().ok();
let a2 = alleles[1].parse::<usize>().ok();
(a1, a2)
}
_ => (None, None), }
}
#[cfg(test)]
mod tests {
use super::*;
use noodles::vcf::variant::record_buf::{RecordBuf, Samples};
#[test]
fn test_multiallelic_split() -> io::Result<()> {
let temp_dir = tempfile::tempdir()?;
let prefix = temp_dir.path().join("test");
{
let mut writer = PlinkWriter::new(&prefix)?;
use noodles::vcf::variant::record_buf::AlternateBases;
use noodles::vcf::variant::record_buf::samples::Keys;
let keys: Keys = vec![String::from("GT")].into_iter().collect();
let samples = vec![
vec![Some(Value::String("1/2".into()))],
vec![Some(Value::String("0/1".into()))],
vec![Some(Value::String("2/2".into()))],
];
let samples = Samples::new(keys, samples);
let record = RecordBuf::builder()
.set_reference_sequence_name("1")
.set_variant_start(noodles::core::Position::try_from(100).unwrap())
.set_reference_bases("A")
.set_alternate_bases(AlternateBases::from(vec!["C".into(), "G".into()]))
.set_samples(samples)
.build();
writer.write_variant(&record)?;
}
let bim_content = std::fs::read_to_string(prefix.with_extension("bim"))?;
let lines: Vec<&str> = bim_content.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("1:100_ALT1"));
assert!(lines[0].ends_with("A\tC"));
assert!(lines[1].contains("1:100_ALT2"));
assert!(lines[1].ends_with("A\tG"));
Ok(())
}
}