use crate::chrom;
use crate::error::VarEffectError;
use crate::fasta::FastaReader;
pub fn format_hgvs_g(
chrom: &str,
pos: u64,
ref_allele: &[u8],
alt_allele: &[u8],
fasta: &FastaReader,
) -> Result<Option<String>, VarEffectError> {
let ref_u = ref_allele.to_ascii_uppercase();
let alt_u = alt_allele.to_ascii_uppercase();
if ref_u.is_empty() || alt_u.is_empty() || !is_acgtn(&ref_u) || !is_acgtn(&alt_u) {
return Ok(None);
}
let acc = match resolve_accession(chrom) {
Some(acc) => acc,
None => return Ok(None),
};
let chrom_len = match fasta.chrom_length(chrom) {
Some(len) => len,
None => return Ok(None),
};
if pos.saturating_add(ref_u.len() as u64) > chrom_len {
return Ok(None);
}
if !fasta.verify_ref(chrom, pos, &ref_u)? {
return Ok(None);
}
let (start, r, a) = trim_to_minimal_edit(pos, &ref_u, &alt_u);
match (r.as_slice(), a.as_slice()) {
([], []) => Ok(None),
([rb], [ab]) => Ok(Some(format!(
"{acc}:g.{}{}>{}",
start + 1,
char::from(*rb),
char::from(*ab)
))),
(del, []) => {
let len = del.len() as u64;
let s = shift_deletion(chrom, start, len, chrom_len, fasta)?;
if len == 1 {
Ok(Some(format!("{acc}:g.{}del", s + 1)))
} else {
Ok(Some(format!("{acc}:g.{}_{}del", s + 1, s + len)))
}
}
([], ins) => format_insertion(&acc, chrom, start, ins, chrom_len, fasta),
(del, ins) => {
let len = del.len() as u64;
let alt_str = ascii_to_string(ins);
if len == 1 {
Ok(Some(format!("{acc}:g.{}delins{}", start + 1, alt_str)))
} else {
Ok(Some(format!(
"{acc}:g.{}_{}delins{}",
start + 1,
start + len,
alt_str
)))
}
}
}
}
fn resolve_accession(chrom: &str) -> Option<String> {
if chrom.starts_with("NC_") {
return Some(chrom.to_string());
}
let acc = chrom::ucsc_to_refseq(chrom);
if acc == chrom {
None
} else {
Some(acc.to_string())
}
}
fn trim_to_minimal_edit(pos: u64, ref_u: &[u8], alt_u: &[u8]) -> (u64, Vec<u8>, Vec<u8>) {
let mut r_end = ref_u.len();
let mut a_end = alt_u.len();
while r_end > 0 && a_end > 0 && ref_u[r_end - 1] == alt_u[a_end - 1] {
r_end -= 1;
a_end -= 1;
}
let mut lead = 0;
while lead < r_end && lead < a_end && ref_u[lead] == alt_u[lead] {
lead += 1;
}
(
pos + lead as u64,
ref_u[lead..r_end].to_vec(),
alt_u[lead..a_end].to_vec(),
)
}
fn shift_deletion(
chrom: &str,
start: u64,
len: u64,
chrom_len: u64,
fasta: &FastaReader,
) -> Result<u64, VarEffectError> {
let mut s = start;
while s + len < chrom_len {
if fasta.fetch_base(chrom, s)? != fasta.fetch_base(chrom, s + len)? {
break;
}
s += 1;
}
Ok(s)
}
fn format_insertion(
acc: &str,
chrom: &str,
start: u64,
ins: &[u8],
chrom_len: u64,
fasta: &FastaReader,
) -> Result<Option<String>, VarEffectError> {
let mut p = start;
let mut seq = ins.to_vec();
while p < chrom_len && fasta.fetch_base(chrom, p)? == seq[0] {
seq.rotate_left(1);
p += 1;
}
let m = seq.len() as u64;
if p >= m && fasta.fetch_sequence(chrom, p - m, p)? == seq {
return if m == 1 {
Ok(Some(format!("{acc}:g.{p}dup")))
} else {
Ok(Some(format!("{acc}:g.{}_{}dup", p - m + 1, p)))
};
}
if p == 0 || p >= chrom_len {
return Ok(None);
}
Ok(Some(format!(
"{acc}:g.{}_{}ins{}",
p,
p + 1,
ascii_to_string(&seq)
)))
}
fn is_acgtn(bytes: &[u8]) -> bool {
bytes
.iter()
.all(|&b| matches!(b, b'A' | b'C' | b'G' | b'T' | b'N'))
}
fn ascii_to_string(bytes: &[u8]) -> String {
String::from_utf8(bytes.to_vec()).expect("ACGTN alleles are valid ASCII")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fasta::{FastaReader, 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 fmt(chrom: &str, pos: u64, r: &[u8], a: &[u8]) -> Option<String> {
let (_tmp, fasta) = fasta_with(&[("1", CONTIG)]);
format_hgvs_g(chrom, pos, r, a, &fasta).expect("format_hgvs_g should not error")
}
#[test]
fn substitution() {
assert_eq!(
fmt("chr1", 2, b"T", b"A"),
Some("NC_000001.11:g.3T>A".to_string())
);
}
#[test]
fn substitution_is_case_insensitive() {
assert_eq!(
fmt("chr1", 2, b"t", b"a"),
Some("NC_000001.11:g.3T>A".to_string())
);
}
#[test]
fn substitution_ref_genome_mismatch_yields_none() {
assert_eq!(fmt("chr1", 2, b"C", b"A"), None);
}
#[test]
fn single_base_deletion_shifts_3prime() {
assert_eq!(
fmt("chr1", 2, b"TG", b"T"),
Some("NC_000001.11:g.7del".to_string())
);
}
#[test]
fn range_deletion_shifts_3prime() {
assert_eq!(
fmt("chr1", 2, b"TGG", b"T"),
Some("NC_000001.11:g.6_7del".to_string())
);
}
#[test]
fn duplication_in_homopolymer() {
assert_eq!(
fmt("chr1", 2, b"T", b"TG"),
Some("NC_000001.11:g.7dup".to_string())
);
}
#[test]
fn plain_insertion_not_a_duplication() {
assert_eq!(
fmt("chr1", 7, b"T", b"TC"),
Some("NC_000001.11:g.8_9insC".to_string())
);
}
#[test]
fn deletion_insertion() {
assert_eq!(
fmt("chr1", 2, b"TG", b"CC"),
Some("NC_000001.11:g.3_4delinsCC".to_string())
);
}
#[test]
fn single_ref_base_delins() {
assert_eq!(
fmt("chr1", 2, b"T", b"CC"),
Some("NC_000001.11:g.3delinsCC".to_string())
);
}
#[test]
fn multibase_tandem_duplication() {
let (_tmp, fasta) = fasta_with(&[("1", b"AAGTGTGTCC")]);
assert_eq!(
format_hgvs_g("chr1", 1, b"A", b"AGT", &fasta).unwrap(),
Some("NC_000001.11:g.7_8dup".to_string())
);
}
#[test]
fn contig_end_deletion_clamps_without_error() {
assert_eq!(
fmt("chr1", 8, b"AA", b"A"),
Some("NC_000001.11:g.10del".to_string())
);
}
#[test]
fn insertion_before_first_base_yields_none() {
assert_eq!(fmt("chr1", 0, b"A", b"GA"), None);
}
#[test]
fn ref_overhanging_contig_end_yields_none() {
assert_eq!(fmt("chr1", 9, b"AA", b"A"), None);
}
#[test]
fn symbolic_allele_yields_none() {
assert_eq!(fmt("chr1", 2, b"T", b"<DEL>"), None);
}
#[test]
fn patch_contig_yields_none() {
let (_tmp, fasta) = fasta_with(&[("1", CONTIG)]);
assert_eq!(
format_hgvs_g("chrUn_KI270302v1", 5, b"A", b"T", &fasta).unwrap(),
None
);
}
#[test]
fn accession_resolution_covers_chrom_edges() {
assert_eq!(resolve_accession("chr17").as_deref(), Some("NC_000017.11"));
assert_eq!(resolve_accession("chrX").as_deref(), Some("NC_000023.11"));
assert_eq!(resolve_accession("chrY").as_deref(), Some("NC_000024.10"));
assert_eq!(resolve_accession("chrM").as_deref(), Some("NC_012920.1"));
assert_eq!(
resolve_accession("NC_000017.11").as_deref(),
Some("NC_000017.11")
);
assert_eq!(resolve_accession("17"), None);
assert_eq!(resolve_accession("chr9_KN196479v1_fix"), None);
}
}