use crate::chrom;
use crate::codon::reverse_complement;
use crate::error::VarEffectError;
use crate::fasta::FastaReader;
use crate::hgvs_reverse::{parse_seq, validate_base};
use crate::left_align::left_align_indel;
use crate::types::GenomicVariant;
#[derive(Debug, Clone, PartialEq, Eq)]
enum HgvsGChange {
Substitution { ref_base: u8, alt_base: u8 },
Deletion { stated: Option<Vec<u8>> },
Duplication { stated: Option<Vec<u8>> },
Insertion { bases: Vec<u8> },
Delins { bases: Vec<u8> },
Inversion,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ParsedHgvsG {
accession: String,
start: u64,
end: Option<u64>,
change: HgvsGChange,
}
fn parse_hgvs_g(input: &str) -> Result<ParsedHgvsG, VarEffectError> {
let err = |msg: &str| VarEffectError::HgvsParseError(format!("{msg}: \"{input}\""));
if !input.is_ascii() {
return Err(err("HGVS notation must be ASCII"));
}
let (accession, desc) = input
.split_once(":g.")
.or_else(|| input.split_once(":m."))
.ok_or_else(|| err("missing ':g.' or ':m.' separator"))?;
if accession.is_empty() {
return Err(err("empty accession"));
}
if accession.starts_with("NG_")
|| accession.starts_with("LRG_")
|| accession.starts_with("NW_")
|| accession.starts_with("NT_")
{
return Err(err(
"only chromosomal NC_/chr genomic references are supported; \
gene-region/LRG/scaffold references use region-relative coordinates",
));
}
if desc.contains('=') {
return Err(err(
"identity/reference-call (=) describes no sequence change, not a variant",
));
}
if desc.contains("::") || desc.contains(['?', '(', ')', '[', ']', ';', '^']) {
return Err(err(
"imprecise, templated, or multi-allele notation is not resolvable to exact coordinates",
));
}
if let Some(idx) = desc.find("delins") {
let pos_str = &desc[..idx];
let inserted = parse_seq(&desc[idx + 6..], input)?; let (start, end) = parse_positions(pos_str, input)?;
Ok(ParsedHgvsG {
accession: accession.to_string(),
start,
end,
change: HgvsGChange::Delins { bases: inserted },
})
} else if let Some(idx) = desc.find("inv") {
let pos_str = &desc[..idx];
if !desc[idx + 3..].is_empty() {
return Err(err("unexpected trailing characters after 'inv'"));
}
let (start, end_opt) = parse_positions(pos_str, input)?;
let end = end_opt.ok_or_else(|| err("inversion requires a range (start_end)"))?;
if end <= start {
return Err(err("inversion span must be > 1 nt"));
}
Ok(ParsedHgvsG {
accession: accession.to_string(),
start,
end: Some(end),
change: HgvsGChange::Inversion,
})
} else if let Some(ins_idx) = desc.find("ins")
&& desc.contains('_')
{
let pos_str = &desc[..ins_idx];
let inserted = parse_seq(&desc[ins_idx + 3..], input)?; let (start, end_opt) = parse_positions(pos_str, input)?;
let end = end_opt.ok_or_else(|| err("insertion requires two flanking positions"))?;
if end != start + 1 {
return Err(err("insertion flanks must be adjacent (end == start + 1)"));
}
Ok(ParsedHgvsG {
accession: accession.to_string(),
start,
end: Some(end),
change: HgvsGChange::Insertion { bases: inserted },
})
} else if let Some(idx) = desc.find("del") {
let pos_str = &desc[..idx];
let stated = parse_optional_seq(&desc[idx + 3..], input)?; let (start, end) = parse_positions(pos_str, input)?;
Ok(ParsedHgvsG {
accession: accession.to_string(),
start,
end,
change: HgvsGChange::Deletion { stated },
})
} else if let Some(idx) = desc.find("dup") {
let pos_str = &desc[..idx];
let stated = parse_optional_seq(&desc[idx + 3..], input)?; let (start, end) = parse_positions(pos_str, input)?;
Ok(ParsedHgvsG {
accession: accession.to_string(),
start,
end,
change: HgvsGChange::Duplication { stated },
})
} else if let Some(gt_idx) = desc.find('>') {
if gt_idx == 0 {
return Err(err("substitution missing REF base"));
}
let ref_base = desc.as_bytes()[gt_idx - 1].to_ascii_uppercase();
let pos_str = &desc[..gt_idx - 1];
let alt_str = &desc[gt_idx + 1..];
if alt_str.len() != 1 {
return Err(err("substitution ALT must be a single base"));
}
let alt_base = alt_str.as_bytes()[0].to_ascii_uppercase();
validate_base(ref_base, input)?;
validate_base(alt_base, input)?;
let start = parse_pos(pos_str, input)?;
Ok(ParsedHgvsG {
accession: accession.to_string(),
start,
end: None,
change: HgvsGChange::Substitution { ref_base, alt_base },
})
} else {
Err(err(
"unrecognized change type (expected del/dup/ins/delins/inv or >)",
))
}
}
fn parse_optional_seq(s: &str, input: &str) -> Result<Option<Vec<u8>>, VarEffectError> {
if s.is_empty() {
Ok(None)
} else {
Ok(Some(parse_seq(s, input)?))
}
}
fn parse_positions(s: &str, input: &str) -> Result<(u64, Option<u64>), VarEffectError> {
if let Some((left, right)) = s.split_once('_') {
let start = parse_pos(left, input)?;
let end = parse_pos(right, input)?;
if end < start {
return Err(VarEffectError::HgvsParseError(format!(
"range end {end} precedes start {start}: \"{input}\""
)));
}
Ok((start, Some(end)))
} else {
Ok((parse_pos(s, input)?, None))
}
}
fn parse_pos(s: &str, input: &str) -> Result<u64, VarEffectError> {
let err = |msg: &str| {
VarEffectError::HgvsParseError(format!("{msg} in position \"{s}\": \"{input}\""))
};
if s.is_empty() {
return Err(err("empty position"));
}
if !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(err(
"genomic positions must be plain integers (no +/-/* offsets)",
));
}
let value: u64 = s.parse().map_err(|_| err("position integer overflow"))?;
if value == 0 {
return Err(err("position 0 is invalid (HGVS is 1-based)"));
}
Ok(value)
}
fn accession_to_chrom(accession: &str) -> Option<String> {
if accession.starts_with("NC_") {
let ucsc = chrom::refseq_to_ucsc(accession);
if ucsc == accession {
None
} else {
Some(ucsc.to_string())
}
} else if accession.starts_with("chr") {
Some(accession.to_string())
} else {
None
}
}
fn build_substitution(
pos: u64,
ref_base: u8,
alt_base: u8,
chrom: &str,
hgvs: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let gpos = pos - 1;
let fasta_ref = fasta.fetch_base(chrom, gpos)?.to_ascii_uppercase();
if fasta_ref != ref_base {
return Err(VarEffectError::HgvsRefMismatch {
hgvs: hgvs.to_string(),
chrom: chrom.to_string(),
pos: gpos,
expected: String::from(fasta_ref as char),
got: String::from(ref_base as char),
});
}
Ok(GenomicVariant {
chrom: chrom.to_string(),
pos: gpos,
ref_allele: vec![ref_base],
alt_allele: vec![alt_base],
})
}
fn verify_stated_span(
stated: Option<&[u8]>,
span: &[u8],
chrom: &str,
gstart0: u64,
hgvs: &str,
) -> Result<(), VarEffectError> {
if let Some(stated) = stated
&& stated != span
{
return Err(VarEffectError::HgvsRefMismatch {
hgvs: hgvs.to_string(),
chrom: chrom.to_string(),
pos: gstart0,
expected: String::from_utf8_lossy(span).into_owned(),
got: String::from_utf8_lossy(stated).into_owned(),
});
}
Ok(())
}
fn build_deletion(
start: u64,
end: Option<u64>,
stated: Option<&[u8]>,
chrom: &str,
hgvs: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let gstart0 = start - 1;
let gend0 = end.unwrap_or(start) - 1;
let deleted = fasta.fetch_sequence(chrom, gstart0, gend0 + 1)?;
verify_stated_span(stated, &deleted, chrom, gstart0, hgvs)?;
let anchor_pos =
gstart0
.checked_sub(1)
.ok_or_else(|| VarEffectError::CoordinateOutOfRange {
chrom: chrom.to_string(),
start: 0,
end: 0,
chrom_len: fasta.chrom_length(chrom).unwrap_or(0),
})?;
let anchor_base = fasta.fetch_base(chrom, anchor_pos)?;
let mut ref_allele = Vec::with_capacity(1 + deleted.len());
ref_allele.push(anchor_base);
ref_allele.extend_from_slice(&deleted);
Ok(GenomicVariant {
chrom: chrom.to_string(),
pos: anchor_pos,
ref_allele,
alt_allele: vec![anchor_base],
})
}
fn build_duplication(
start: u64,
end: Option<u64>,
stated: Option<&[u8]>,
chrom: &str,
hgvs: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let gstart0 = start - 1;
let gend0 = end.unwrap_or(start) - 1;
let dup_seq = fasta.fetch_sequence(chrom, gstart0, gend0 + 1)?;
verify_stated_span(stated, &dup_seq, chrom, gstart0, hgvs)?;
let anchor_base = fasta.fetch_base(chrom, gend0)?;
let mut alt_allele = Vec::with_capacity(1 + dup_seq.len());
alt_allele.push(anchor_base);
alt_allele.extend_from_slice(&dup_seq);
Ok(GenomicVariant {
chrom: chrom.to_string(),
pos: gend0,
ref_allele: vec![anchor_base],
alt_allele,
})
}
fn build_insertion(
start: u64,
inserted: &[u8],
chrom: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let anchor_pos = start - 1; let anchor_base = fasta.fetch_base(chrom, anchor_pos)?;
let mut alt_allele = Vec::with_capacity(1 + inserted.len());
alt_allele.push(anchor_base);
alt_allele.extend_from_slice(inserted);
Ok(GenomicVariant {
chrom: chrom.to_string(),
pos: anchor_pos,
ref_allele: vec![anchor_base],
alt_allele,
})
}
fn build_delins(
start: u64,
end: Option<u64>,
inserted: &[u8],
chrom: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let gstart0 = start - 1;
let gend0 = end.unwrap_or(start) - 1;
let ref_allele = fasta.fetch_sequence(chrom, gstart0, gend0 + 1)?;
Ok(GenomicVariant {
chrom: chrom.to_string(),
pos: gstart0,
ref_allele,
alt_allele: inserted.to_vec(),
})
}
fn build_inversion(
start: u64,
end: u64,
chrom: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let gstart0 = start - 1;
let gend0 = end - 1;
let span = fasta.fetch_sequence(chrom, gstart0, gend0 + 1)?;
let alt_allele = reverse_complement(&span);
Ok(GenomicVariant {
chrom: chrom.to_string(),
pos: gstart0,
ref_allele: span,
alt_allele,
})
}
fn canonicalize(
raw: GenomicVariant,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let shifted = {
let ref_str =
std::str::from_utf8(&raw.ref_allele).map_err(|_| VarEffectError::InvalidAllele)?;
let alt_str =
std::str::from_utf8(&raw.alt_allele).map_err(|_| VarEffectError::InvalidAllele)?;
left_align_indel(fasta, &raw.chrom, raw.pos + 1, ref_str, alt_str)?
};
match shifted {
Some((pos_1based, r, a)) => Ok(GenomicVariant {
chrom: raw.chrom,
pos: pos_1based - 1,
ref_allele: r.into_bytes(),
alt_allele: a.into_bytes(),
}),
None => Ok(raw),
}
}
pub(crate) fn resolve_hgvs_g(
hgvs: &str,
fasta: &FastaReader,
) -> Result<GenomicVariant, VarEffectError> {
let parsed = parse_hgvs_g(hgvs)?;
let chrom =
accession_to_chrom(&parsed.accession).ok_or_else(|| VarEffectError::ChromNotFound {
chrom: parsed.accession.clone(),
})?;
if fasta.chrom_length(&chrom).is_none() {
return Err(VarEffectError::ChromNotFound { chrom });
}
let raw = match &parsed.change {
HgvsGChange::Substitution { ref_base, alt_base } => {
build_substitution(parsed.start, *ref_base, *alt_base, &chrom, hgvs, fasta)?
}
HgvsGChange::Deletion { stated } => build_deletion(
parsed.start,
parsed.end,
stated.as_deref(),
&chrom,
hgvs,
fasta,
)?,
HgvsGChange::Duplication { stated } => build_duplication(
parsed.start,
parsed.end,
stated.as_deref(),
&chrom,
hgvs,
fasta,
)?,
HgvsGChange::Insertion { bases } => build_insertion(parsed.start, bases, &chrom, fasta)?,
HgvsGChange::Delins { bases } => {
build_delins(parsed.start, parsed.end, bases, &chrom, fasta)?
}
HgvsGChange::Inversion => build_inversion(
parsed.start,
parsed.end.expect("inversion parse guarantees a range"),
&chrom,
fasta,
)?,
};
if raw.ref_allele == raw.alt_allele {
return Err(VarEffectError::HgvsParseError(format!(
"no sequence change: \"{hgvs}\""
)));
}
canonicalize(raw, fasta)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fasta::write_genome_binary;
use tempfile::TempDir;
const CONTIG: &[u8] = b"AATGGGGTAA";
fn fasta_with(contigs: &[(&str, &[u8])]) -> (TempDir, FastaReader) {
let tmp = TempDir::new().expect("tempdir");
let bin = tmp.path().join("g.bin");
let idx = tmp.path().join("g.bin.idx");
write_genome_binary(contigs, "test", &bin, &idx).expect("write synthetic genome");
let fasta = FastaReader::open(&bin).expect("open synthetic genome");
(tmp, fasta)
}
fn resolve(hgvs: &str) -> Result<GenomicVariant, VarEffectError> {
let (_tmp, fasta) = fasta_with(&[("1", CONTIG)]);
resolve_hgvs_g(hgvs, &fasta)
}
fn gv(pos: u64, r: &[u8], a: &[u8]) -> GenomicVariant {
GenomicVariant {
chrom: "chr1".to_string(),
pos,
ref_allele: r.to_vec(),
alt_allele: a.to_vec(),
}
}
#[test]
fn substitution() {
assert_eq!(resolve("NC_000001.11:g.3T>A").unwrap(), gv(2, b"T", b"A"));
}
#[test]
fn substitution_is_case_insensitive() {
assert_eq!(resolve("NC_000001.11:g.3t>a").unwrap(), gv(2, b"T", b"A"));
}
#[test]
fn single_base_deletion_left_aligns() {
assert_eq!(resolve("NC_000001.11:g.7del").unwrap(), gv(2, b"TG", b"T"));
}
#[test]
fn range_deletion() {
assert_eq!(
resolve("NC_000001.11:g.4_5del").unwrap(),
gv(2, b"TGG", b"T")
);
}
#[test]
fn deletion_at_contig_start_errors() {
assert!(matches!(
resolve("NC_000001.11:g.1del"),
Err(VarEffectError::CoordinateOutOfRange { .. })
));
}
#[test]
fn stated_deletion_sequence_validates() {
assert_eq!(resolve("NC_000001.11:g.7delG").unwrap(), gv(2, b"TG", b"T"));
}
#[test]
fn stated_deletion_sequence_mismatch_errors() {
assert!(matches!(
resolve("NC_000001.11:g.7delA"),
Err(VarEffectError::HgvsRefMismatch { .. })
));
}
#[test]
fn duplication_left_aligns() {
assert_eq!(resolve("NC_000001.11:g.7dup").unwrap(), gv(2, b"T", b"TG"));
}
#[test]
fn range_duplication_left_aligns() {
assert_eq!(
resolve("NC_000001.11:g.6_7dup").unwrap(),
gv(2, b"T", b"TGG")
);
}
#[test]
fn stated_duplication_sequence_validates() {
assert_eq!(resolve("NC_000001.11:g.7dupG").unwrap(), gv(2, b"T", b"TG"));
}
#[test]
fn stated_duplication_sequence_mismatch_errors() {
assert!(matches!(
resolve("NC_000001.11:g.7dupA"),
Err(VarEffectError::HgvsRefMismatch { .. })
));
}
#[test]
fn insertion() {
assert_eq!(
resolve("NC_000001.11:g.8_9insC").unwrap(),
gv(7, b"T", b"TC")
);
}
#[test]
fn delins() {
assert_eq!(
resolve("NC_000001.11:g.3_4delinsCC").unwrap(),
gv(2, b"TG", b"CC")
);
}
#[test]
fn delins_trims_shared_prefix_to_substitution() {
assert_eq!(
resolve("NC_000001.11:g.3_4delinsTC").unwrap(),
gv(3, b"G", b"C")
);
}
#[test]
fn delins_collapses_to_indel_and_left_aligns() {
assert_eq!(
resolve("NC_000001.11:g.4_5delinsG").unwrap(),
gv(2, b"TG", b"T")
);
}
#[test]
fn inversion() {
assert_eq!(
resolve("NC_000001.11:g.3_5inv").unwrap(),
gv(2, b"TGG", b"CCA")
);
}
#[test]
fn palindromic_inversion_is_rejected() {
assert!(matches!(
resolve("NC_000001.11:g.2_3inv"),
Err(VarEffectError::HgvsParseError(_))
));
}
#[test]
fn substitution_ref_mismatch_errors() {
assert!(matches!(
resolve("NC_000001.11:g.3C>A"),
Err(VarEffectError::HgvsRefMismatch { .. })
));
}
fn is_parse_err(hgvs: &str) -> bool {
matches!(resolve(hgvs), Err(VarEffectError::HgvsParseError(_)))
}
#[test]
fn rejects_position_zero() {
assert!(is_parse_err("NC_000001.11:g.0T>A"));
}
#[test]
fn rejects_non_ascii_input() {
assert!(is_parse_err("NC_000001.11:g.3é>A"));
}
#[test]
fn rejects_offset_forms() {
assert!(is_parse_err("NC_000001.11:g.3+2C>A"));
assert!(is_parse_err("NC_000001.11:g.3*1del"));
}
#[test]
fn rejects_non_adjacent_insertion() {
assert!(is_parse_err("NC_000001.11:g.3_5insA"));
}
#[test]
fn rejects_single_position_inversion() {
assert!(is_parse_err("NC_000001.11:g.3inv"));
}
#[test]
fn rejects_reversed_range() {
assert!(is_parse_err("NC_000001.11:g.5_3del"));
}
#[test]
fn rejects_identity() {
assert!(is_parse_err("NC_000001.11:g.3="));
assert!(is_parse_err("NC_000001.11:g.3_4="));
}
#[test]
fn rejects_imprecise_and_templated() {
assert!(is_parse_err("NC_000001.11:g.?"));
assert!(is_parse_err("NC_000001.11:g.(3_4)del"));
assert!(is_parse_err("NC_000001.11:g.(3_4)_(6_7)del"));
assert!(is_parse_err("NC_000001.11:g.3AC[4]"));
assert!(is_parse_err("NC_000001.11:g.3_4insN[10]"));
assert!(is_parse_err("NC_000001.11:g.[3T>A;5G>C]"));
}
#[test]
fn rejects_non_chromosomal_references() {
assert!(is_parse_err("NG_012232.1:g.3T>A"));
assert!(is_parse_err("LRG_199:g.3T>A"));
assert!(is_parse_err("NW_009646201.1:g.3T>A"));
assert!(is_parse_err("NT_187633.1:g.3T>A"));
}
#[test]
fn rejects_missing_separator() {
assert!(is_parse_err("NM_000546.6:c.742C>T"));
}
#[test]
fn accepts_ucsc_chrom_accession() {
assert_eq!(resolve("chr1:g.3T>A").unwrap(), gv(2, b"T", b"A"));
}
#[test]
fn unknown_accession_is_chrom_not_found() {
assert!(matches!(
resolve("NC_000009.99:g.3T>A"),
Err(VarEffectError::ChromNotFound { .. })
));
assert!(matches!(
resolve("chrZZ:g.3T>A"),
Err(VarEffectError::ChromNotFound { .. })
));
}
#[test]
fn accession_to_chrom_covers_edges() {
assert_eq!(accession_to_chrom("NC_000017.11").as_deref(), Some("chr17"));
assert_eq!(accession_to_chrom("NC_000024.10").as_deref(), Some("chrY"));
assert_eq!(accession_to_chrom("NC_012920.1").as_deref(), Some("chrM"));
assert_eq!(accession_to_chrom("chr1").as_deref(), Some("chr1"));
assert_eq!(accession_to_chrom("NC_000009.99"), None);
assert_eq!(accession_to_chrom("gibberish"), None);
}
#[test]
fn mito_prefix_is_parsed() {
let parsed = parse_hgvs_g("NC_012920.1:m.8993T>G").unwrap();
assert_eq!(parsed.accession, "NC_012920.1");
assert_eq!(parsed.start, 8993);
assert_eq!(
accession_to_chrom(&parsed.accession).as_deref(),
Some("chrM")
);
}
#[test]
fn round_trips_through_format_hgvs_g() {
let (_tmp, fasta) = fasta_with(&[("1", CONTIG)]);
for input in [
"NC_000001.11:g.3T>A",
"NC_000001.11:g.7del",
"NC_000001.11:g.7dup",
"NC_000001.11:g.8_9insC",
] {
let gvar = resolve_hgvs_g(input, &fasta).unwrap();
let formatted = crate::hgvs_g::format_hgvs_g(
&gvar.chrom,
gvar.pos,
&gvar.ref_allele,
&gvar.alt_allele,
&fasta,
)
.unwrap();
assert_eq!(formatted.as_deref(), Some(input), "round-trip for {input}");
}
}
}