use std::io::{self, BufRead};
use thiserror::Error;
use crate::dtc::{ArrayMetrics, Record as DtcRecord};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReportMetadata {
pub gsgt_version: Option<String>,
pub manifest: Option<String>,
pub num_snps: Option<u64>,
pub num_samples: Option<u64>,
}
#[derive(Debug, Error)]
pub enum GsError {
#[error("I/O error")]
Io(#[from] io::Error),
#[error("not a GenomeStudio Final Report: no [Data] section found")]
MissingDataSection,
#[error("GenomeStudio report has no column header row after [Data]")]
MissingColumnHeader,
#[error("GenomeStudio report is missing required column(s): {0}")]
MissingColumns(String),
#[error(
"GenomeStudio report has no 'Allele1/2 - Plus' columns. Re-export the Final \
Report with Plus-strand alleles enabled; Top/Forward/AB cannot be converted \
accurately without the array manifest."
)]
NoPlusAlleles,
#[error("line {line}: invalid position '{value}'")]
InvalidPosition { line: u64, value: String },
#[error(
"line {line}: invalid variant ID {value:?}: must not contain whitespace or ';' \
(forbidden in the VCF ID field)"
)]
InvalidId { line: u64, value: String },
#[error("line {line}: wrong field count (expected {expected}, found {found})")]
FieldCount {
line: u64,
expected: usize,
found: usize,
},
}
#[derive(Debug, Clone, Copy)]
struct ColumnMap {
sample_id: Option<usize>,
snp_name: usize,
chr: usize,
position: usize,
allele1_plus: usize,
allele2_plus: usize,
baf: Option<usize>,
lrr: Option<usize>,
gencall: Option<usize>,
gentrain: Option<usize>,
width: usize,
}
fn norm(name: &str) -> String {
name.trim().trim_matches('"').trim().to_ascii_lowercase()
}
impl ColumnMap {
fn from_header(header_fields: &[&str]) -> Result<Self, GsError> {
let normalized: Vec<String> = header_fields.iter().map(|f| norm(f)).collect();
let find = |candidates: &[&str]| -> Option<usize> {
candidates
.iter()
.find_map(|c| normalized.iter().position(|n| n == c))
};
let find_suffix =
|suffix: &str| -> Option<usize> { normalized.iter().position(|n| n.ends_with(suffix)) };
let snp_name = find(&["snp name", "gxg snp name", "name"])
.or_else(|| find_suffix("snp name"))
.ok_or_else(|| GsError::MissingColumns("SNP Name".into()))?;
let chr =
find(&["chr", "chromosome"]).ok_or_else(|| GsError::MissingColumns("Chr".into()))?;
let position = find(&["position", "pos", "mapinfo"])
.ok_or_else(|| GsError::MissingColumns("Position".into()))?;
let allele1_plus = find(&["allele1 - plus"]);
let allele2_plus = find(&["allele2 - plus"]);
let (allele1_plus, allele2_plus) = match (allele1_plus, allele2_plus) {
(Some(a), Some(b)) => (a, b),
_ => return Err(GsError::NoPlusAlleles),
};
Ok(ColumnMap {
sample_id: find(&["sample id", "sample_id", "sample"]),
snp_name,
chr,
position,
allele1_plus,
allele2_plus,
baf: find(&["b allele freq", "baf", "b allele frequency"]),
lrr: find(&["log r ratio", "lrr"]),
gencall: find(&["gc score", "gencall score", "gencall"]),
gentrain: find(&["gt score", "gentrain score", "gentrain"]),
width: header_fields.len(),
})
}
}
pub struct Reader<R> {
inner: R,
columns: ColumnMap,
line: u64,
buf: String,
locked_sample: Option<String>,
pub skipped_other_sample: u64,
metadata: ReportMetadata,
}
impl<R> Reader<R>
where
R: BufRead,
{
pub fn new(mut inner: R) -> Result<Self, GsError> {
let mut metadata = ReportMetadata::default();
let mut line: u64 = 0;
let mut buf = String::new();
loop {
buf.clear();
let n = inner.read_line(&mut buf)?;
if n == 0 {
return Err(GsError::MissingDataSection);
}
line += 1;
let trimmed = strip_bom(buf.trim_end_matches(['\n', '\r']).trim());
if trimmed.is_empty() {
continue;
}
if trimmed.eq_ignore_ascii_case("[data]") {
break;
}
if trimmed.eq_ignore_ascii_case("[header]") {
continue;
}
absorb_metadata(trimmed, &mut metadata);
}
let header_line = loop {
buf.clear();
let n = inner.read_line(&mut buf)?;
if n == 0 {
return Err(GsError::MissingColumnHeader);
}
line += 1;
let trimmed = buf.trim_end_matches(['\n', '\r']).trim();
if !trimmed.is_empty() {
break trimmed.to_string();
}
};
let header_fields: Vec<&str> = header_line.split(',').collect();
let columns = ColumnMap::from_header(&header_fields)?;
Ok(Self {
inner,
columns,
line,
buf: String::new(),
locked_sample: None,
skipped_other_sample: 0,
metadata,
})
}
pub fn metadata(&self) -> &ReportMetadata {
&self.metadata
}
fn parse_data_line(&mut self, raw: &str) -> Result<Option<DtcRecord>, GsError> {
let fields: Vec<&str> = raw.split(',').map(|s| s.trim()).collect();
let n = fields.len();
let width = self.columns.width;
let acceptable = n == width || (n == width + 1 && fields[n - 1].is_empty());
if !acceptable {
return Err(GsError::FieldCount {
line: self.line,
expected: width,
found: n,
});
}
if let Some(idx) = self.columns.sample_id {
let sample = fields[idx];
match &self.locked_sample {
None => self.locked_sample = Some(sample.to_string()),
Some(locked) if locked != sample => {
self.skipped_other_sample += 1;
return Ok(None);
}
Some(_) => {}
}
}
let chromosome = fields[self.columns.chr].trim();
if chromosome.is_empty() {
return Ok(None);
}
let position_str = fields[self.columns.position].trim();
let position = position_str
.parse::<u64>()
.map_err(|_| GsError::InvalidPosition {
line: self.line,
value: position_str.to_string(),
})?;
if position == 0 {
return Ok(None);
}
let a1 = fields[self.columns.allele1_plus].trim();
let a2 = fields[self.columns.allele2_plus].trim();
let genotype = join_alleles(a1, a2);
let snp_name = fields[self.columns.snp_name].trim();
let id = if snp_name.is_empty() || snp_name == "." {
None
} else if crate::dtc::is_valid_vcf_id(snp_name) {
Some(snp_name.to_string())
} else {
return Err(GsError::InvalidId {
line: self.line,
value: snp_name.to_string(),
});
};
let num = |idx: Option<usize>| -> Option<f32> {
let s = fields[idx?].trim();
if s.is_empty() {
return None;
}
s.parse::<f32>().ok().filter(|v| v.is_finite())
};
let metrics = ArrayMetrics {
baf: num(self.columns.baf),
lrr: num(self.columns.lrr),
gencall: num(self.columns.gencall),
gentrain: num(self.columns.gentrain),
};
Ok(Some(DtcRecord {
id,
chromosome: chromosome.to_string(),
position,
genotype,
metrics: if metrics.is_empty() {
None
} else {
Some(metrics)
},
}))
}
}
impl<R> Iterator for Reader<R>
where
R: BufRead,
{
type Item = Result<DtcRecord, GsError>;
fn next(&mut self) -> Option<Self::Item> {
loop {
self.buf.clear();
match self.inner.read_line(&mut self.buf) {
Ok(0) => return None,
Ok(_) => {
self.line += 1;
let trimmed = self.buf.trim_end_matches(['\n', '\r']).trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
continue;
}
let owned = trimmed.to_string();
match self.parse_data_line(&owned) {
Ok(Some(rec)) => return Some(Ok(rec)),
Ok(None) => continue,
Err(e) => return Some(Err(e)),
}
}
Err(e) => return Some(Err(GsError::Io(e))),
}
}
}
}
fn join_alleles(a1: &str, a2: &str) -> String {
let a1 = if a1.is_empty() { "-" } else { a1 };
let a2 = if a2.is_empty() { "-" } else { a2 };
if a1.chars().count() <= 1 && a2.chars().count() <= 1 {
format!("{a1}{a2}")
} else {
format!("{a1}/{a2}")
}
}
fn absorb_metadata(line: &str, meta: &mut ReportMetadata) {
let mut parts = line.splitn(2, ',');
let key = parts.next().unwrap_or("").trim();
let rest = parts.next().unwrap_or("").trim();
let value = rest.trim_start_matches(',').trim();
match key.to_ascii_lowercase().as_str() {
"gsgt version" => meta.gsgt_version = Some(value.to_string()),
"content" => meta.manifest = Some(value.to_string()),
"num snps" => meta.num_snps = value.parse().ok(),
"num samples" => meta.num_samples = value.parse().ok(),
_ => {}
}
}
fn strip_bom(s: &str) -> &str {
s.strip_prefix('\u{feff}').unwrap_or(s)
}
pub fn looks_like_genome_studio(buf: &[u8]) -> bool {
let text = match std::str::from_utf8(buf) {
Ok(t) => t,
Err(_) => {
let valid = buf.iter().take_while(|&&b| b.is_ascii()).count();
std::str::from_utf8(&buf[..valid]).unwrap_or("")
}
};
for line in text.lines().take(8) {
let t = strip_bom(line.trim());
if t.is_empty() {
continue;
}
if t.eq_ignore_ascii_case("[header]") || t.eq_ignore_ascii_case("[data]") {
return true;
}
if t.to_ascii_lowercase().starts_with("gsgt version") {
return true;
}
return false;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
[Header]
GSGT Version,2.0.5
Processing Date,5/19/2025 12:53 PM
Content,,GxGComprehensiveGSAv2-2_20031858_A2.bpm
Num SNPs,4
Num Samples,1
[Data]
Sample ID,SNP Name,Chr,Position,Log R Ratio,B Allele Freq,Allele1 - Plus,Allele2 - Plus,Allele1 - Top,Allele2 - Top
S1,rs100,1,102914837,-0.02,1.00,G,G,G,G
S1,rs200,1,106194696,-0.24,0.07,A,T,A,A
S1,rs300,2,500,0.00,0.50,-,-,A,G
S1,rs400,2,0,0.00,0.50,C,C,C,C
";
fn reader(s: &str) -> Reader<&[u8]> {
Reader::new(s.as_bytes()).expect("construct reader")
}
#[test]
fn parses_header_metadata() {
let r = reader(SAMPLE);
let m = r.metadata();
assert_eq!(m.gsgt_version.as_deref(), Some("2.0.5"));
assert_eq!(
m.manifest.as_deref(),
Some("GxGComprehensiveGSAv2-2_20031858_A2.bpm")
);
assert_eq!(m.num_snps, Some(4));
assert_eq!(m.num_samples, Some(1));
}
#[test]
fn extracts_plus_alleles_and_skips_unmapped() {
let recs: Vec<_> = reader(SAMPLE).map(|r| r.unwrap()).collect();
assert_eq!(recs.len(), 3);
assert_eq!(recs[0].id.as_deref(), Some("rs100"));
assert_eq!(recs[0].chromosome, "1");
assert_eq!(recs[0].position, 102914837);
assert_eq!(recs[0].genotype, "GG");
assert_eq!(recs[1].genotype, "AT");
assert_eq!(recs[2].genotype, "--");
assert!(recs[2].is_missing());
}
#[test]
fn parses_array_metrics() {
let recs: Vec<_> = reader(SAMPLE).map(|r| r.unwrap()).collect();
let m = recs[0].metrics.expect("metrics present");
assert_eq!(m.lrr, Some(-0.02));
assert_eq!(m.baf, Some(1.00));
assert_eq!(m.gencall, None);
assert_eq!(m.gentrain, None);
let full = "\
[Data]
SNP Name,Chr,Position,Log R Ratio,B Allele Freq,GC Score,GT Score,Allele1 - Plus,Allele2 - Plus
rs1,1,100,-0.12,0.48,0.97,0.81,A,G
";
let r = Reader::new(full.as_bytes())
.unwrap()
.next()
.unwrap()
.unwrap();
let m = r.metrics.expect("metrics present");
assert_eq!(m.lrr, Some(-0.12));
assert_eq!(m.baf, Some(0.48));
assert_eq!(m.gencall, Some(0.97));
assert_eq!(m.gentrain, Some(0.81));
}
#[test]
fn rejects_snp_name_with_forbidden_chars() {
let report = "\
[Data]
SNP Name,Chr,Position,Allele1 - Plus,Allele2 - Plus
rs1;weird,1,100,A,G
rs2,1,200,C,T
";
let results: Vec<_> = Reader::new(report.as_bytes()).unwrap().collect();
assert_eq!(results.len(), 2);
match &results[0] {
Err(GsError::InvalidId { value, .. }) => assert_eq!(value, "rs1;weird"),
other => panic!("expected InvalidId, got {other:?}"),
}
assert_eq!(results[1].as_ref().unwrap().id.as_deref(), Some("rs2"));
}
#[test]
fn requires_plus_alleles() {
let no_plus = "\
[Header]
GSGT Version,2.0.5
[Data]
SNP Name,Chr,Position,Allele1 - Top,Allele2 - Top
rs1,1,100,A,G
";
let err = Reader::new(no_plus.as_bytes()).err();
assert!(
matches!(err, Some(GsError::NoPlusAlleles)),
"expected NoPlusAlleles, got {err:?}"
);
}
#[test]
fn locks_onto_first_sample() {
let multi = "\
[Header]
Num Samples,2
[Data]
Sample ID,SNP Name,Chr,Position,Allele1 - Plus,Allele2 - Plus
S1,rs1,1,100,A,A
S2,rs1,1,100,C,C
S1,rs2,1,200,G,T
S2,rs2,1,200,T,T
";
let mut r = Reader::new(multi.as_bytes()).unwrap();
let recs: Vec<_> = (&mut r).map(|x| x.unwrap()).collect();
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].genotype, "AA");
assert_eq!(recs[1].genotype, "GT");
assert_eq!(r.skipped_other_sample, 2);
}
#[test]
fn column_order_is_by_name_not_position() {
let reordered = "\
[Data]
Chr,Position,Allele2 - Plus,GxG SNP Name,GC Score,Allele1 - Plus,Sample ID
1,100,T,rsX,0.99,A,S1
";
let recs: Vec<_> = Reader::new(reordered.as_bytes())
.unwrap()
.map(|x| x.unwrap())
.collect();
assert_eq!(recs.len(), 1);
assert_eq!(recs[0].id.as_deref(), Some("rsX"));
assert_eq!(recs[0].chromosome, "1");
assert_eq!(recs[0].position, 100);
assert_eq!(recs[0].genotype, "AT");
}
#[test]
fn detector_recognizes_reports() {
assert!(looks_like_genome_studio(b"[Header]\nGSGT Version,2.0.5\n"));
assert!(looks_like_genome_studio(b"\n\n[Data]\nSNP Name,Chr\n"));
assert!(looks_like_genome_studio(
b"GSGT Version,2.0.5\nProcessing Date,x\n"
));
assert!(!looks_like_genome_studio(b"# rsid\trs1\t1\t100\tAA\n"));
assert!(!looks_like_genome_studio(b"rsid\tchromosome\tposition\n"));
}
}