use super::SpdiVariant;
use crate::convert::CoordinateMapper;
use crate::coords::{OneBasedPos, ZeroBasedPos};
use crate::error::FerroError;
use crate::hgvs::edit::{InsertedSequence, NaEdit, RepeatCount, Sequence};
use crate::hgvs::interval::{Interval, UncertainBoundary};
use crate::hgvs::location::{CdsPos, GenomePos, RnaPos, TxPos};
use crate::hgvs::parser::accession::parse_accession;
use crate::hgvs::variant::{
Accession, CdsVariant, CircularVariant, GenomeVariant, HgvsVariant, LocEdit, MtVariant,
RnaVariant, TxVariant,
};
use crate::normalize::rules::InsCoordKind;
use crate::reference::provider::ReferenceProvider;
use crate::reference::transcript::Transcript;
use crate::sequence::reverse_complement;
const MAX_REPEAT_EXPANSION_BASES: usize = 100_000;
const MAX_REPEAT_SEARCH_BASES: u64 = 200_000;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConversionError {
UnsupportedVariantType {
description: String,
},
MissingReferenceData {
description: String,
},
UnsupportedEditType {
description: String,
},
InvalidPosition {
description: String,
},
InvalidAccession {
description: String,
},
ProviderRequired {
variant_type: String,
reason: String,
},
UnrepresentableInSpdi {
description: String,
},
}
impl std::fmt::Display for ConversionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConversionError::UnsupportedVariantType { description } => {
write!(
f,
"unsupported variant type for conversion: {}",
description
)
}
ConversionError::MissingReferenceData { description } => {
write!(f, "missing reference data: {}", description)
}
ConversionError::UnsupportedEditType { description } => {
write!(f, "unsupported edit type for conversion: {}", description)
}
ConversionError::InvalidPosition { description } => {
write!(f, "invalid position: {}", description)
}
ConversionError::InvalidAccession { description } => {
write!(f, "invalid accession: {}", description)
}
ConversionError::ProviderRequired {
variant_type,
reason,
} => {
write!(
f,
"reference provider required to convert {}. variant: {}",
variant_type, reason
)
}
ConversionError::UnrepresentableInSpdi { description } => {
write!(f, "cannot be expressed in SPDI: {}", description)
}
}
}
}
impl std::error::Error for ConversionError {}
impl From<ConversionError> for FerroError {
fn from(err: ConversionError) -> Self {
FerroError::ConversionError {
msg: err.to_string(),
}
}
}
fn sequence_to_string(seq: &Sequence) -> String {
seq.to_string()
}
fn inserted_sequence_to_string(seq: &InsertedSequence) -> Option<String> {
match seq {
InsertedSequence::Literal(s) => Some(s.to_string()),
_ => None,
}
}
fn resolve_payload_position(
accession: &str,
transcript: &Transcript,
pos: u64,
coord: &str,
) -> Result<u64, ConversionError> {
let base = i64::try_from(pos).map_err(|_| ConversionError::InvalidPosition {
description: format!(
"{coord}. insert payload position {pos} on {accession} exceeds the addressable range"
),
})?;
let mapper = CoordinateMapper::new(transcript);
let tx = mapper.cds_to_tx(&CdsPos::new(base)).map_err(|e| {
ConversionError::MissingReferenceData {
description: format!(
"could not resolve {coord}. insert payload position {pos} on {accession} \
to a transcript position: {e}"
),
}
})?;
let one_based = ensure_positive_tx(tx.base, coord, pos)?;
if one_based > transcript.sequence_length() {
return Err(ConversionError::InvalidPosition {
description: format!(
"{coord}. insert payload position {pos} on {accession} resolves to transcript \
base {one_based}, past the transcript 3' end (length {})",
transcript.sequence_length()
),
});
}
Ok(one_based)
}
fn resolve_payload_span<P>(
accession: &str,
start: u64,
end: u64,
kind: InsCoordKind,
provider: &P,
) -> Result<(u64, u64), ConversionError>
where
P: ReferenceProvider + ?Sized,
{
let coord = match kind {
InsCoordKind::Direct => return Ok((start, end)),
InsCoordKind::Cds => "c",
InsCoordKind::Rna => "r",
};
let transcript =
provider
.get_transcript(accession)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!(
"could not load transcript {accession} to resolve the {coord}. insert \
payload {start}_{end}: {e}"
),
})?;
if kind == InsCoordKind::Rna && transcript.cds_start.is_none() {
return Ok((start, end));
}
Ok((
resolve_payload_position(accession, &transcript, start, coord)?,
resolve_payload_position(accession, &transcript, end, coord)?,
))
}
fn read_reference_span<P>(
accession: &str,
start: u64,
end: u64,
invert: bool,
alphabet: AlphabetMode,
kind: InsCoordKind,
provider: Option<&P>,
) -> Result<String, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
let provider = provider.ok_or_else(|| ConversionError::MissingReferenceData {
description: format!(
"position-range insertion {start}_{end} names bases in the same reference; \
a provider is required to read them"
),
})?;
let (start, end) = resolve_payload_span(accession, start, end, kind, provider)?;
let bases = fetch_reference_bases(provider, accession, start, end)?;
let bases = if invert {
reverse_complement(&bases)
} else {
bases
};
Ok(apply_alphabet(&bases, alphabet))
}
fn refuse_undetermined_repeat_unit(unit: &str) -> Result<(), ConversionError> {
if unit.bytes().any(|b| b.eq_ignore_ascii_case(&b'N')) {
return Err(ConversionError::UnrepresentableInSpdi {
description: format!(
"repeat unit `{unit}` contains an undetermined base (`N`); it names a length, \
not bases, so expanding it would assert bases the input never specified. Like \
`ins<length>`, an undetermined-content repeat is refused rather than emitted \
as a run of `N`s"
),
});
}
Ok(())
}
fn expand_repeat_unit(
unit: &str,
count: u64,
alphabet: AlphabetMode,
) -> Result<String, ConversionError> {
let unit = apply_alphabet(unit, alphabet);
refuse_undetermined_repeat_unit(&unit)?;
let count = count as usize;
let expansion_bases =
unit.len()
.checked_mul(count)
.ok_or_else(|| ConversionError::UnsupportedEditType {
description: format!(
"repeat expansion {} x {} overflows usize",
unit.len(),
count
),
})?;
if expansion_bases > MAX_REPEAT_EXPANSION_BASES {
return Err(ConversionError::UnsupportedEditType {
description: format!(
"repeat expansion {} bases exceeds SPDI ins-string cap of {} bases",
expansion_bases, MAX_REPEAT_EXPANSION_BASES
),
});
}
Ok(unit.repeat(count))
}
fn resolve_position_range_insert<P>(
seq: &InsertedSequence,
accession: &str,
alphabet: AlphabetMode,
kind: InsCoordKind,
provider: Option<&P>,
) -> Result<Option<String>, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
let Some((start, end, invert)) = seq.as_position_range() else {
return Ok(None);
};
read_reference_span(accession, start, end, invert, alphabet, kind, provider).map(Some)
}
fn exact_repeat_insert(seq: &InsertedSequence) -> Option<(String, u64)> {
use crate::hgvs::edit::InsertedPart;
let exact = |count: &RepeatCount| match count {
RepeatCount::Exact(n) => Some(*n),
_ => None,
};
match seq {
InsertedSequence::Repeat { base, count } => {
Some((base.to_char().to_string(), exact(count)?))
}
InsertedSequence::SequenceRepeat { sequence, count } => {
Some((sequence_to_string(sequence), exact(count)?))
}
InsertedSequence::Complex(parts) => match parts.as_slice() {
[InsertedPart::Repeat { base, count }] => {
Some((base.to_char().to_string(), exact(count)?))
}
_ => None,
},
_ => None,
}
}
fn resolve_exact_repeat_insert(
seq: &InsertedSequence,
alphabet: AlphabetMode,
) -> Result<Option<String>, ConversionError> {
let Some((unit, count)) = exact_repeat_insert(seq) else {
return Ok(None);
};
expand_repeat_unit(&unit, count, alphabet).map(Some)
}
fn resolve_inserted_part<P>(
part: &crate::hgvs::edit::InsertedPart,
accession: &str,
alphabet: AlphabetMode,
kind: InsCoordKind,
provider: Option<&P>,
) -> Result<String, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
use crate::hgvs::edit::InsertedPart;
match part {
InsertedPart::Literal(seq) => Ok(apply_alphabet(&seq.to_string(), alphabet)),
InsertedPart::PositionRange { start, end } => {
read_reference_span(accession, *start, *end, false, alphabet, kind, provider)
}
InsertedPart::PositionRangeInv { start, end } => {
read_reference_span(accession, *start, *end, true, alphabet, kind, provider)
}
InsertedPart::Repeat {
base,
count: RepeatCount::Exact(n),
} => expand_repeat_unit(&base.to_char().to_string(), *n, alphabet),
InsertedPart::Repeat { base, count } => Err(ConversionError::UnsupportedEditType {
description: format!(
"compound insert part {base}{count} has no determinate expansion; \
this shape is not yet encodable as SPDI"
),
}),
InsertedPart::CdsPositionRange(range) => Err(ConversionError::UnsupportedEditType {
description: format!(
"compound insert part {range} names CDS positions with intronic offsets; \
this shape is not yet encodable as SPDI"
),
}),
InsertedPart::ExternalRef(reference) => Err(ConversionError::UnsupportedEditType {
description: format!(
"compound insert part {reference} references an external sequence SPDI \
cannot dereference; this shape is not yet encodable as SPDI"
),
}),
}
}
fn resolve_complex_insert<P>(
seq: &InsertedSequence,
accession: &str,
alphabet: AlphabetMode,
kind: InsCoordKind,
provider: Option<&P>,
) -> Result<Option<String>, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
let InsertedSequence::Complex(parts) = seq else {
return Ok(None);
};
let mut resolved = String::new();
for part in parts {
resolved.push_str(&resolve_inserted_part(
part, accession, alphabet, kind, provider,
)?);
}
Ok(Some(resolved))
}
fn get_start_pos(interval: &Interval<GenomePos>) -> Option<u64> {
interval.start.inner().map(|p| p.base)
}
fn get_end_pos(interval: &Interval<GenomePos>) -> Option<u64> {
interval.end.inner().map(|p| p.base)
}
fn stated_positions(boundary: &UncertainBoundary<GenomePos>) -> [Option<&GenomePos>; 2] {
match boundary {
UncertainBoundary::Single(mu) => [mu.inner(), None],
UncertainBoundary::Range { start, end } => [start.inner(), end.inner()],
}
}
fn resolve_genomic_end_pos(
interval: &Interval<GenomePos>,
coord: &str,
) -> Result<u64, ConversionError> {
get_end_pos(interval).ok_or_else(|| ConversionError::InvalidPosition {
description: format!(
"{coord}. end boundary `{}` names no single coordinate: an SPDI position is an \
exact interbase coordinate, and an uncertain range or unknown (`?`) boundary \
supplies none. Collapsing it onto the start position would make distinct \
descriptions share one triple. Give the end a definite position, or keep the \
description in HGVS",
interval.end
),
})
}
fn reject_unresolvable_genomic_position(
interval: &Interval<GenomePos>,
coord: &str,
) -> Result<(), ConversionError> {
for endpoint in [&interval.start, &interval.end]
.into_iter()
.flat_map(stated_positions)
.flatten()
{
if endpoint.offset.is_some() {
return Err(ConversionError::InvalidPosition {
description: format!(
"{coord}. position {endpoint} carries a +/- offset: a genomic position \
cannot have one and SPDI has no offset notation to express it. Drop the \
offset, or describe the variant on a transcript accession where an offset \
is meaningful"
),
});
}
if let Some(special) = endpoint.special {
return Err(ConversionError::InvalidPosition {
description: format!(
"{coord}. position `{special}` names no numeric coordinate: `pter`, `qter` \
and `cen` are landmarks of the assembled chromosome, not positions on the \
sequence, and SPDI has no notation for them. Give the position a number, \
or keep the description in HGVS"
),
});
}
}
Ok(())
}
pub fn hgvs_to_spdi_simple(variant: &HgvsVariant) -> Result<SpdiVariant, ConversionError> {
match variant {
HgvsVariant::Genome(g) => genome_to_spdi_simple(g),
HgvsVariant::Mt(m) => mt_to_spdi_simple(m),
HgvsVariant::Circular(o) => circular_to_spdi_simple(o),
HgvsVariant::Tx(n) => tx_to_spdi_simple(n),
HgvsVariant::Rna(r) => rna_to_spdi_simple(r),
HgvsVariant::Cds(_) => Err(ConversionError::ProviderRequired {
variant_type: "c".to_string(),
reason:
"CDS positions need transcript metadata (CDS start) to resolve to a transcript \
position; call hgvs_to_spdi with a ReferenceProvider"
.to_string(),
}),
HgvsVariant::Protein(_) => Err(ConversionError::UnsupportedVariantType {
description: "protein variants cannot be represented in SPDI; SPDI describes \
nucleotide variants on a sequence accession"
.to_string(),
}),
_ => Err(ConversionError::UnsupportedVariantType {
description: format!(
"variant type {} cannot be converted to SPDI",
variant.variant_type()
),
}),
}
}
pub fn hgvs_to_spdi<P: ReferenceProvider + ?Sized>(
variant: &HgvsVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
match variant {
HgvsVariant::Genome(g) => genome_to_spdi_with_provider(g, provider),
HgvsVariant::Mt(m) => mt_to_spdi_with_provider(m, provider),
HgvsVariant::Circular(o) => circular_to_spdi_with_provider(o, provider),
HgvsVariant::Tx(n) => tx_to_spdi_with_provider(n, provider),
HgvsVariant::Rna(r) => rna_to_spdi_with_provider(r, provider),
HgvsVariant::Cds(c) => cds_to_spdi_with_provider(c, provider),
HgvsVariant::Protein(_) => Err(ConversionError::UnsupportedVariantType {
description: "protein variants cannot be represented in SPDI; SPDI describes \
nucleotide variants on a sequence accession"
.to_string(),
}),
_ => Err(ConversionError::UnsupportedVariantType {
description: format!(
"variant type {} cannot be converted to SPDI",
variant.variant_type()
),
}),
}
}
fn genome_to_spdi_simple(variant: &GenomeVariant) -> Result<SpdiVariant, ConversionError> {
reject_unresolvable_genomic_position(&variant.loc_edit.location, "g")?;
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = get_start_pos(&variant.loc_edit.location).ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert variant with unknown start position".to_string(),
}
})?;
let end_pos = resolve_genomic_end_pos(&variant.loc_edit.location, "g")?;
emit_spdi_for_edit(
variant.accession.to_string(),
start_pos,
end_pos,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
None::<&dyn ReferenceProvider>,
)
}
fn mt_to_spdi_simple(variant: &MtVariant) -> Result<SpdiVariant, ConversionError> {
reject_unresolvable_genomic_position(&variant.loc_edit.location, "m")?;
if let (Some(s), Some(e)) = (
get_start_pos(&variant.loc_edit.location),
get_end_pos(&variant.loc_edit.location),
) {
if s > e {
return Err(ConversionError::InvalidPosition {
description: format!(
"Cannot convert wraparound m. variant to SPDI: SPDI is a single edit and \
has no representation for circular-contig wraparound. Variant accession: {}",
variant.accession
),
});
}
}
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = get_start_pos(&variant.loc_edit.location).ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert variant with unknown start position".to_string(),
}
})?;
let end_pos = resolve_genomic_end_pos(&variant.loc_edit.location, "m")?;
emit_spdi_for_edit(
variant.accession.to_string(),
start_pos,
end_pos,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
None::<&dyn ReferenceProvider>,
)
}
fn transcript_axis_sequence(accession: &Accession) -> String {
accession.transcript_accession()
}
fn tx_to_spdi_simple(variant: &TxVariant) -> Result<SpdiVariant, ConversionError> {
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_tx = tx_pos_for_simple_path(&variant.loc_edit.location, "n")?;
let end_tx = tx_end_for_simple_path(&variant.loc_edit.location, "n")?;
emit_spdi_for_edit(
transcript_axis_sequence(&variant.accession),
start_tx,
end_tx,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
None::<&dyn ReferenceProvider>,
)
}
fn rna_to_spdi_simple(variant: &RnaVariant) -> Result<SpdiVariant, ConversionError> {
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = rna_pos_for_simple_path(&variant.loc_edit.location, "r")?;
let end_pos = rna_end_for_simple_path(&variant.loc_edit.location, "r")?;
emit_spdi_for_edit(
transcript_axis_sequence(&variant.accession),
start_pos,
end_pos,
edit,
AlphabetMode::Rna,
InsCoordKind::Rna,
None::<&dyn ReferenceProvider>,
)
}
fn genome_to_spdi_with_provider<P: ReferenceProvider + ?Sized>(
variant: &GenomeVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
reject_unresolvable_genomic_position(&variant.loc_edit.location, "g")?;
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = get_start_pos(&variant.loc_edit.location).ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert variant with unknown start position".to_string(),
}
})?;
let end_pos = resolve_genomic_end_pos(&variant.loc_edit.location, "g")?;
emit_spdi_for_edit(
variant.accession.to_string(),
start_pos,
end_pos,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
Some(provider),
)
}
fn mt_to_spdi_with_provider<P: ReferenceProvider + ?Sized>(
variant: &MtVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
reject_unresolvable_genomic_position(&variant.loc_edit.location, "m")?;
if let (Some(s), Some(e)) = (
get_start_pos(&variant.loc_edit.location),
get_end_pos(&variant.loc_edit.location),
) {
if s > e {
return Err(ConversionError::InvalidPosition {
description: format!(
"Cannot convert wraparound m. variant to SPDI: SPDI is a single edit and \
has no representation for circular-contig wraparound. Variant accession: {}",
variant.accession
),
});
}
}
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = get_start_pos(&variant.loc_edit.location).ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert variant with unknown start position".to_string(),
}
})?;
let end_pos = resolve_genomic_end_pos(&variant.loc_edit.location, "m")?;
emit_spdi_for_edit(
variant.accession.to_string(),
start_pos,
end_pos,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
Some(provider),
)
}
fn circular_to_spdi_simple(variant: &CircularVariant) -> Result<SpdiVariant, ConversionError> {
reject_unresolvable_genomic_position(&variant.loc_edit.location, "o")?;
if let (Some(s), Some(e)) = (
get_start_pos(&variant.loc_edit.location),
get_end_pos(&variant.loc_edit.location),
) {
if s > e {
return Err(ConversionError::InvalidPosition {
description: format!(
"Cannot convert wraparound o. variant to SPDI: SPDI is a single edit and \
has no representation for circular-contig wraparound. Variant accession: {}",
variant.accession
),
});
}
}
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = get_start_pos(&variant.loc_edit.location).ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert variant with unknown start position".to_string(),
}
})?;
let end_pos = resolve_genomic_end_pos(&variant.loc_edit.location, "o")?;
emit_spdi_for_edit(
variant.accession.to_string(),
start_pos,
end_pos,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
None::<&dyn ReferenceProvider>,
)
}
fn circular_to_spdi_with_provider<P: ReferenceProvider + ?Sized>(
variant: &CircularVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
reject_unresolvable_genomic_position(&variant.loc_edit.location, "o")?;
if let (Some(s), Some(e)) = (
get_start_pos(&variant.loc_edit.location),
get_end_pos(&variant.loc_edit.location),
) {
if s > e {
return Err(ConversionError::InvalidPosition {
description: format!(
"Cannot convert wraparound o. variant to SPDI: SPDI is a single edit and \
has no representation for circular-contig wraparound. Variant accession: {}",
variant.accession
),
});
}
}
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_pos = get_start_pos(&variant.loc_edit.location).ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert variant with unknown start position".to_string(),
}
})?;
let end_pos = resolve_genomic_end_pos(&variant.loc_edit.location, "o")?;
emit_spdi_for_edit(
variant.accession.to_string(),
start_pos,
end_pos,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
Some(provider),
)
}
fn cds_to_spdi_with_provider<P: ReferenceProvider + ?Sized>(
variant: &CdsVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let start_cds = variant.loc_edit.location.start.inner().ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert c. variant with unknown start position".to_string(),
}
})?;
let end_cds = resolve_transcript_end_boundary(&variant.loc_edit.location, "c")?;
let (start_tx, end_tx) = resolve_cds_to_tx(&variant.accession, start_cds, end_cds, provider)?;
emit_spdi_for_edit(
transcript_axis_sequence(&variant.accession),
start_tx,
end_tx,
edit,
AlphabetMode::Dna,
InsCoordKind::Cds,
Some(provider),
)
}
fn tx_to_spdi_with_provider<P: ReferenceProvider + ?Sized>(
variant: &TxVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let (start_tx, end_tx) = if tx_needs_provider(&variant.loc_edit.location) {
let start = variant.loc_edit.location.start.inner().ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert n. variant with unknown start position".to_string(),
}
})?;
let end = resolve_transcript_end_boundary(&variant.loc_edit.location, "n")?;
resolve_tx_to_provider_tx(&variant.accession, start, end, provider)?
} else {
resolve_tx_exonic_bounded(&variant.accession, &variant.loc_edit.location, provider)?
};
emit_spdi_for_edit(
transcript_axis_sequence(&variant.accession),
start_tx,
end_tx,
edit,
AlphabetMode::Dna,
InsCoordKind::Direct,
Some(provider),
)
}
fn rna_to_spdi_with_provider<P: ReferenceProvider + ?Sized>(
variant: &RnaVariant,
provider: &P,
) -> Result<SpdiVariant, ConversionError> {
let edit = unwrap_edit(&variant.loc_edit.edit)?;
let (start_tx, end_tx) = if rna_needs_provider(&variant.loc_edit.location) {
let start = variant.loc_edit.location.start.inner().ok_or_else(|| {
ConversionError::InvalidPosition {
description: "cannot convert r. variant with unknown start position".to_string(),
}
})?;
let end = resolve_transcript_end_boundary(&variant.loc_edit.location, "r")?;
resolve_rna_to_provider_tx(&variant.accession, start, end, provider)?
} else {
resolve_rna_exonic_bounded(&variant.accession, &variant.loc_edit.location, provider)?
};
emit_spdi_for_edit(
transcript_axis_sequence(&variant.accession),
start_tx,
end_tx,
edit,
AlphabetMode::Rna,
InsCoordKind::Rna,
Some(provider),
)
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum AlphabetMode {
Dna,
Rna,
}
fn unwrap_edit<E>(edit: &crate::hgvs::uncertainty::Mu<E>) -> Result<&E, ConversionError> {
edit.inner()
.ok_or_else(|| ConversionError::InvalidPosition {
description: "cannot convert variant with unknown edit".to_string(),
})
}
fn resolve_transcript_end_boundary<'a, T: std::fmt::Display>(
interval: &'a Interval<T>,
coord: &str,
) -> Result<&'a T, ConversionError> {
interval
.end
.inner()
.ok_or_else(|| ConversionError::InvalidPosition {
description: format!(
"{coord}. interval end `{}` names no single coordinate: an end that is a range \
`(a_b)` or unknown `?` states where the variant ends only approximately, and an \
SPDI triple ends at one exact position. Substituting the start would describe a \
different variant — one ending where it begins. Give the end a position, or keep \
the description in HGVS",
interval.end
),
})
}
fn tx_pos_for_simple_path(interval: &Interval<TxPos>, coord: &str) -> Result<u64, ConversionError> {
let start = interval
.start
.inner()
.ok_or_else(|| ConversionError::InvalidPosition {
description: format!(
"cannot convert {}. variant with unknown start position",
coord
),
})?;
require_simple_tx_pos(start, coord)
}
fn tx_end_for_simple_path(interval: &Interval<TxPos>, coord: &str) -> Result<u64, ConversionError> {
require_simple_tx_pos(resolve_transcript_end_boundary(interval, coord)?, coord)
}
fn require_simple_tx_pos(pos: &TxPos, coord: &str) -> Result<u64, ConversionError> {
if pos.is_intronic() {
return Err(ConversionError::UnrepresentableInSpdi {
description: format!(
"intronic {}. position cannot be expressed in SPDI without genomic projection; \
SPDI is positional and has no offset notation",
coord
),
});
}
if pos.is_downstream() {
return Err(ConversionError::MissingReferenceData {
description: format!(
"downstream {}. position (*N) requires reference provider with transcript length",
coord
),
});
}
if pos.base < 1 {
return Err(ConversionError::MissingReferenceData {
description: format!(
"non-positive {}. position {} requires reference provider with transcript length",
coord, pos.base
),
});
}
Ok(pos.base as u64)
}
fn rna_pos_for_simple_path(
interval: &Interval<RnaPos>,
coord: &str,
) -> Result<u64, ConversionError> {
let start = interval
.start
.inner()
.ok_or_else(|| ConversionError::InvalidPosition {
description: format!(
"cannot convert {}. variant with unknown start position",
coord
),
})?;
require_simple_rna_pos(start, coord)
}
fn rna_end_for_simple_path(
interval: &Interval<RnaPos>,
coord: &str,
) -> Result<u64, ConversionError> {
require_simple_rna_pos(resolve_transcript_end_boundary(interval, coord)?, coord)
}
fn require_simple_rna_pos(pos: &RnaPos, coord: &str) -> Result<u64, ConversionError> {
if pos.is_intronic() {
return Err(ConversionError::UnrepresentableInSpdi {
description: format!(
"intronic {}. position cannot be expressed in SPDI without genomic projection; \
SPDI is positional and has no offset notation",
coord
),
});
}
if pos.utr3 {
return Err(ConversionError::MissingReferenceData {
description: format!(
"3' UTR {}. position (*N) requires reference provider with transcript length",
coord
),
});
}
if pos.base < 1 {
return Err(ConversionError::MissingReferenceData {
description: format!(
"non-positive {}. position {} requires reference provider with transcript length",
coord, pos.base
),
});
}
Ok(pos.base as u64)
}
fn tx_needs_provider(interval: &Interval<TxPos>) -> bool {
let needs = |p: &TxPos| p.is_intronic() || p.is_downstream() || p.base < 1;
interval.start.inner().is_some_and(needs) || interval.end.inner().is_some_and(needs)
}
fn rna_needs_provider(interval: &Interval<RnaPos>) -> bool {
let needs = |p: &RnaPos| p.is_intronic() || p.utr3 || p.base < 1;
interval.start.inner().is_some_and(needs) || interval.end.inner().is_some_and(needs)
}
fn resolve_cds_to_tx<P: ReferenceProvider + ?Sized>(
accession: &Accession,
start: &CdsPos,
end: &CdsPos,
provider: &P,
) -> Result<(u64, u64), ConversionError> {
if start.is_intronic() || end.is_intronic() {
return Err(ConversionError::UnrepresentableInSpdi {
description: "intronic c. positions cannot be expressed in SPDI without genomic \
projection; SPDI is positional and has no offset notation"
.to_string(),
});
}
let tx_id = accession.transcript_accession();
let transcript =
provider
.get_transcript(&tx_id)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!("could not load transcript {}: {}", tx_id, e),
})?;
let mapper = CoordinateMapper::new(&transcript);
let s = mapper
.cds_to_tx(start)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!("could not resolve {} to transcript position: {}", start, e),
})?;
let e = mapper
.cds_to_tx(end)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!("could not resolve {} to transcript position: {}", end, e),
})?;
let tx_len = transcript.sequence_length();
let s_u = ensure_tx_in_bounds(s.base, tx_len, "c", start)?;
let e_u = ensure_tx_in_bounds(e.base, tx_len, "c", end)?;
Ok((s_u, e_u))
}
fn resolve_tx_to_provider_tx<P: ReferenceProvider + ?Sized>(
accession: &Accession,
start: &TxPos,
end: &TxPos,
provider: &P,
) -> Result<(u64, u64), ConversionError> {
let tx_id = accession.transcript_accession();
let transcript =
provider
.get_transcript(&tx_id)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!("could not load transcript {}: {}", tx_id, e),
})?;
let s = resolve_tx_pos(start, &transcript)?;
let e = resolve_tx_pos(end, &transcript)?;
Ok((s, e))
}
fn resolve_tx_exonic_bounded<P: ReferenceProvider + ?Sized>(
accession: &Accession,
interval: &Interval<TxPos>,
provider: &P,
) -> Result<(u64, u64), ConversionError> {
let start = interval
.start
.inner()
.ok_or_else(|| ConversionError::InvalidPosition {
description: "cannot convert n. variant with unknown start position".to_string(),
})?;
let end = resolve_transcript_end_boundary(interval, "n")?;
match resolve_tx_to_provider_tx(accession, start, end, provider) {
Ok(pair) => Ok(pair),
Err(ConversionError::MissingReferenceData { .. }) => {
let s = tx_pos_for_simple_path(interval, "n")?;
let e = tx_end_for_simple_path(interval, "n")?;
Ok((s, e))
}
Err(e) => Err(e),
}
}
fn resolve_rna_exonic_bounded<P: ReferenceProvider + ?Sized>(
accession: &Accession,
interval: &Interval<RnaPos>,
provider: &P,
) -> Result<(u64, u64), ConversionError> {
let start = interval
.start
.inner()
.ok_or_else(|| ConversionError::InvalidPosition {
description: "cannot convert r. variant with unknown start position".to_string(),
})?;
let end = resolve_transcript_end_boundary(interval, "r")?;
match resolve_rna_to_provider_tx(accession, start, end, provider) {
Ok(pair) => Ok(pair),
Err(ConversionError::MissingReferenceData { .. }) => {
let s = rna_pos_for_simple_path(interval, "r")?;
let e = rna_end_for_simple_path(interval, "r")?;
Ok((s, e))
}
Err(e) => Err(e),
}
}
fn resolve_rna_to_provider_tx<P: ReferenceProvider + ?Sized>(
accession: &Accession,
start: &RnaPos,
end: &RnaPos,
provider: &P,
) -> Result<(u64, u64), ConversionError> {
let tx_id = accession.transcript_accession();
let transcript =
provider
.get_transcript(&tx_id)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!("could not load transcript {}: {}", tx_id, e),
})?;
let s = resolve_rna_pos(start, &transcript)?;
let e = resolve_rna_pos(end, &transcript)?;
Ok((s, e))
}
fn resolve_tx_pos(pos: &TxPos, transcript: &Transcript) -> Result<u64, ConversionError> {
if pos.is_intronic() {
return Err(ConversionError::UnrepresentableInSpdi {
description: format!(
"intronic n.{} cannot be expressed in SPDI without genomic projection; \
SPDI is positional and has no offset notation",
pos
),
});
}
if pos.is_downstream() {
return Err(ConversionError::InvalidPosition {
description: format!(
"downstream n.{} cannot be expressed in SPDI on the transcript accession \
without genomic projection",
pos
),
});
}
ensure_tx_in_bounds(pos.base, transcript.sequence_length(), "n", pos)
}
fn resolve_rna_pos(pos: &RnaPos, transcript: &Transcript) -> Result<u64, ConversionError> {
if pos.is_intronic() {
return Err(ConversionError::UnrepresentableInSpdi {
description: format!(
"intronic r.{} cannot be expressed in SPDI without genomic projection; \
SPDI is positional and has no offset notation",
pos
),
});
}
if pos.utr3 {
if pos.base < 1 {
return Err(ConversionError::InvalidPosition {
description: format!("3' UTR position *{} must be >= 1", pos.base),
});
}
if transcript.cds_end.is_none() {
return Err(ConversionError::MissingReferenceData {
description: format!(
"r.*{} requires a CDS end on the transcript; non-coding \
transcripts have no 3'UTR anchor",
pos.base
),
});
}
let mapper = CoordinateMapper::new(transcript);
let tx = mapper.cds_to_tx(&CdsPos::utr3(pos.base)).map_err(|e| {
ConversionError::MissingReferenceData {
description: format!(
"could not resolve r.*{} to transcript position: {}",
pos.base, e
),
}
})?;
return ensure_tx_in_bounds(tx.base, transcript.sequence_length(), "r", pos);
}
if pos.is_5utr() {
if transcript.cds_start.is_none() {
return Err(ConversionError::MissingReferenceData {
description: format!(
"r.{} requires a CDS start on the transcript; non-coding \
transcripts have no 5'UTR anchor",
pos.base
),
});
}
let mapper = CoordinateMapper::new(transcript);
let tx = mapper.cds_to_tx(&CdsPos::new(pos.base)).map_err(|e| {
ConversionError::MissingReferenceData {
description: format!(
"could not resolve r.{} to transcript position: {}",
pos.base, e
),
}
})?;
return ensure_positive_tx(tx.base, "r", pos);
}
if transcript.cds_start.is_some() {
if transcript.cds_end.is_none() {
return Err(ConversionError::InvalidPosition {
description: format!(
"r.{} on a coding transcript requires a CDS end to resolve \
CDS-relative; the transcript has a CDS start but no CDS end",
pos.base
),
});
}
let mapper = CoordinateMapper::new(transcript);
let tx = mapper.cds_to_tx(&CdsPos::new(pos.base)).map_err(|e| {
ConversionError::MissingReferenceData {
description: format!(
"could not resolve r.{} to transcript position: {}",
pos.base, e
),
}
})?;
return ensure_tx_in_bounds(tx.base, transcript.sequence_length(), "r", pos);
}
ensure_tx_in_bounds(pos.base, transcript.sequence_length(), "r", pos)
}
fn ensure_positive_tx<P: std::fmt::Display>(
base: i64,
coord: &str,
pos: P,
) -> Result<u64, ConversionError> {
if base < 1 {
return Err(ConversionError::InvalidPosition {
description: format!(
"transcript position from {}. coordinate {} resolves to a non-positive base ({})",
coord, pos, base
),
});
}
Ok(base as u64)
}
fn ensure_tx_in_bounds<P: std::fmt::Display + Copy>(
base: i64,
tx_len: u64,
coord: &str,
pos: P,
) -> Result<u64, ConversionError> {
let one_based = ensure_positive_tx(base, coord, pos)?;
if one_based > tx_len {
return Err(ConversionError::InvalidPosition {
description: format!(
"transcript position from {}. coordinate {} resolves to base {} past the \
transcript 3' end (length {})",
coord, pos, one_based, tx_len
),
});
}
Ok(one_based)
}
fn emit_spdi_for_edit<P>(
sequence: String,
start_one_based: u64,
end_one_based: u64,
edit: &NaEdit,
alphabet: AlphabetMode,
ins_coords: InsCoordKind,
provider: Option<&P>,
) -> Result<SpdiVariant, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
let hgvs_pos_ob =
OneBasedPos::try_new(start_one_based).ok_or_else(|| ConversionError::InvalidPosition {
description: "position 0 is not valid in HGVS".to_string(),
})?;
let spdi_pos_zb: ZeroBasedPos = hgvs_pos_ob.to_zero_based();
let spdi_pos = spdi_pos_zb.value();
match edit {
NaEdit::Substitution {
reference,
alternative,
} => Ok(SpdiVariant::new(
sequence,
spdi_pos,
apply_alphabet(&reference.to_string(), alphabet),
apply_alphabet(&alternative.to_string(), alphabet),
)),
NaEdit::Insertion { sequence: inserted } => {
let ins_str = if let Some(resolved) =
resolve_position_range_insert(inserted, &sequence, alphabet, ins_coords, provider)?
{
resolved
} else if let Some(resolved) = resolve_exact_repeat_insert(inserted, alphabet)? {
resolved
} else if let Some(resolved) =
resolve_complex_insert(inserted, &sequence, alphabet, ins_coords, provider)?
{
resolved
} else {
let literal = inserted_sequence_to_string(inserted).ok_or_else(|| {
ConversionError::MissingReferenceData {
description: "insertion sequence is neither a literal sequence, a \
same-reference position range, nor an exact tandem repeat; \
this shape is not yet encodable as SPDI"
.to_string(),
}
})?;
apply_alphabet(&literal, alphabet)
};
Ok(SpdiVariant::new(sequence, start_one_based, "", ins_str))
}
NaEdit::Duplication {
sequence: dup_seq, ..
} => {
let dup_str = match dup_seq {
Some(seq) => sequence_to_string(seq),
None => match provider {
Some(p) => fetch_reference_bases(p, &sequence, start_one_based, end_one_based)?,
None => {
return Err(unspelled_bases_error(
UnspelledBases::Duplicated,
start_one_based,
end_one_based,
));
}
},
};
Ok(SpdiVariant::new(
sequence,
end_one_based,
"",
apply_alphabet(&dup_str, alphabet),
))
}
NaEdit::Deletion {
sequence: del_seq, ..
} => {
let del_str = match del_seq {
Some(seq) => sequence_to_string(seq),
None => match provider {
Some(p) => fetch_reference_bases(p, &sequence, start_one_based, end_one_based)?,
None => {
return Err(unspelled_bases_error(
UnspelledBases::Deleted,
start_one_based,
end_one_based,
));
}
},
};
Ok(SpdiVariant::new(
sequence,
spdi_pos,
apply_alphabet(&del_str, alphabet),
"",
))
}
NaEdit::Delins {
sequence: ins_seq,
deleted,
deleted_length: _,
substitution_reference: None,
} => {
let ins_str = if let Some(resolved) =
resolve_position_range_insert(ins_seq, &sequence, alphabet, ins_coords, provider)?
{
resolved
} else if let Some(resolved) = resolve_exact_repeat_insert(ins_seq, alphabet)? {
resolved
} else if let Some(resolved) =
resolve_complex_insert(ins_seq, &sequence, alphabet, ins_coords, provider)?
{
resolved
} else {
let literal = inserted_sequence_to_string(ins_seq).ok_or_else(|| {
ConversionError::UnsupportedEditType {
description: "delins inserted sequence is neither a literal sequence, a \
same-reference position range, nor an exact tandem repeat; \
this shape is not yet encodable as SPDI"
.to_string(),
}
})?;
apply_alphabet(&literal, alphabet)
};
let del_str = match deleted {
Some(seq) => sequence_to_string(seq),
None => match provider {
Some(p) => fetch_reference_bases(p, &sequence, start_one_based, end_one_based)?,
None => {
return Err(unspelled_bases_error(
UnspelledBases::DeletedInDelins,
start_one_based,
end_one_based,
));
}
},
};
Ok(SpdiVariant::new(
sequence,
spdi_pos,
apply_alphabet(&del_str, alphabet),
ins_str,
))
}
NaEdit::Identity {
sequence: id_seq,
whole_entity,
} => {
let ref_base = match id_seq {
Some(seq) => sequence_to_string(seq),
None if *whole_entity => {
return Err(ConversionError::UnsupportedEditType {
description: "a whole-entity identity (`=`) asserts the entire \
reference is unchanged and names no interval; SPDI \
has no representation for it"
.to_string(),
});
}
None => match provider {
Some(p) => fetch_reference_bases(p, &sequence, start_one_based, end_one_based)?,
None => {
return Err(unspelled_bases_error(
UnspelledBases::Unchanged,
start_one_based,
end_one_based,
));
}
},
};
let ref_base = apply_alphabet(&ref_base, alphabet);
Ok(SpdiVariant::new(
sequence,
spdi_pos,
ref_base.clone(),
ref_base,
))
}
NaEdit::Inversion {
sequence: inv_seq, ..
} => {
let del_raw = match inv_seq {
Some(seq) => sequence_to_string(seq),
None => match provider {
Some(p) => fetch_reference_bases(p, &sequence, start_one_based, end_one_based)?,
None => {
return Err(unspelled_bases_error(
UnspelledBases::Inverted,
start_one_based,
end_one_based,
));
}
},
};
let del_str = apply_alphabet(&del_raw, alphabet);
let ins_str = reverse_complement(&del_str);
Ok(SpdiVariant::new(sequence, spdi_pos, del_str, ins_str))
}
NaEdit::Repeat {
sequence: unit_seq,
count,
additional_counts,
trailing,
} => {
if !additional_counts.is_empty() {
return Err(ConversionError::UnsupportedEditType {
description:
"genotype-style repeat (multiple counts) cannot be expressed as a single SPDI; emit each allele separately"
.to_string(),
});
}
if trailing.is_some() {
return Err(ConversionError::UnsupportedEditType {
description: "repeat with trailing sequence cannot be represented in SPDI"
.to_string(),
});
}
let unit = unit_seq
.as_ref()
.ok_or_else(|| ConversionError::MissingReferenceData {
description:
"repeat unit sequence not provided; cannot expand into SPDI delins"
.to_string(),
})?;
let n_post = match count {
RepeatCount::Exact(n) => *n as usize,
_ => {
return Err(ConversionError::UnsupportedEditType {
description:
"uncertain or range repeat counts cannot be represented in SPDI"
.to_string(),
});
}
};
let unit_str = apply_alphabet(&sequence_to_string(unit), alphabet);
refuse_undetermined_repeat_unit(&unit_str)?;
let expansion_bases = unit_str.len().checked_mul(n_post).ok_or_else(|| {
ConversionError::UnsupportedEditType {
description: format!(
"repeat expansion {} x {} overflows usize",
unit_str.len(),
n_post
),
}
})?;
if expansion_bases > MAX_REPEAT_EXPANSION_BASES {
return Err(ConversionError::UnsupportedEditType {
description: format!(
"repeat expansion {} bases exceeds SPDI ins-string cap of {} bases",
expansion_bases, MAX_REPEAT_EXPANSION_BASES
),
});
}
let (del_start, del_end, span_origin) = match provider {
Some(p) if start_one_based == end_one_based => resolve_repeat_tract_span(
p,
&sequence,
start_one_based,
unit_str.as_bytes(),
alphabet,
)?,
_ => (start_one_based, end_one_based, RepeatSpanOrigin::Tract),
};
let spdi_pos = del_start - 1;
let del_str = match provider {
Some(p) => {
fetch_normalized_reference_bases(p, &sequence, del_start, del_end, alphabet)?
}
None => {
return Err(unspelled_bases_error(
UnspelledBases::RepeatTract,
start_one_based,
end_one_based,
));
}
};
let divisible = !unit_str.is_empty() && del_str.len().is_multiple_of(unit_str.len());
let matches_unit =
divisible && del_str == unit_str.repeat(del_str.len() / unit_str.len());
if !matches_unit {
return Err(ConversionError::InvalidPosition {
description: match span_origin {
RepeatSpanOrigin::NoRunAtAnchor => format!(
"no {unit} repeat is anchored at {seq}:{anchor}: the reference \
there is not a run of {unit}. A single-position anchor names the \
*first base* of the tract, so check the run's start, or spell the \
whole range explicitly (`g.<start>_<end>{unit}[n]`).",
unit = unit_str,
seq = sequence,
anchor = start_one_based,
),
_ if !divisible => format!(
"repeat span {}:{}-{} length {} is not a multiple of unit length {}",
sequence,
del_start,
del_end,
del_str.len(),
unit_str.len()
),
_ => format!(
"repeat span {}:{}-{} does not match repeat unit {}",
sequence, del_start, del_end, unit_str
),
},
});
}
let ins_str = unit_str.repeat(n_post);
Ok(SpdiVariant::new(sequence, spdi_pos, del_str, ins_str))
}
NaEdit::CopyNumber { .. } => Err(ConversionError::UnsupportedEditType {
description: "copy number variants cannot be represented in SPDI format".to_string(),
}),
NaEdit::Conversion { .. } => Err(ConversionError::UnsupportedEditType {
description: "conversion variants cannot be represented in SPDI format".to_string(),
}),
_ => Err(ConversionError::UnsupportedEditType {
description: format!("unsupported edit type: {:?}", edit),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum UnspelledBases {
Duplicated,
Deleted,
DeletedInDelins,
Unchanged,
Inverted,
RepeatTract,
}
impl UnspelledBases {
fn adjective(self) -> &'static str {
match self {
Self::Duplicated => "duplicated",
Self::Deleted | Self::DeletedInDelins => "deleted",
Self::Unchanged => "unchanged",
Self::Inverted => "inverted",
Self::RepeatTract => "pre-expansion repeat-tract",
}
}
fn spelled_example(self) -> Option<&'static str> {
match self {
Self::Duplicated => Some("g.10_12dupACG"),
Self::Deleted => Some("g.10_12delACG"),
Self::DeletedInDelins => Some("g.10_12delACGinsT"),
Self::Unchanged => Some("g.10A="),
Self::Inverted => Some("g.10_12invACG"),
Self::RepeatTract => None,
}
}
}
fn unspelled_bases_error(
what: UnspelledBases,
start_one_based: u64,
end_one_based: u64,
) -> ConversionError {
let remedy = match what.spelled_example() {
Some(example) => format!(
"Spell them in the description (e.g. `{example}`) or convert with a reference provider."
),
None => "Convert with a reference provider: the repeat notation states the unit, \
not the span its tract currently occupies, so the bases cannot be spelled \
in the description."
.to_string(),
};
ConversionError::MissingReferenceData {
description: format!(
"cannot convert to SPDI: the {} bases at {}..={} are unknown (no reference data). \
{remedy}",
what.adjective(),
start_one_based,
end_one_based,
),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RepeatSpanOrigin {
Tract,
NoRunAtAnchor,
NotSearched,
}
fn resolve_repeat_tract_span<P>(
provider: &P,
accession: &str,
anchor_one_based: u64,
unit: &[u8],
alphabet: AlphabetMode,
) -> Result<(u64, u64, RepeatSpanOrigin), ConversionError>
where
P: ReferenceProvider + ?Sized,
{
const INITIAL_HALF_WIDTH: u64 = 128;
if unit.is_empty() {
return Ok((
anchor_one_based,
anchor_one_based,
RepeatSpanOrigin::NotSearched,
));
}
let unit_len = unit.len() as u64;
let fallback = (
anchor_one_based,
anchor_one_based.saturating_add(unit_len - 1),
);
let Ok(sequence_length) = provider.get_sequence_length(accession) else {
return Err(ConversionError::UnsupportedEditType {
description: format!(
"cannot resolve the repeat tract at {accession}:{anchor_one_based}: the \
reference provider could not report the length of {accession}, so the run's \
extent could not be verified; spell the whole range explicitly \
(`g.<start>_<end>{}[n]`) rather than the start alone",
String::from_utf8_lossy(unit)
),
});
};
if sequence_length == 0 || anchor_one_based > sequence_length {
return Ok((fallback.0, fallback.1, RepeatSpanOrigin::NotSearched));
}
let fallback = (fallback.0, fallback.1.min(sequence_length));
let mut half_width = INITIAL_HALF_WIDTH;
loop {
let window_start = anchor_one_based.saturating_sub(half_width).max(1);
let window_end = anchor_one_based
.saturating_add(half_width)
.min(sequence_length);
let window = fetch_normalized_reference_bases(
provider,
accession,
window_start,
window_end,
alphabet,
)?;
let bytes = window.as_bytes();
let anchor_offset = (anchor_one_based - window_start) as usize;
let Some((_, tract_start, tract_end)) =
crate::normalize::rules::count_tandem_repeats(bytes, anchor_offset, unit)
else {
return Ok((fallback.0, fallback.1, RepeatSpanOrigin::NoRunAtAnchor));
};
let open_at_start = tract_start < unit.len() && window_start > 1;
let open_at_end = tract_end + unit.len() > bytes.len() && window_end < sequence_length;
if !open_at_start && !open_at_end {
return Ok((
window_start + tract_start as u64,
window_start + tract_end as u64 - 1,
RepeatSpanOrigin::Tract,
));
}
if half_width.saturating_mul(2).saturating_add(1) >= MAX_REPEAT_SEARCH_BASES {
return Err(ConversionError::UnsupportedEditType {
description: format!(
"repeat tract at {accession}:{anchor_one_based} still extends past a \
{MAX_REPEAT_SEARCH_BASES}-base search window; spell the whole range \
explicitly (`g.<start>_<end>{}[n]`) rather than the start alone",
String::from_utf8_lossy(unit)
),
});
}
half_width = half_width.saturating_mul(2);
}
}
fn fetch_normalized_reference_bases<P>(
provider: &P,
accession: &str,
start_one_based: u64,
end_one_based: u64,
alphabet: AlphabetMode,
) -> Result<String, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
let raw = fetch_reference_bases(provider, accession, start_one_based, end_one_based)?;
let normalized = apply_alphabet(&raw, alphabet);
debug_assert_eq!(
normalized.len(),
raw.len(),
"alphabet normalization must preserve byte length"
);
Ok(normalized)
}
fn fetch_reference_bases<P>(
provider: &P,
accession: &str,
start_one_based: u64,
end_one_based: u64,
) -> Result<String, ConversionError>
where
P: ReferenceProvider + ?Sized,
{
if start_one_based < 1 || end_one_based < start_one_based {
return Err(ConversionError::InvalidPosition {
description: format!(
"invalid 1-based interval [{}, {}] for reference fetch",
start_one_based, end_one_based
),
});
}
let zb_start = start_one_based - 1;
let zb_end = end_one_based;
let expected_len = (zb_end - zb_start) as usize;
let bases = match provider.get_genomic_sequence(accession, zb_start, zb_end) {
Ok(s) => s,
Err(_) => provider
.get_sequence(accession, zb_start, zb_end)
.map_err(|e| ConversionError::MissingReferenceData {
description: format!(
"could not fetch reference for {}:{}-{}: {}",
accession, start_one_based, end_one_based, e
),
})?,
};
if bases.len() != expected_len {
return Err(ConversionError::MissingReferenceData {
description: format!(
"reference fetch for {}:{}-{} returned {} bases, expected {}",
accession,
start_one_based,
end_one_based,
bases.len(),
expected_len
),
});
}
Ok(bases)
}
pub(crate) fn apply_alphabet(s: &str, alphabet: AlphabetMode) -> String {
match alphabet {
AlphabetMode::Dna => s.to_ascii_uppercase(),
AlphabetMode::Rna => s
.chars()
.map(|c| match c.to_ascii_uppercase() {
'U' => 'T',
other => other,
})
.collect(),
}
}
pub fn spdi_to_hgvs(spdi: &SpdiVariant) -> Result<HgvsVariant, ConversionError> {
let accession = parse_accession(&spdi.sequence)
.map(|(_, acc)| acc)
.map_err(|_| ConversionError::InvalidAccession {
description: format!("could not parse accession: {}", spdi.sequence),
})?;
let spdi_pos_zb = ZeroBasedPos::new(spdi.position);
let hgvs_pos_ob = spdi_pos_zb.to_one_based();
let hgvs_pos = hgvs_pos_ob.value();
let (interval, edit) = if spdi.is_identity() {
if spdi.deletion.is_empty() {
return Err(ConversionError::UnsupportedEditType {
description: "a zero-width SPDI triple names no bases, so it cannot be \
converted to an identity over an interval"
.to_string(),
});
}
let seq = Some(string_to_sequence(&spdi.deletion)?);
let del_len = spdi.deletion.len();
let interval = if del_len > 1 {
Interval::new(
GenomePos::new(hgvs_pos),
GenomePos::new(hgvs_pos + del_len as u64 - 1),
)
} else {
Interval::point(GenomePos::new(hgvs_pos))
};
(
interval,
NaEdit::Identity {
sequence: seq,
whole_entity: false,
},
)
} else if spdi.deletion.len() == 1 && spdi.insertion.len() == 1 {
let ref_base = char_to_base(spdi.deletion.chars().next().unwrap())?;
let alt_base = char_to_base(spdi.insertion.chars().next().unwrap())?;
(
Interval::point(GenomePos::new(hgvs_pos)),
NaEdit::Substitution {
reference: ref_base,
alternative: alt_base,
},
)
} else if spdi.is_deletion() {
let del_len = spdi.deletion.len();
let del_seq = string_to_sequence(&spdi.deletion)?;
let interval = if del_len > 1 {
Interval::new(
GenomePos::new(hgvs_pos),
GenomePos::new(hgvs_pos + del_len as u64 - 1),
)
} else {
Interval::point(GenomePos::new(hgvs_pos))
};
(
interval,
NaEdit::Deletion {
sequence: Some(del_seq),
length: None,
},
)
} else if spdi.is_insertion() {
if spdi.position == 0 {
return Err(ConversionError::InvalidPosition {
description: "SPDI position 0 represents an insertion before the \
first base, which has no HGVS notation"
.to_string(),
});
}
let ins_seq = string_to_sequence(&spdi.insertion)?;
(
Interval::new(
GenomePos::new(spdi.position),
GenomePos::new(spdi.position + 1),
),
NaEdit::Insertion {
sequence: InsertedSequence::Literal(ins_seq),
},
)
} else if !spdi.deletion.is_empty()
&& spdi.deletion.len() == spdi.insertion.len()
&& spdi.deletion.len() >= 2
&& reverse_complement(&spdi.deletion).eq_ignore_ascii_case(&spdi.insertion)
{
let inv_seq = string_to_sequence(&spdi.deletion)?;
let del_len = spdi.deletion.len();
let interval = Interval::new(
GenomePos::new(hgvs_pos),
GenomePos::new(hgvs_pos + del_len as u64 - 1),
);
(
interval,
NaEdit::Inversion {
sequence: Some(inv_seq),
length: None,
},
)
} else {
let del_len = spdi.deletion.len();
let ins_seq = string_to_sequence(&spdi.insertion)?;
let interval = if del_len > 1 {
Interval::new(
GenomePos::new(hgvs_pos),
GenomePos::new(hgvs_pos + del_len as u64 - 1),
)
} else {
Interval::point(GenomePos::new(hgvs_pos))
};
(
interval,
NaEdit::Delins {
sequence: InsertedSequence::Literal(ins_seq),
deleted: None,
deleted_length: None,
substitution_reference: None,
},
)
};
if is_mitochondrial_accession(&accession) {
return Ok(HgvsVariant::Mt(MtVariant {
accession,
gene_symbol: None,
loc_edit: LocEdit::new(interval, edit),
}));
}
Ok(HgvsVariant::Genome(GenomeVariant {
accession,
gene_symbol: None,
loc_edit: LocEdit::new(interval, edit),
}))
}
fn is_mitochondrial_accession(accession: &Accession) -> bool {
accession.is_mitochondrial()
}
pub fn spdi_to_hgvs_with_ref<R>(
spdi: &SpdiVariant,
reference: &R,
) -> Result<HgvsVariant, ConversionError>
where
R: crate::reference::provider::ReferenceProvider + ?Sized,
{
let base = spdi_to_hgvs(spdi)?;
if !spdi.is_insertion() {
return Ok(base);
}
if let Some(dup_variant) = recover_dup_from_insertion(spdi, reference, &base)? {
return Ok(dup_variant);
}
Ok(base)
}
fn recover_dup_from_insertion<R>(
spdi: &SpdiVariant,
reference: &R,
base: &HgvsVariant,
) -> Result<Option<HgvsVariant>, ConversionError>
where
R: crate::reference::provider::ReferenceProvider + ?Sized,
{
debug_assert!(spdi.is_insertion());
let ins = &spdi.insertion;
let ins_len = ins.len() as u64;
if ins_len == 0 {
return Ok(None);
}
let flank_end = spdi.position;
if flank_end < ins_len {
return Ok(None);
}
let flank_start = flank_end - ins_len;
let flank = match reference.get_genomic_sequence(&spdi.sequence, flank_start, flank_end) {
Ok(s) => s,
Err(_) => match reference.get_sequence(&spdi.sequence, flank_start, flank_end) {
Ok(s) => s,
Err(e) => {
return Err(ConversionError::MissingReferenceData {
description: format!(
"could not fetch 5' flank for {}:{}-{}: {}",
spdi.sequence, flank_start, flank_end, e
),
});
}
},
};
if flank.len() as u64 != ins_len {
return Ok(None);
}
if !flank.eq_ignore_ascii_case(ins) {
return Ok(None);
}
let end_one_based = flank_end; let start_one_based = end_one_based + 1 - ins_len;
let dup_seq = string_to_sequence(ins)?;
let interval = if ins_len == 1 {
Interval::point(GenomePos::new(end_one_based))
} else {
Interval::new(
GenomePos::new(start_one_based),
GenomePos::new(end_one_based),
)
};
let edit = NaEdit::Duplication {
sequence: Some(dup_seq),
length: None,
uncertain_extent: None,
};
match base {
HgvsVariant::Genome(g) => Ok(Some(HgvsVariant::Genome(GenomeVariant {
accession: g.accession.clone(),
gene_symbol: g.gene_symbol.clone(),
loc_edit: LocEdit::new(interval, edit),
}))),
HgvsVariant::Mt(m) => Ok(Some(HgvsVariant::Mt(MtVariant {
accession: m.accession.clone(),
gene_symbol: m.gene_symbol.clone(),
loc_edit: LocEdit::new(interval, edit),
}))),
_ => Ok(None),
}
}
fn string_to_sequence(s: &str) -> Result<Sequence, ConversionError> {
s.parse().map_err(|_| ConversionError::InvalidPosition {
description: format!("invalid sequence: {}", s),
})
}
fn char_to_base(c: char) -> Result<crate::hgvs::edit::Base, ConversionError> {
crate::hgvs::edit::Base::from_char(c).ok_or_else(|| ConversionError::InvalidPosition {
description: format!("invalid base character: {}", c),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hgvs::parser::parse_hgvs;
fn tx_downstream_sub(accession: &str, base: i64) -> crate::hgvs::HgvsVariant {
use crate::hgvs::interval::TxInterval;
use crate::hgvs::location::TxPos;
let mut variant =
parse_hgvs(&format!("{accession}:n.{base}A>G")).expect("the template parses");
let crate::hgvs::HgvsVariant::Tx(tx) = &mut variant else {
unreachable!("an `n.` description parses as Tx")
};
tx.loc_edit.location = TxInterval::point(TxPos::downstream(base));
variant
}
#[test]
fn spelled_examples_are_parseable() {
for what in [
UnspelledBases::Duplicated,
UnspelledBases::Deleted,
UnspelledBases::DeletedInDelins,
UnspelledBases::Unchanged,
UnspelledBases::Inverted,
UnspelledBases::RepeatTract,
] {
let Some(example) = what.spelled_example() else {
assert_eq!(
what,
UnspelledBases::RepeatTract,
"only the repeat tract may decline without offering an example"
);
continue;
};
let prefixed = format!("NC_000001.11:{example}");
assert!(
parse_hgvs(&prefixed).is_ok(),
"{what:?} suggests `{example}`, which ferro cannot parse as `{prefixed}`"
);
}
}
#[test]
fn the_repeat_tract_offers_only_the_provider() {
let message = unspelled_bases_error(UnspelledBases::RepeatTract, 10, 12).to_string();
assert!(message.contains("10..=12"), "no span named: {message}");
assert!(
message.contains("reference provider"),
"the provider route must be named: {message}"
);
assert!(
!message.contains("Spell them"),
"a repeat's tract cannot be spelled in the description, so the message \
must not suggest it: {message}"
);
}
#[test]
fn the_decline_message_has_no_embedded_whitespace_runs() {
for what in [
UnspelledBases::Duplicated,
UnspelledBases::Deleted,
UnspelledBases::DeletedInDelins,
UnspelledBases::Unchanged,
UnspelledBases::Inverted,
UnspelledBases::RepeatTract,
] {
let message = unspelled_bases_error(what, 10, 12).to_string();
assert!(
!message.contains(" "),
"{what:?} carries a run of consecutive spaces: {message:?}"
);
assert!(
!message.contains('\n') && !message.contains('\t'),
"{what:?} carries a newline or tab: {message:?}"
);
}
}
#[test]
fn the_decline_message_names_the_span_and_both_remedies() {
let message = unspelled_bases_error(UnspelledBases::Unchanged, 10, 12).to_string();
assert!(message.contains("10..=12"), "no span named: {message}");
assert!(message.contains("unchanged"), "no shape named: {message}");
assert!(message.contains("g.10A="), "no example offered: {message}");
assert!(
message.contains("Spell them") && message.contains("reference provider"),
"both remedies must be offered: {message}"
);
}
#[test]
fn each_shape_declines_distinguishably() {
let messages: Vec<String> = [
UnspelledBases::Duplicated,
UnspelledBases::Deleted,
UnspelledBases::DeletedInDelins,
UnspelledBases::Unchanged,
UnspelledBases::Inverted,
UnspelledBases::RepeatTract,
]
.iter()
.map(|w| unspelled_bases_error(*w, 10, 12).to_string())
.collect();
let unique: std::collections::BTreeSet<&String> = messages.iter().collect();
assert_eq!(
unique.len(),
messages.len(),
"two shapes decline identically: {messages:#?}"
);
}
#[test]
fn a_provider_less_conversion_declines_with_the_span() {
for (descriptor, adjective, span) in [
("NC_000001.11:g.10_12dup", "duplicated", "10..=12"),
("NC_000001.11:g.10_12del", "deleted", "10..=12"),
("NC_000001.11:g.10_12delinsT", "deleted", "10..=12"),
("NC_000001.11:g.10_12=", "unchanged", "10..=12"),
("NC_000001.11:g.10_12inv", "inverted", "10..=12"),
(
"NC_000001.11:g.10_15AC[3]",
"pre-expansion repeat-tract",
"10..=15",
),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
let message = hgvs_to_spdi_simple(&variant)
.expect_err(&format!("`{descriptor}` must decline without a provider"))
.to_string();
assert!(
message.contains(adjective) && message.contains(span),
"`{descriptor}` declined without naming the {adjective} bases at {span}: {message}"
);
}
}
#[test]
fn test_hgvs_to_spdi_substitution() {
let hgvs = parse_hgvs("NC_000001.11:g.12345A>G").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.sequence, "NC_000001.11");
assert_eq!(spdi.position, 12344);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_insertion() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 100);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn test_hgvs_to_spdi_deletion_with_seq() {
let hgvs = parse_hgvs("NC_000001.11:g.100_102delATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn test_hgvs_to_spdi_deletion_without_seq() {
let hgvs = parse_hgvs("NC_000001.11:g.100_102del").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn test_hgvs_to_spdi_delins_without_ref() {
let hgvs = parse_hgvs("NC_000001.11:g.100_102delinsTTCC").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown"));
}
#[test]
fn test_hgvs_to_spdi_delins_with_explicit_deleted_no_ref() {
let hgvs = parse_hgvs("NC_000001.11:g.100_102delATGinsTTCC").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs)
.expect("explicit deleted sequence should not require reference data");
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "TTCC");
}
#[test]
fn test_hgvs_to_spdi_duplication_with_seq() {
let hgvs = parse_hgvs("NC_000001.11:g.100_102dupATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 102);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn test_hgvs_to_spdi_identity() {
let hgvs = parse_hgvs("NC_000001.11:g.100A=").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "A");
}
#[test]
fn normalizing_a_window_preserves_its_length() {
for window in ["acgtACGT", "uuuUUU", "acgunACGUN", "", "nnnn"] {
for alphabet in [AlphabetMode::Dna, AlphabetMode::Rna] {
assert_eq!(
apply_alphabet(window, alphabet).len(),
window.len(),
"`{window}` changed length under {alphabet:?}"
);
}
}
}
#[test]
fn a_uracil_spelled_tract_is_found_on_the_rna_axis() {
let mut provider = MockProvider::new();
provider.add_genomic_sequence("NR_TEST.1", "GGuuuuGG".to_string());
assert_eq!(
resolve_repeat_tract_span(&provider, "NR_TEST.1", 3, b"T", AlphabetMode::Rna).unwrap(),
(3, 6, RepeatSpanOrigin::Tract),
"the `r.` axis must see the tract its unit was rewritten to match"
);
assert_eq!(
resolve_repeat_tract_span(&provider, "NR_TEST.1", 3, b"T", AlphabetMode::Dna).unwrap(),
(3, 3, RepeatSpanOrigin::NoRunAtAnchor),
"on the DNA axis `U` is not `T`, so no tract exists and the \
unit-wide fallback stands"
);
}
struct LengthlessProvider(MockProvider);
impl ReferenceProvider for LengthlessProvider {
fn get_transcript(&self, id: &str) -> Result<std::sync::Arc<Transcript>, FerroError> {
self.0.get_transcript(id)
}
fn get_sequence(&self, id: &str, start: u64, end: u64) -> Result<String, FerroError> {
self.0.get_sequence(id, start, end)
}
fn get_genomic_sequence(
&self,
contig: &str,
start: u64,
end: u64,
) -> Result<String, FerroError> {
self.0.get_genomic_sequence(contig, start, end)
}
fn get_sequence_length(&self, id: &str) -> Result<u64, FerroError> {
Err(FerroError::ReferenceNotFound { id: id.to_string() })
}
}
fn cag_tract_provider() -> MockProvider {
let mut provider = MockProvider::new();
provider.add_genomic_sequence("NC_TEST.1", "GGCAGCAGCAGGG".to_string());
provider
}
#[test]
fn an_anchored_repeat_declines_when_the_length_lookup_fails() {
let provider = LengthlessProvider(cag_tract_provider());
let anchored = parse_hgvs("NC_TEST.1:g.3CAG[5]").unwrap();
let err = hgvs_to_spdi(&anchored, &provider)
.expect_err("an anchored repeat must decline when the tract span cannot be verified");
assert!(
matches!(err, ConversionError::UnsupportedEditType { .. }),
"declining an unverifiable anchored tract, got {err:?}"
);
let ranged = parse_hgvs("NC_TEST.1:g.3_11CAG[5]").unwrap();
let spdi = hgvs_to_spdi(&ranged, &provider)
.expect("the explicit-range spelling carries its own span and needs no length");
assert_eq!(spdi.position, 2);
assert_eq!(spdi.deletion, "CAGCAGCAG");
assert_eq!(spdi.insertion, "CAGCAGCAGCAGCAG");
}
#[test]
fn an_anchored_repeat_converts_over_its_full_run_when_the_length_is_known() {
let provider = cag_tract_provider();
let anchored = parse_hgvs("NC_TEST.1:g.3CAG[5]").unwrap();
let spdi = hgvs_to_spdi(&anchored, &provider)
.expect("a healthy provider resolves the anchored tract");
assert_eq!(spdi.position, 2);
assert_eq!(spdi.deletion, "CAGCAGCAG");
assert_eq!(spdi.insertion, "CAGCAGCAGCAGCAG");
let ranged = parse_hgvs("NC_TEST.1:g.3_11CAG[5]").unwrap();
assert_eq!(hgvs_to_spdi(&ranged, &provider).unwrap(), spdi);
}
fn identity_provider() -> MockProvider {
let mut provider = MockProvider::new();
provider.add_genomic_sequence("NC_000001.11", "ACGTACGTACGTACGTACGTACGTACGT".to_string());
provider
}
#[test]
fn an_unspelled_identity_takes_its_base_from_the_reference() {
let hgvs = parse_hgvs("NC_000001.11:g.10=").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "C");
assert_eq!(spdi.insertion, "C");
}
#[test]
fn an_unspelled_identity_takes_its_whole_span_from_the_reference() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12=").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "CGT");
}
#[test]
fn a_reference_span_insertion_reads_its_bases_from_the_reference() {
let hgvs = parse_hgvs("NC_000001.11:g.10_11ins5_8").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 10);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ACGT");
}
#[test]
fn an_inverted_reference_span_insertion_reads_the_reverse_complement() {
let hgvs = parse_hgvs("NC_000001.11:g.10_11ins5_7inv").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 10);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "CGT");
}
#[test]
fn a_reference_span_insertion_without_a_provider_is_declined() {
let hgvs = parse_hgvs("NC_000001.11:g.10_11ins5_8").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::MissingReferenceData { .. })),
"a same-reference range insert cannot be resolved without a provider: {result:?}"
);
}
#[test]
fn a_reference_span_delins_reads_both_spans_from_the_reference() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins5_8").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "ACGT");
}
#[test]
fn an_exact_repeat_insertion_expands_its_unit() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insC[4]").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 100);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "CCCC");
}
#[test]
fn an_exact_multibase_repeat_insertion_expands_the_whole_unit() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insCG[3]").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 100);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "CGCGCG");
}
#[test]
fn an_uncertain_repeat_count_insertion_is_declined() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insC[10_15]").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::MissingReferenceData { .. })),
"a range repeat count names no single expansion: {result:?}"
);
}
#[test]
fn an_n_unit_repeat_insertion_is_refused() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insN[18]").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"an N-unit repeat names a length, not bases, and must not emit an N-run: {result:?}"
);
}
#[test]
fn an_n_unit_repeat_delins_is_refused() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delinsN[341]").unwrap();
let result = hgvs_to_spdi(&hgvs, &identity_provider());
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"an N-unit repeat delins must be refused, not emitted as an N-run: {result:?}"
);
}
#[test]
fn an_n_containing_multibase_repeat_insertion_is_refused() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insAN[3]").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"a repeat unit containing N is undetermined content and must be refused: {result:?}"
);
}
#[test]
fn an_n_unit_repeat_part_in_a_compound_insert_is_refused() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[N[3];A]").unwrap();
let result = hgvs_to_spdi(&hgvs, &identity_provider());
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"an N-unit repeat part in a compound insert must be refused: {result:?}"
);
}
#[test]
fn an_n_unit_shortform_repeat_is_refused() {
let hgvs = parse_hgvs("NC_000001.11:g.100_105N[6]").unwrap();
let result = hgvs_to_spdi(&hgvs, &identity_provider());
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"a short-form N-unit repeat must be refused, not expanded to an N-run: {result:?}"
);
}
#[test]
fn a_concrete_base_repeat_insertion_still_expands() {
let hgvs = parse_hgvs("NC_000001.11:g.100_101insACGT[3]").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 100);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ACGTACGTACGT");
}
#[test]
fn an_exact_repeat_delins_expands_its_unit() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delinsCG[3]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "CGCGCG");
}
#[test]
fn a_compound_insert_mixing_a_literal_and_a_reference_span_reads_both() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[T;5_8]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "TACGT");
}
#[test]
fn a_compound_insert_concatenates_its_parts_in_order() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[5_8;T]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "ACGTT");
}
#[test]
fn a_compound_insert_reverse_complements_an_inverted_span_part() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[A;5_7inv]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "ACGT");
}
#[test]
fn a_compound_insert_expands_an_exact_repeat_part() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[C[3];A]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "CGT");
assert_eq!(spdi.insertion, "CCCA");
}
#[test]
fn a_compound_insertion_reads_on_the_ins_arm_too() {
let hgvs = parse_hgvs("NC_000001.11:g.10_11ins[T;5_8]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &identity_provider()).unwrap();
assert_eq!(spdi.position, 10);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "TACGT");
}
#[test]
fn a_compound_insert_with_a_span_part_needs_a_provider() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[T;5_8]").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::MissingReferenceData { .. })),
"a coordinate-span part cannot be resolved without a provider: {result:?}"
);
}
#[test]
fn a_compound_insert_with_an_undetermined_part_is_declined() {
let hgvs = parse_hgvs("NC_000001.11:g.10_12delins[N[10_15];A]").unwrap();
let result = hgvs_to_spdi(&hgvs, &identity_provider());
assert!(
matches!(result, Err(ConversionError::UnsupportedEditType { .. })),
"a non-exact repeat count part names no single expansion: {result:?}"
);
}
#[test]
fn an_unspelled_identity_fetches_on_the_non_genomic_axes_too() {
let mut provider = MockProvider::new();
provider.add_genomic_sequence("NR_TEST.1", "ACGTACGTACGTACGTACGTACGTACGT".to_string());
for (identity, deletion) in [
("NR_TEST.1:n.10=", "NR_TEST.1:n.10del"),
("NR_TEST.1:r.10=", "NR_TEST.1:r.10del"),
] {
let id_spdi = hgvs_to_spdi(&parse_hgvs(identity).unwrap(), &provider).unwrap();
let del_spdi = hgvs_to_spdi(&parse_hgvs(deletion).unwrap(), &provider).unwrap();
assert_eq!(
(id_spdi.position, id_spdi.deletion.as_str()),
(del_spdi.position, del_spdi.deletion.as_str()),
"{identity} must claim the same span `{deletion}` deletes"
);
assert_eq!(
id_spdi.insertion, id_spdi.deletion,
"{identity} is an identity"
);
assert_eq!(del_spdi.insertion, "", "{deletion} deletes");
}
}
#[test]
fn an_unspelled_identity_needs_reference_data() {
let hgvs = parse_hgvs("NC_000001.11:g.10=").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::MissingReferenceData { .. })),
"expected MissingReferenceData, got {result:?}"
);
}
#[test]
fn an_out_of_range_unspelled_identity_reports_the_fetch_failure() {
let hgvs = parse_hgvs("NC_000001.11:g.9999=").unwrap();
let result = hgvs_to_spdi(&hgvs, &identity_provider());
assert!(
matches!(result, Err(ConversionError::MissingReferenceData { .. })),
"expected MissingReferenceData, got {result:?}"
);
}
#[test]
fn a_whole_entity_identity_has_no_spdi() {
let hgvs = parse_hgvs("NC_000001.11:g.=").unwrap();
let result = hgvs_to_spdi(&hgvs, &identity_provider());
assert!(
matches!(result, Err(ConversionError::UnsupportedEditType { .. })),
"expected UnsupportedEditType, got {result:?}"
);
}
#[test]
fn an_identity_member_does_not_alias_a_sibling_insertion_junction() {
let provider = identity_provider();
let variant = parse_hgvs("NC_000001.11:g.[10_11dup;12=]").unwrap();
let members = match variant {
HgvsVariant::Allele(allele) => allele.variants,
other => panic!("expected an allele, got {other}"),
};
let triples: Vec<SpdiVariant> = members
.iter()
.map(|m| hgvs_to_spdi(m, &provider).expect("member converts"))
.collect();
assert_eq!(triples[0].position, 11);
assert_eq!(triples[0].deletion, "");
assert_eq!(triples[0].insertion, "CG");
assert_eq!(triples[1].position, 11);
assert_eq!(
triples[1].deletion, "T",
"the identity must claim its base; a zero-width deletion here is \
indistinguishable from a second insertion at interbase {}",
triples[0].position
);
}
#[test]
fn test_hgvs_to_spdi_simple_cds_requires_provider() {
let hgvs = parse_hgvs("NM_000088.3:c.100A>G").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::ProviderRequired { .. })
));
let err = result.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("c."), "message should mention c.: {}", msg);
assert!(
msg.contains("provider"),
"message should mention provider: {}",
msg
);
}
#[test]
fn test_hgvs_to_spdi_simple_short_form_inversion_requires_provider() {
let hgvs = parse_hgvs("NC_000001.11:g.100_200inv").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn test_spdi_to_hgvs_substitution() {
let spdi = SpdiVariant::new("NC_000001.11", 12344, "A", "G");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.12345A>G");
}
#[test]
fn test_spdi_to_hgvs_deletion() {
let spdi = SpdiVariant::deletion("NC_000001.11", 99, "ATG");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100_102delATG");
}
#[test]
fn test_spdi_to_hgvs_insertion() {
let spdi = SpdiVariant::insertion("NC_000001.11", 100, "ATG");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100_101insATG");
}
#[test]
fn test_spdi_to_hgvs_delins() {
let spdi = SpdiVariant::delins("NC_000001.11", 99, "ATG", "TTCC");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100_102delinsTTCC");
}
#[test]
fn test_spdi_to_hgvs_identity() {
let spdi = SpdiVariant::new("NC_000001.11", 99, "A", "A");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100A=");
}
#[test]
fn test_spdi_to_hgvs_single_del() {
let spdi = SpdiVariant::deletion("NC_000001.11", 99, "A");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100delA");
}
#[test]
fn test_roundtrip_substitution() {
let original = "NC_000001.11:g.12345A>G";
let hgvs = parse_hgvs(original).unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
let back = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(back.to_string(), original);
}
#[test]
fn test_roundtrip_insertion() {
let original = "NC_000001.11:g.100_101insATG";
let hgvs = parse_hgvs(original).unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
let back = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(back.to_string(), original);
}
#[test]
fn test_roundtrip_deletion_with_seq() {
let original = "NC_000001.11:g.100_102delATG";
let hgvs = parse_hgvs(original).unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
let back = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(back.to_string(), original);
}
#[test]
fn test_error_display() {
let err = ConversionError::UnsupportedVariantType {
description: "test".to_string(),
};
assert!(err.to_string().contains("unsupported variant type"));
let err = ConversionError::MissingReferenceData {
description: "test".to_string(),
};
assert!(err.to_string().contains("missing reference data"));
}
fn make_test_genomic_provider() -> crate::reference::mock::MockProvider {
let mut p = crate::reference::mock::MockProvider::new();
let mut contig = String::new();
contig.push_str(&"N".repeat(99)); contig.push_str("ATG"); contig.push_str(&"N".repeat(97)); contig.push_str("GATTACA"); contig.push_str(&"N".repeat(793)); contig.push_str("AAACCCGGGT"); contig.push_str(&"N".repeat(50));
p.add_genomic_sequence("NC_000001.11", &contig);
p
}
#[test]
fn fetch_reference_bases_returns_genomic_bases() {
let provider = make_test_genomic_provider();
let bases = fetch_reference_bases(&provider, "NC_000001.11", 100, 102).unwrap();
assert_eq!(bases, "ATG");
}
#[test]
fn fetch_reference_bases_errors_when_provider_lacks_contig() {
let provider = crate::reference::mock::MockProvider::new();
let err = fetch_reference_bases(&provider, "NC_000099.99", 100, 102).unwrap_err();
assert!(matches!(err, ConversionError::MissingReferenceData { .. }));
let msg = err.to_string();
assert!(msg.contains("NC_000099.99"));
assert!(msg.contains("100"));
assert!(msg.contains("102"));
}
#[test]
fn fetch_reference_bases_errors_on_short_contig() {
let mut provider = crate::reference::mock::MockProvider::new();
provider.add_genomic_sequence("NC_000001.11", "ATG");
let err = fetch_reference_bases(&provider, "NC_000001.11", 100, 102).unwrap_err();
assert!(matches!(err, ConversionError::MissingReferenceData { .. }));
}
#[test]
fn hgvs_to_spdi_deletion_short_form_with_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_102del").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NC_000001.11");
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn hgvs_to_spdi_duplication_short_form_with_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_102dup").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 102);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn hgvs_to_spdi_single_base_duplication_short_form_with_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100dup").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 100);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "A");
}
#[test]
fn hgvs_to_spdi_delins_short_form_with_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_102delinsTTCC").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "TTCC");
}
#[test]
fn hgvs_to_spdi_long_deletion_with_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.1000_1009del").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "AAACCCGGGT");
assert_eq!(spdi.deletion.len(), 10);
assert_eq!(spdi.insertion, "");
}
#[test]
fn hgvs_to_spdi_explicit_deletion_does_not_consult_provider() {
let provider = crate::reference::mock::MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.100_102delATG").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn hgvs_to_spdi_explicit_duplication_does_not_consult_provider() {
let provider = crate::reference::mock::MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.100_102dupATG").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 102);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn hgvs_to_spdi_substitution_unaffected_by_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.12345A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.to_string(), "NC_000001.11:12344:A:G");
}
#[test]
fn hgvs_to_spdi_mnv_delins_with_provider_round_trips() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_102delinsGGG").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "GGG");
let back = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(back.to_string(), "NC_000001.11:g.100_102delinsGGG");
}
#[test]
fn hgvs_to_spdi_deletion_round_trip_with_provider() {
let provider = make_test_genomic_provider();
let original = parse_hgvs("NC_000001.11:g.100_102del").unwrap();
let spdi = hgvs_to_spdi(&original, &provider).unwrap();
let back = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(back.to_string(), "NC_000001.11:g.100_102delATG");
}
#[test]
fn hgvs_to_spdi_delins_round_trip_with_provider() {
let provider = make_test_genomic_provider();
let original = parse_hgvs("NC_000001.11:g.100_102delinsTTCC").unwrap();
let spdi = hgvs_to_spdi(&original, &provider).unwrap();
let back = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(back.to_string(), "NC_000001.11:g.100_102delinsTTCC");
}
#[test]
fn hgvs_to_spdi_dup_round_trip_emits_ins_form_via_reference_free_path() {
let provider = make_test_genomic_provider();
let original = parse_hgvs("NC_000001.11:g.100_102dup").unwrap();
let spdi = hgvs_to_spdi(&original, &provider).unwrap();
assert_eq!(spdi.position, 102);
assert_eq!(spdi.insertion, "ATG");
let recovered = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(recovered.to_string(), "NC_000001.11:g.102_103insATG");
}
#[test]
fn hgvs_to_spdi_mito_short_form_deletion_with_provider() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut seq = "N".repeat(16559);
seq.push_str("GATC"); seq.push_str(&"N".repeat(20));
provider.add_genomic_sequence("NC_012920.1", &seq);
let hgvs = parse_hgvs("NC_012920.1:m.16560_16563del").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "GATC");
assert_eq!(spdi.insertion, "");
}
#[test]
fn hgvs_to_spdi_deletion_with_provider_missing_data() {
let provider = crate::reference::mock::MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.100_102del").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
let msg = result.unwrap_err().to_string();
assert!(msg.contains("NC_000001.11"));
}
#[test]
fn hgvs_to_spdi_simple_pins_existing_short_form_failures() {
for input in [
"NC_000001.11:g.100_102del",
"NC_000001.11:g.100_102dup",
"NC_000001.11:g.100_102delinsTTCC",
] {
let hgvs = parse_hgvs(input).unwrap();
let r = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(r, Err(ConversionError::MissingReferenceData { .. })),
"expected MissingReferenceData for {} (got {:?})",
input,
r
);
}
}
#[test]
fn hgvs_to_spdi_short_form_is_idempotent() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_102del").unwrap();
let a = hgvs_to_spdi(&hgvs, &provider).unwrap();
let b = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(a.to_string(), b.to_string());
}
#[test]
fn test_spdi_empty_deletion_insertion() {
let spdi = SpdiVariant::insertion("NC_000001.11", 100, "ATG");
assert!(spdi.is_insertion());
assert!(!spdi.is_deletion());
assert!(!spdi.is_identity());
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn test_spdi_empty_insertion_deletion() {
let spdi = SpdiVariant::deletion("NC_000001.11", 100, "ATG");
assert!(spdi.is_deletion());
assert!(!spdi.is_insertion());
assert!(!spdi.is_identity());
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn test_spdi_both_empty_is_identity() {
let spdi = SpdiVariant::new("NC_000001.11", 100, "", "");
assert!(spdi.is_identity());
assert!(!spdi.is_insertion());
assert!(!spdi.is_deletion());
}
#[test]
fn test_spdi_single_base_insertion() {
let spdi = SpdiVariant::insertion("NC_000001.11", 100, "A");
assert!(spdi.is_insertion());
assert_eq!(spdi.insertion.len(), 1);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("ins"));
}
#[test]
fn test_spdi_single_base_deletion() {
let spdi = SpdiVariant::deletion("NC_000001.11", 100, "A");
assert!(spdi.is_deletion());
assert_eq!(spdi.deletion.len(), 1);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("del"));
}
#[test]
fn test_spdi_long_insertion_100bp() {
let long_seq = "A".repeat(100);
let spdi = SpdiVariant::insertion("NC_000001.11", 12345, &long_seq);
assert!(spdi.is_insertion());
assert_eq!(spdi.insertion.len(), 100);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("ins"));
assert!(hgvs.to_string().ends_with(&format!("ins{}", long_seq)));
}
#[test]
fn test_spdi_long_deletion_100bp() {
let long_seq = "ACGT".repeat(25); let spdi = SpdiVariant::deletion("NC_000001.11", 12345, &long_seq);
assert!(spdi.is_deletion());
assert_eq!(spdi.deletion.len(), 100);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("del"));
}
#[test]
fn test_spdi_long_indel_asymmetric() {
let del_seq = "A".repeat(50);
let ins_seq = "G".repeat(150);
let spdi = SpdiVariant::delins("NC_000001.11", 12345, &del_seq, &ins_seq);
assert!(!spdi.is_insertion());
assert!(!spdi.is_deletion());
assert_eq!(spdi.deletion.len(), 50);
assert_eq!(spdi.insertion.len(), 150);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("delins"));
}
#[test]
fn test_spdi_very_long_insertion_1000bp() {
let long_seq = "ACGT".repeat(250); let spdi = SpdiVariant::insertion("NC_000001.11", 50000, &long_seq);
assert!(spdi.is_insertion());
assert_eq!(spdi.insertion.len(), 1000);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("ins"));
}
#[test]
fn test_spdi_position_zero() {
let spdi = SpdiVariant::new("NC_000001.11", 0, "A", "G");
assert_eq!(spdi.position, 0);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("g.1A>G"));
}
#[test]
fn test_spdi_position_max() {
let spdi = SpdiVariant::new("NC_000001.11", 248956421, "A", "G");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("248956422")); }
#[test]
fn test_spdi_lowercase_sequence() {
let spdi = SpdiVariant::new("NC_000001.11", 100, "a", "g");
assert_eq!(spdi.deletion, "a");
assert_eq!(spdi.insertion, "g");
}
#[test]
fn test_spdi_mixed_case_sequence() {
let spdi = SpdiVariant::new("NC_000001.11", 100, "AtGc", "GcTa");
assert_eq!(spdi.deletion, "AtGc");
assert_eq!(spdi.insertion, "GcTa");
}
#[test]
fn test_spdi_n_bases_in_sequence() {
let spdi = SpdiVariant::new("NC_000001.11", 100, "ANG", "TNC");
assert_eq!(spdi.deletion, "ANG");
assert_eq!(spdi.insertion, "TNC");
}
#[test]
fn test_spdi_complex_repeat_sequence() {
let repeat = "CAG".repeat(30); let spdi = SpdiVariant::insertion("NC_000004.12", 3074876, &repeat);
assert!(spdi.is_insertion());
assert_eq!(spdi.insertion.len(), 90);
}
#[test]
fn test_spdi_to_hgvs_delins_single_base_del() {
let spdi = SpdiVariant::delins("NC_000001.11", 100, "A", "TTTT");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("delinsTTTT"));
}
#[test]
fn test_spdi_to_hgvs_delins_single_base_ins() {
let spdi = SpdiVariant::delins("NC_000001.11", 100, "AAAA", "T");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("delinsT"));
}
#[test]
fn test_spdi_different_chromosome_formats() {
let test_cases = vec![
("NC_000001.11", "NC_000001.11"), ("NC_000023.11", "NC_000023.11"), ("NC_000024.10", "NC_000024.10"), ("NC_012920.1", "NC_012920.1"), ];
for (input_acc, expected_acc) in test_cases {
let spdi = SpdiVariant::new(input_acc, 100, "A", "G");
assert_eq!(spdi.sequence, expected_acc);
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().starts_with(expected_acc));
}
}
#[test]
fn test_spdi_roundtrip_preserves_case_normalized() {
let spdi = SpdiVariant::new("NC_000001.11", 100, "A", "G");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
let back = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(back.deletion, "A");
assert_eq!(back.insertion, "G");
let spdi_delins = SpdiVariant::new("NC_000001.11", 100, "ACGT", "TGCA");
let hgvs_delins = spdi_to_hgvs(&spdi_delins).unwrap();
let back_delins = hgvs_to_spdi_simple(&hgvs_delins);
assert!(back_delins.is_err());
}
#[test]
fn test_spdi_empty_seq_insertion_roundtrip() {
let spdi = SpdiVariant::insertion("NC_000001.11", 100, "ATG");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert!(hgvs.to_string().contains("ins"));
let back = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(back.deletion, "");
assert_eq!(back.insertion, "ATG");
}
#[test]
fn test_hgvs_to_spdi_various_accession_types() {
let test_variants = vec![
"NC_000001.11:g.12345A>G", "NC_000023.11:g.12345A>G", "NC_012920.1:g.12345A>G", ];
for variant_str in test_variants {
let hgvs = parse_hgvs(variant_str).unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs);
assert!(spdi.is_ok(), "Failed for: {}", variant_str);
}
}
#[test]
fn test_spdi_display_format() {
let spdi = SpdiVariant::new("NC_000001.11", 12344, "A", "G");
assert_eq!(spdi.to_string(), "NC_000001.11:12344:A:G");
let spdi_del = SpdiVariant::deletion("NC_000001.11", 100, "ATG");
assert_eq!(spdi_del.to_string(), "NC_000001.11:100:ATG:");
let spdi_ins = SpdiVariant::insertion("NC_000001.11", 100, "ATG");
assert_eq!(spdi_ins.to_string(), "NC_000001.11:100::ATG");
}
#[test]
fn test_spdi_identity_various_lengths() {
let spdi1 = SpdiVariant::new("NC_000001.11", 100, "A", "A");
assert!(spdi1.is_identity());
let spdi2 = SpdiVariant::new("NC_000001.11", 100, "ATG", "ATG");
assert!(spdi2.is_identity());
let spdi3 = SpdiVariant::new("NC_000001.11", 100, "", "");
assert!(spdi3.is_identity());
}
fn provider_with_genomic(seq: &str) -> crate::reference::mock::MockProvider {
let mut p = crate::reference::mock::MockProvider::new();
p.add_genomic_sequence("NC_000001.11", seq);
p
}
#[test]
fn spdi_to_hgvs_with_ref_recovers_multi_base_dup() {
let mut contig = "N".repeat(99);
contig.push_str("ATG"); contig.push_str(&"N".repeat(50));
let provider = provider_with_genomic(&contig);
let spdi = SpdiVariant::insertion("NC_000001.11", 102, "ATG");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100_102dupATG");
}
#[test]
fn spdi_to_hgvs_with_ref_recovers_single_base_dup() {
let mut contig = "N".repeat(99);
contig.push('A'); contig.push_str(&"N".repeat(20));
let provider = provider_with_genomic(&contig);
let spdi = SpdiVariant::insertion("NC_000001.11", 100, "A");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100dupA");
}
#[test]
fn spdi_to_hgvs_with_ref_keeps_ins_when_no_match() {
let mut contig = "N".repeat(99);
contig.push_str("CCC"); contig.push_str(&"N".repeat(20));
let provider = provider_with_genomic(&contig);
let spdi = SpdiVariant::insertion("NC_000001.11", 102, "ATG");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.102_103insATG");
}
#[test]
fn spdi_to_hgvs_with_ref_rejects_ins_at_contig_start() {
let provider = provider_with_genomic("ATGCATGCATGC");
let spdi = SpdiVariant::insertion("NC_000001.11", 0, "ATG");
let err = spdi_to_hgvs_with_ref(&spdi, &provider).expect_err(
"SPDI position 0 (insertion before contig start) must surface InvalidPosition",
);
assert!(matches!(err, ConversionError::InvalidPosition { .. }));
}
#[test]
fn spdi_to_hgvs_with_ref_substitution_unchanged() {
let provider = provider_with_genomic(&"N".repeat(20000));
let spdi = SpdiVariant::new("NC_000001.11", 12344, "A", "G");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.12345A>G");
}
#[test]
fn spdi_to_hgvs_with_ref_deletion_unchanged() {
let provider = provider_with_genomic(&"N".repeat(2000));
let spdi = SpdiVariant::deletion("NC_000001.11", 99, "ATG");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100_102delATG");
}
#[test]
fn spdi_to_hgvs_with_ref_delins_unchanged() {
let provider = provider_with_genomic(&"N".repeat(2000));
let spdi = SpdiVariant::delins("NC_000001.11", 99, "ATG", "TTCC");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100_102delinsTTCC");
}
#[test]
fn spdi_to_hgvs_with_ref_identity_unchanged() {
let provider = provider_with_genomic(&"N".repeat(2000));
let spdi = SpdiVariant::new("NC_000001.11", 99, "A", "A");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100A=");
}
#[test]
fn zero_width_identity_is_refused_on_both_entry_points() {
let spdi = SpdiVariant::new("NC_000001.11", 99, "", "");
assert!(spdi.is_identity(), "both sides empty is an identity");
assert!(
matches!(
spdi_to_hgvs(&spdi),
Err(ConversionError::UnsupportedEditType { .. })
),
"a triple naming zero bases must not convert to an identity"
);
let provider = provider_with_genomic(&"N".repeat(2000));
assert!(
matches!(
spdi_to_hgvs_with_ref(&spdi, &provider),
Err(ConversionError::UnsupportedEditType { .. })
),
"the reference-aware path must inherit the refusal"
);
}
#[test]
fn a_one_base_identity_still_converts() {
let spdi = SpdiVariant::new("NC_000001.11", 99, "A", "A");
let hgvs = spdi_to_hgvs(&spdi).expect("a one-base identity converts");
assert_eq!(hgvs.to_string(), "NC_000001.11:g.100A=");
}
#[test]
fn spdi_to_hgvs_with_ref_propagates_ref_error() {
let provider = crate::reference::mock::MockProvider::new();
let spdi = SpdiVariant::insertion("NC_000999.99", 101, "ATG");
let result = spdi_to_hgvs_with_ref(&spdi, &provider);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
use crate::reference::mock::MockProvider;
use crate::reference::transcript::{Exon, GenomeBuild, ManeStatus, Strand};
fn make_test_provider() -> MockProvider {
let tx = Transcript::new(
"NM_TEST.1".to_string(),
Some("TEST".to_string()),
Strand::Plus,
"AAAAATGCCCAAAGGGTTTAGGCCCAAAGGGTTATAAA".to_string() + "AA",
Some(6),
Some(35),
vec![Exon::new(1, 1, 40)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
provider
}
fn make_intronic_provider() -> MockProvider {
let tx = Transcript::new(
"NM_INTRON.1".to_string(),
Some("INTRON".to_string()),
Strand::Plus,
"A".repeat(100),
Some(11),
Some(90),
vec![Exon::new(1, 1, 50), Exon::new(2, 51, 100)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
provider
}
#[test]
fn r_star_and_c_star_resolve_to_same_spdi_position() {
let provider = make_test_provider();
let c = parse_hgvs("NM_TEST.1:c.*1del").unwrap();
let r = parse_hgvs("NM_TEST.1:r.*1del").unwrap();
let c_spdi = hgvs_to_spdi(&c, &provider).unwrap();
let r_spdi = hgvs_to_spdi(&r, &provider).unwrap();
assert_eq!(
c_spdi.position, r_spdi.position,
"c.*1 and r.*1 must resolve to the same SPDI position"
);
assert_eq!(c_spdi.position, 35);
}
fn make_gapped_utr3_provider() -> MockProvider {
let tx = Transcript::new(
"NM_GAP.1".to_string(),
Some("GAP".to_string()),
Strand::Plus,
"A".repeat(44),
Some(6),
Some(35),
vec![Exon::new(1, 1, 35), Exon::new(2, 40, 44)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
provider
}
#[test]
fn r_star_c_star_agree_across_exon_gap() {
let provider = make_gapped_utr3_provider();
let c = parse_hgvs("NM_GAP.1:c.*1A>G").unwrap();
let r = parse_hgvs("NM_GAP.1:r.*1a>g").unwrap();
let c_spdi = hgvs_to_spdi(&c, &provider).unwrap();
let r_spdi = hgvs_to_spdi(&r, &provider).unwrap();
assert_eq!(
c_spdi.position, r_spdi.position,
"c.*1 and r.*1 must resolve to the same SPDI position across the exon gap"
);
assert_eq!(
c_spdi.position, 35,
"*1 is cds_end + 1 on the flat transcript (tx 36 → SPDI 35); the exon \
walk this replaced skipped the tx 36-39 bases and answered SPDI 39 (#1619)"
);
}
#[test]
fn r_star_on_non_coding_transcript_declines() {
let mut provider = MockProvider::new();
let tx = Transcript::new(
"NR_TEST.1".to_string(),
Some("NCTEST".to_string()),
Strand::Plus,
"A".repeat(40),
None,
None,
vec![Exon::new(1, 1, 40)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
provider.add_transcript(tx);
let r = parse_hgvs("NR_TEST.1:r.*1del").unwrap();
assert!(hgvs_to_spdi(&r, &provider).is_err());
}
#[test]
fn r_minus_and_c_minus_resolve_to_same_spdi_position() {
let provider = make_test_provider();
let c = parse_hgvs("NM_TEST.1:c.-3del").unwrap();
let r = parse_hgvs("NM_TEST.1:r.-3del").unwrap();
let c_spdi = hgvs_to_spdi(&c, &provider).unwrap();
let r_spdi = hgvs_to_spdi(&r, &provider).unwrap();
assert_eq!(
c_spdi.position, r_spdi.position,
"c.-3 and r.-3 must resolve to the same SPDI position"
);
assert_eq!(c_spdi.position, 2);
}
fn make_gapped_utr5_provider() -> MockProvider {
let tx = Transcript::new(
"NM_GAP5.1".to_string(),
Some("GAP5".to_string()),
Strand::Plus,
"A".repeat(44),
Some(12),
Some(40),
vec![Exon::new(1, 1, 5), Exon::new(2, 10, 44)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
provider
}
#[test]
fn r_minus_c_minus_agree_across_exon_gap() {
let provider = make_gapped_utr5_provider();
let c = parse_hgvs("NM_GAP5.1:c.-3A>G").unwrap();
let r = parse_hgvs("NM_GAP5.1:r.-3a>g").unwrap();
let c_spdi = hgvs_to_spdi(&c, &provider).unwrap();
let r_spdi = hgvs_to_spdi(&r, &provider).unwrap();
assert_eq!(
c_spdi.position, r_spdi.position,
"c.-3 and r.-3 must resolve to the same SPDI position across the exon gap"
);
assert_eq!(
c_spdi.position, 8,
"-3 is cds_start - 3 on the flat transcript (tx 9 → SPDI 8); the exon \
walk this replaced skipped the tx 6-9 bases and answered SPDI 4 (#1619)"
);
}
#[test]
fn r_minus_on_non_coding_transcript_declines() {
let mut provider = MockProvider::new();
let tx = Transcript::new(
"NR_TEST5.1".to_string(),
Some("NCTEST5".to_string()),
Strand::Plus,
"A".repeat(40),
None,
None,
vec![Exon::new(1, 1, 40)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
provider.add_transcript(tx);
let r = parse_hgvs("NR_TEST5.1:r.-3del").unwrap();
assert!(hgvs_to_spdi(&r, &provider).is_err());
}
#[test]
fn r_star_and_c_star_at_last_base_resolve() {
let provider = make_test_provider();
let c = parse_hgvs("NM_TEST.1:c.*5del").unwrap();
let r = parse_hgvs("NM_TEST.1:r.*5del").unwrap();
let c_spdi = hgvs_to_spdi(&c, &provider).unwrap();
let r_spdi = hgvs_to_spdi(&r, &provider).unwrap();
assert_eq!(
c_spdi.position, r_spdi.position,
"c.*5 and r.*5 (last transcript base) must resolve to the same SPDI position"
);
assert_eq!(c_spdi.position, 39);
}
#[test]
fn r_star_and_c_star_past_end_both_decline() {
let provider = make_test_provider();
let c = parse_hgvs("NM_TEST.1:c.*6del").unwrap();
let r = parse_hgvs("NM_TEST.1:r.*6del").unwrap();
assert!(
hgvs_to_spdi(&c, &provider).is_err(),
"c.*6 is one base past the transcript 3' end and must decline"
);
assert!(
hgvs_to_spdi(&r, &provider).is_err(),
"r.*6 is one base past the transcript 3' end and must decline"
);
let c_far = parse_hgvs("NM_TEST.1:c.*99999del").unwrap();
let r_far = parse_hgvs("NM_TEST.1:r.*99999del").unwrap();
assert!(hgvs_to_spdi(&c_far, &provider).is_err());
assert!(hgvs_to_spdi(&r_far, &provider).is_err());
}
#[test]
fn test_hgvs_to_spdi_simple_mt_substitution() {
let hgvs = parse_hgvs("NC_012920.1:m.3243A>G").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.sequence, "NC_012920.1");
assert_eq!(spdi.position, 3242);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
assert_eq!(spdi.to_string(), "NC_012920.1:3242:A:G");
}
#[test]
fn test_hgvs_to_spdi_simple_mt_insertion() {
let hgvs = parse_hgvs("NC_012920.1:m.100_101insATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 100);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn test_hgvs_to_spdi_simple_mt_deletion_with_seq() {
let hgvs = parse_hgvs("NC_012920.1:m.3243_3245delAGG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 3242);
assert_eq!(spdi.deletion, "AGG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn test_hgvs_to_spdi_simple_mt_deletion_without_seq_needs_ref() {
let hgvs = parse_hgvs("NC_012920.1:m.3243_3245del").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn dup_hgvs_to_spdi_to_hgvs_with_ref_roundtrip_multi_base() {
let mut contig = "N".repeat(99);
contig.push_str("ATG");
contig.push_str(&"N".repeat(20));
let provider = provider_with_genomic(&contig);
let original = "NC_000001.11:g.100_102dupATG";
let hgvs = parse_hgvs(original).unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.to_string(), "NC_000001.11:102::ATG");
let recovered = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(recovered.to_string(), original);
}
#[test]
fn dup_hgvs_to_spdi_to_hgvs_with_ref_roundtrip_single_base() {
let mut contig = "N".repeat(99);
contig.push('A');
contig.push_str(&"N".repeat(20));
let provider = provider_with_genomic(&contig);
let original = "NC_000001.11:g.100dupA";
let hgvs = parse_hgvs(original).unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.to_string(), "NC_000001.11:100::A");
let recovered = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(recovered.to_string(), original);
}
#[test]
fn spdi_to_hgvs_with_ref_does_not_false_detect_non_tandem_insertion() {
let contig = "ATCGATCGATCGAGGGTCCC".to_string();
let provider = provider_with_genomic(&contig);
let spdi = SpdiVariant::insertion("NC_000001.11", 13, "ATCGATCGATCG");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
let s = hgvs.to_string();
assert!(s.contains("ins"), "expected ins-form, got {}", s);
assert!(!s.contains("dup"), "expected not dup, got {}", s);
}
#[test]
fn audit_pin_no_ref_spdi_to_hgvs_renders_dup_shape_as_ins() {
let spdi = SpdiVariant::insertion("NC_000001.11", 102, "ATG");
let hgvs = spdi_to_hgvs(&spdi).unwrap();
assert_eq!(hgvs.to_string(), "NC_000001.11:g.102_103insATG");
}
#[test]
fn dup_recovery_is_idempotent_through_two_roundtrips() {
let mut contig = "N".repeat(99);
contig.push_str("ATG");
contig.push_str(&"N".repeat(20));
let provider = provider_with_genomic(&contig);
let original = "NC_000001.11:g.100_102dupATG";
let hgvs1 = parse_hgvs(original).unwrap();
let spdi1 = hgvs_to_spdi_simple(&hgvs1).unwrap();
let hgvs2 = spdi_to_hgvs_with_ref(&spdi1, &provider).unwrap();
let spdi2 = hgvs_to_spdi_simple(&hgvs2).unwrap();
let hgvs3 = spdi_to_hgvs_with_ref(&spdi2, &provider).unwrap();
assert_eq!(spdi1, spdi2);
assert_eq!(hgvs2.to_string(), hgvs3.to_string());
assert_eq!(hgvs3.to_string(), original);
}
#[test]
fn spdi_to_hgvs_with_ref_recovers_dup_for_mito_accession() {
let mut contig = "N".repeat(99);
contig.push_str("ATG");
contig.push_str(&"N".repeat(20));
let mut provider = crate::reference::mock::MockProvider::new();
provider.add_genomic_sequence("NC_012920.1", &contig);
let spdi = SpdiVariant::insertion("NC_012920.1", 102, "ATG");
let hgvs = spdi_to_hgvs_with_ref(&spdi, &provider).unwrap();
assert_eq!(hgvs.to_string(), "NC_012920.1:m.100_102dupATG");
}
#[test]
fn test_hgvs_to_spdi_simple_mt_dup_with_seq() {
let hgvs = parse_hgvs("NC_012920.1:m.100_102dupATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 102);
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn test_hgvs_to_spdi_simple_mt_identity() {
let hgvs = parse_hgvs("NC_012920.1:m.3243A=").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 3242);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "A");
}
#[test]
fn test_hgvs_to_spdi_simple_tx_substitution() {
let hgvs = parse_hgvs("NR_046018.2:n.5C>G").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.sequence, "NR_046018.2");
assert_eq!(spdi.position, 4); assert_eq!(spdi.deletion, "C");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_simple_tx_insertion() {
let hgvs = parse_hgvs("NR_046018.2:n.10_11insATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.sequence, "NR_046018.2");
assert_eq!(spdi.position, 10);
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn test_hgvs_to_spdi_simple_tx_deletion_with_seq() {
let hgvs = parse_hgvs("NR_046018.2:n.10_12delATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn test_hgvs_to_spdi_simple_tx_identity() {
let hgvs = parse_hgvs("NR_046018.2:n.10A=").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "A");
}
#[test]
fn test_hgvs_to_spdi_simple_tx_intronic_is_unrepresentable() {
let hgvs = parse_hgvs("NR_046018.2:n.100+5A>G").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"expected a representation limit, got {result:?}"
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("intronic"), "msg: {}", msg);
}
#[test]
fn test_hgvs_to_spdi_simple_tx_downstream_needs_provider() {
let hgvs = tx_downstream_sub("NR_046018.2", 5);
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn test_hgvs_to_spdi_simple_tx_negative_base_needs_provider() {
let hgvs = parse_hgvs("NR_046018.2:n.-3A>G").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn test_hgvs_to_spdi_simple_dna_lowercase_uppercased() {
let hgvs = parse_hgvs("NC_000001.11:g.100a>g").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_simple_rna_substitution_lowercase() {
let hgvs = parse_hgvs("NR_046018.2:r.5c>g").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.sequence, "NR_046018.2");
assert_eq!(spdi.position, 4);
assert_eq!(spdi.deletion, "C");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_simple_rna_substitution_u_to_t() {
let hgvs = parse_hgvs("NR_046018.2:r.5u>g").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.deletion, "T");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_simple_rna_insertion_u_to_t() {
let hgvs = parse_hgvs("NR_046018.2:r.10_11insauug").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 10);
assert_eq!(spdi.deletion, "");
assert_eq!(spdi.insertion, "ATTG");
}
#[test]
fn test_hgvs_to_spdi_simple_rna_deletion_with_seq() {
let hgvs = parse_hgvs("NR_046018.2:r.10_12delauu").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "ATT"); }
#[test]
fn test_hgvs_to_spdi_simple_rna_intronic_is_unrepresentable() {
let hgvs = parse_hgvs("NR_046018.2:r.10+5a>g").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"expected a representation limit, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_simple_protein_rejected() {
let hgvs = parse_hgvs("NP_000079.2:p.Arg600Gln").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::UnsupportedVariantType { .. })
));
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("protein") && msg.contains("SPDI"),
"expected helpful protein-rejection message; got: {}",
msg
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_protein_rejected() {
let provider = MockProvider::new();
let hgvs = parse_hgvs("NP_000079.2:p.Arg600Gln").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(matches!(
result,
Err(ConversionError::UnsupportedVariantType { .. })
));
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_substitution() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.1A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NM_TEST.1");
assert_eq!(spdi.position, 5);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_insertion() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.1_2insATG").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 6);
assert_eq!(spdi.insertion, "ATG");
}
#[test]
fn a_cds_axis_range_insert_reads_its_payload_on_the_cds_axis() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.1_2ins3_5").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 6);
assert_eq!(
spdi.insertion, "CCC",
"c.3_5 is tx 8..=10; reading tx 3..=5 unshifted would give AAA"
);
}
#[test]
fn a_cds_axis_range_delins_reads_its_payload_on_the_cds_axis() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.1_3delins3_5").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 5);
assert_eq!(spdi.deletion, "TGC");
assert_eq!(
spdi.insertion, "CCC",
"the delins arm resolves its payload on the same axis as the ins arm"
);
}
#[test]
fn a_coding_rna_axis_range_insert_reads_its_payload_cds_relative() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:r.1_2ins3_5").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 6);
assert_eq!(spdi.insertion, "CCC");
}
#[test]
fn a_cds_axis_compound_insert_reads_its_span_part_on_the_cds_axis() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.1_2ins[T;3_5]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 6);
assert_eq!(
spdi.insertion, "TCCC",
"a bracketed span part must be read on the same axis as a bare one; \
unshifted it would give TAAA"
);
}
#[test]
fn a_noncoding_axis_range_insert_reads_its_payload_unshifted() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:n.1_2ins3_5").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 1);
assert_eq!(
spdi.insertion, "AAA",
"n.3_5 is tx 3..=5; shifting it by cds_start would give CCC"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_deletion_with_seq() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.1_3delATG").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 5);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "");
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_5utr() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.-3A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 2);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_3utr() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.*2A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 36);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_exonic_past_3prime_end_declines() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.99999A>G").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic c.N must decline with InvalidPosition, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_exonic_range_past_3prime_end_declines() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.20_99999del").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic c. range must decline, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_exonic_last_base_ok() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:c.30A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 34, "c.30 -> tx 35 -> SPDI 34");
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_3utr_anchors_at_cds_end() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:r.*2a>g").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(
spdi.position, 36,
"r.*2 must anchor at cds_end (35) + 2 → tx 37 → SPDI 36; pre-#390 \
this anchored at sequence_length (40) + 2 → off-sequence SPDI 41"
);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_coding_matches_cds() {
let provider = make_test_provider();
for (r_str, c_str) in [
("NM_TEST.1:r.1a>g", "NM_TEST.1:c.1A>G"),
("NM_TEST.1:r.30a>g", "NM_TEST.1:c.30A>G"),
] {
let r = hgvs_to_spdi(&parse_hgvs(r_str).unwrap(), &provider).unwrap();
let c = hgvs_to_spdi(&parse_hgvs(c_str).unwrap(), &provider).unwrap();
assert_eq!(r.position, c.position, "{r_str} must match {c_str}");
}
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_coding_first_base() {
let provider = make_test_provider();
let spdi = hgvs_to_spdi(&parse_hgvs("NM_TEST.1:r.1a>g").unwrap(), &provider).unwrap();
assert_eq!(spdi.position, 5, "coding r.1 -> tx 6 (cds_start) -> SPDI 5");
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_coding_past_3prime_end_declines() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:r.99999a>g").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic coding r.N must decline with InvalidPosition, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_coding_range_matches_cds() {
let provider = make_test_provider();
let r = hgvs_to_spdi(&parse_hgvs("NM_TEST.1:r.1_30del").unwrap(), &provider).unwrap();
let c = hgvs_to_spdi(&parse_hgvs("NM_TEST.1:c.1_30del").unwrap(), &provider).unwrap();
assert_eq!(
r.position, c.position,
"r.1_30del must match c.1_30del position"
);
assert_eq!(
r.deletion.len(),
c.deletion.len(),
"r.1_30del must delete the same span as c.1_30del"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_coding_missing_cds_end_declines() {
let tx = Transcript::new(
"NM_NOCDSEND.1".to_string(),
Some("TEST".to_string()),
Strand::Plus,
"AAAAATGCCCAAAGGGTTTAGGCCCAAAGGGTTATAAA".to_string() + "AA",
Some(6),
None,
vec![Exon::new(1, 1, 40)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
let result = hgvs_to_spdi(&parse_hgvs("NM_NOCDSEND.1:r.5a>g").unwrap(), &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"coding r.N on a transcript missing cds_end must decline with \
InvalidPosition, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_cds_intronic_rejected() {
let provider = make_intronic_provider();
let hgvs = parse_hgvs("NM_INTRON.1:c.10+5A>G").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::UnrepresentableInSpdi { .. })),
"expected a representation limit, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_unknown_transcript() {
let provider = MockProvider::new();
let hgvs = parse_hgvs("NM_TEST.1:c.1A>G").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
let msg = result.unwrap_err().to_string();
assert!(msg.contains("transcript") || msg.contains("NM_TEST"));
}
#[test]
fn test_hgvs_to_spdi_with_provider_falls_through_to_simple_for_genome() {
let provider = MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.12345A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.to_string(), "NC_000001.11:12344:A:G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_falls_through_to_simple_for_mt() {
let provider = MockProvider::new();
let hgvs = parse_hgvs("NC_012920.1:m.3243A>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.to_string(), "NC_012920.1:3242:A:G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_falls_through_to_simple_for_exonic_n() {
let provider = MockProvider::new();
let hgvs = parse_hgvs("NR_046018.2:n.5C>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.to_string(), "NR_046018.2:4:C:G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_tx_exonic_past_3prime_end_declines() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:n.99999C>G").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic n.N must decline with InvalidPosition, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_tx_exonic_range_past_3prime_end_declines() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:n.10_99999del").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic n. range must decline, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_tx_exonic_last_base_ok() {
let provider = make_test_provider();
let hgvs = parse_hgvs("NM_TEST.1:n.40C>G").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 39, "n.40 -> tx 40 -> SPDI 39");
}
fn make_noncoding_provider() -> MockProvider {
let tx = Transcript::new(
"NR_TEST.1".to_string(),
Some("NCTEST".to_string()),
Strand::Plus,
"A".repeat(40),
None,
None,
vec![Exon::new(1, 1, 40)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
provider
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_past_3prime_end_declines() {
let provider = make_noncoding_provider();
let hgvs = parse_hgvs("NR_TEST.1:r.99999a>g").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic r.N must decline with InvalidPosition, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_range_past_3prime_end_declines() {
let provider = make_noncoding_provider();
let hgvs = parse_hgvs("NR_TEST.1:r.10_99999del").unwrap();
let result = hgvs_to_spdi(&hgvs, &provider);
assert!(
matches!(result, Err(ConversionError::InvalidPosition { .. })),
"over-length exonic r. range must decline, got {result:?}"
);
}
#[test]
fn test_hgvs_to_spdi_with_provider_rna_exonic_last_base_ok() {
let provider = make_noncoding_provider();
let hgvs = parse_hgvs("NR_TEST.1:r.40a>g").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 39, "r.40 -> tx 40 -> SPDI 39");
}
#[test]
fn test_hgvs_to_spdi_with_provider_falls_through_to_simple_for_exonic_r() {
let provider = MockProvider::new();
let hgvs = parse_hgvs("NR_046018.2:r.5c>g").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.to_string(), "NR_046018.2:4:C:G");
}
#[test]
fn test_hgvs_to_spdi_with_provider_n_downstream_rejected() {
let tx = Transcript::new(
"NR_NONCODING.1".to_string(),
Some("NONCODING".to_string()),
Strand::Plus,
"A".repeat(40),
None,
None,
vec![Exon::new(1, 1, 40)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
);
let mut provider = MockProvider::new();
provider.add_transcript(tx);
let hgvs = tx_downstream_sub("NR_NONCODING.1", 5);
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::InvalidPosition { .. }));
let msg = err.to_string();
assert!(
msg.contains("downstream n.") && msg.contains("genomic projection"),
"expected downstream-n rejection, got: {}",
msg
);
}
#[test]
fn test_hgvs_to_spdi_simple_n_short_form_inversion_requires_provider() {
let hgvs = parse_hgvs("NR_046018.2:n.10_20inv").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn test_hgvs_to_spdi_simple_m_short_form_inversion_requires_provider() {
let hgvs = parse_hgvs("NC_012920.1:m.100_200inv").unwrap();
let result = hgvs_to_spdi_simple(&hgvs);
assert!(matches!(
result,
Err(ConversionError::MissingReferenceData { .. })
));
}
#[test]
fn test_hgvs_to_spdi_inversion_short_form_with_provider() {
let provider = make_test_genomic_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_102inv").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NC_000001.11");
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "CAT");
}
#[test]
fn test_hgvs_to_spdi_inversion_explicit_sequence_no_provider() {
let hgvs = parse_hgvs("NC_000001.11:g.100_102invATG").unwrap();
let spdi = hgvs_to_spdi_simple(&hgvs).unwrap();
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "CAT");
}
#[test]
fn test_hgvs_to_spdi_inversion_explicit_sequence_does_not_consult_provider() {
let provider = crate::reference::mock::MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.100_102invATG").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "CAT");
}
#[test]
fn test_hgvs_to_spdi_inversion_single_base() {
let provider = make_test_genomic_provider();
let hgvs = HgvsVariant::Genome(GenomeVariant {
accession: parse_accession("NC_000001.11").unwrap().1,
gene_symbol: None,
loc_edit: LocEdit::new(
crate::hgvs::interval::GenomeInterval::point(GenomePos::new(100)),
NaEdit::Inversion {
sequence: None,
length: None,
},
),
});
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "A");
assert_eq!(spdi.insertion, "T");
}
#[test]
fn test_hgvs_to_spdi_inversion_palindrome_round_trip() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(99);
contig.push_str("ATAT");
contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NC_000001.11", &contig);
let hgvs = parse_hgvs("NC_000001.11:g.100_103inv").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "ATAT");
assert_eq!(spdi.insertion, "ATAT");
}
#[test]
fn test_hgvs_to_spdi_inversion_short_form_missing_provider_data() {
let provider = crate::reference::mock::MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.100_102inv").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::MissingReferenceData { .. }));
let msg = err.to_string();
assert!(msg.contains("NC_000001.11"));
assert!(msg.contains("100"));
assert!(msg.contains("102"));
}
#[test]
fn test_hgvs_to_spdi_inversion_m_short_form_with_provider() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(99);
contig.push_str("ATG");
contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NC_012920.1", &contig);
let hgvs = parse_hgvs("NC_012920.1:m.100_102inv").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NC_012920.1");
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "CAT");
}
#[test]
fn test_hgvs_to_spdi_inversion_n_short_form_with_provider() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(9);
contig.push_str("ATGCATGC"); contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NR_046018.2", &contig);
let hgvs = parse_hgvs("NR_046018.2:n.10_12inv").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NR_046018.2");
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "CAT");
}
#[test]
fn test_hgvs_to_spdi_inversion_r_short_form_dna_alphabet() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(9);
contig.push_str("ATGCATGC");
contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NR_046018.2", &contig);
let hgvs = parse_hgvs("NR_046018.2:r.10_12inv").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "ATG");
assert_eq!(spdi.insertion, "CAT");
assert!(!spdi.deletion.contains('U'));
assert!(!spdi.insertion.contains('U'));
}
fn make_repeat_provider() -> crate::reference::mock::MockProvider {
let mut p = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(99);
contig.push_str("ATATAT"); contig.push_str(&"N".repeat(94)); contig.push_str("ATATATATAT"); contig.push_str(&"N".repeat(50));
p.add_genomic_sequence("NC_000001.11", &contig);
p
}
#[test]
fn test_hgvs_to_spdi_repeat_expansion_with_provider() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[5]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NC_000001.11");
assert_eq!(spdi.position, 99);
assert_eq!(spdi.deletion, "ATATAT"); assert_eq!(spdi.insertion, "ATATATATAT"); }
#[test]
fn test_hgvs_to_spdi_repeat_contraction_with_provider() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.200_209AT[3]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.position, 199);
assert_eq!(spdi.deletion, "ATATATATAT"); assert_eq!(spdi.insertion, "ATATAT"); }
#[test]
fn test_hgvs_to_spdi_repeat_no_change_with_provider() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[3]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.deletion, "ATATAT");
assert_eq!(spdi.insertion, "ATATAT");
}
#[test]
fn test_hgvs_to_spdi_simple_repeat_requires_provider() {
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[5]").unwrap();
let err = hgvs_to_spdi_simple(&hgvs).unwrap_err();
assert!(matches!(err, ConversionError::MissingReferenceData { .. }));
}
#[test]
fn test_hgvs_to_spdi_repeat_missing_provider_data() {
let provider = crate::reference::mock::MockProvider::new();
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[5]").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::MissingReferenceData { .. }));
let msg = err.to_string();
assert!(msg.contains("NC_000001.11"));
assert!(msg.contains("100"));
assert!(msg.contains("105"));
}
#[test]
fn test_hgvs_to_spdi_repeat_uncertain_count_unsupported() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[3_5]").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::UnsupportedEditType { .. }));
}
#[test]
fn test_hgvs_to_spdi_repeat_unknown_count_unsupported() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[?]").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::UnsupportedEditType { .. }));
}
#[test]
fn test_hgvs_to_spdi_repeat_genotype_unsupported() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[3][5]").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::UnsupportedEditType { .. }));
}
#[test]
fn test_hgvs_to_spdi_repeat_no_unit_unsupported() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_105(5)").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::MissingReferenceData { .. }));
}
#[test]
fn test_hgvs_to_spdi_repeat_span_not_multiple_of_unit() {
let provider = make_repeat_provider();
let hgvs = parse_hgvs("NC_000001.11:g.100_104AT[5]").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::InvalidPosition { .. }));
let msg = err.to_string();
assert!(msg.contains("not a multiple"));
}
#[test]
fn test_hgvs_to_spdi_repeat_span_does_not_match_unit() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(99);
contig.push_str("ATGCAT"); contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NC_000001.11", &contig);
let hgvs = parse_hgvs("NC_000001.11:g.100_105AT[5]").unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::InvalidPosition { .. }));
let msg = err.to_string();
assert!(
msg.contains("does not match repeat unit"),
"expected mismatch message, got: {}",
msg
);
}
#[test]
fn test_hgvs_to_spdi_repeat_n_with_provider() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(9);
contig.push_str("ATATAT"); contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NR_046018.2", &contig);
let hgvs = parse_hgvs("NR_046018.2:n.10_15AT[5]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NR_046018.2");
assert_eq!(spdi.position, 9);
assert_eq!(spdi.deletion, "ATATAT");
assert_eq!(spdi.insertion, "ATATATATAT");
}
#[test]
fn test_hgvs_to_spdi_repeat_expansion_too_large() {
let provider = make_repeat_provider();
let huge = MAX_REPEAT_EXPANSION_BASES / 2 + 1; let hgvs = parse_hgvs(&format!("NC_000001.11:g.100_105AT[{}]", huge)).unwrap();
let err = hgvs_to_spdi(&hgvs, &provider).unwrap_err();
assert!(matches!(err, ConversionError::UnsupportedEditType { .. }));
let msg = err.to_string();
assert!(
msg.contains("exceeds SPDI ins-string cap"),
"expected size-cap message, got: {}",
msg
);
}
#[test]
fn test_hgvs_to_spdi_repeat_m_with_provider() {
let mut provider = crate::reference::mock::MockProvider::new();
let mut contig = "N".repeat(99);
contig.push_str("ATATAT"); contig.push_str(&"N".repeat(50));
provider.add_genomic_sequence("NC_012920.1", &contig);
let hgvs = parse_hgvs("NC_012920.1:m.100_105AT[5]").unwrap();
let spdi = hgvs_to_spdi(&hgvs, &provider).unwrap();
assert_eq!(spdi.sequence, "NC_012920.1");
assert_eq!(spdi.deletion, "ATATAT");
assert_eq!(spdi.insertion, "ATATATATAT");
}
#[test]
fn a_genomic_offset_is_refused_rather_than_dropped() {
let provider = identity_provider();
for (descriptor, coord) in [
("NC_000001.11:g.10+2delC", "g"),
("NC_000001.11:g.10-2delC", "g"),
("NC_000001.11:g.10+2_12delCGT", "g"),
("NC_000001.11:g.10_12+2delCGT", "g"),
("NC_012920.1:m.10+2delC", "m"),
("NC_012920.1:m.10_12+2delCGT", "m"),
("NC_001416.1:o.10+2delC", "o"),
("NC_001416.1:o.10_12+2delCGT", "o"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, converted) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
let err = match converted {
Err(err) => err,
Ok(spdi) => {
panic!(
"`{descriptor}` converted {path} to `{spdi}`; the offset was dropped"
)
}
};
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"`{descriptor}` {path} must be refused as InvalidPosition, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("carries a +/- offset"),
"`{descriptor}` {path} was refused for some other reason: {message}"
);
assert!(
message.contains(&format!("{coord}. position")),
"`{descriptor}` {path} named the wrong coordinate axis: {message}"
);
}
}
}
#[test]
fn a_transcript_axis_offset_declines_for_its_own_reason() {
let provider = make_intronic_provider();
for (descriptor, exonic_sibling) in [
("NM_INTRON.1:c.10+5A>G", "NM_INTRON.1:c.10A>G"),
("NM_INTRON.1:n.10+5A>G", "NM_INTRON.1:n.10A>G"),
("NM_INTRON.1:r.10+5a>g", "NM_INTRON.1:r.10a>g"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
let err = hgvs_to_spdi(&variant, &provider)
.expect_err("an intronic transcript position has no SPDI representation");
assert!(
matches!(err, ConversionError::UnrepresentableInSpdi { .. }),
"`{descriptor}` must decline as UnrepresentableInSpdi — the reference is served \
and is not the obstacle, got {err:?}"
);
assert!(
err.to_string().contains("genomic projection"),
"`{descriptor}` lost its own reason: {err}"
);
let exonic = parse_hgvs(exonic_sibling).expect("fixture must parse");
assert!(
hgvs_to_spdi(&exonic, &provider).is_ok(),
"`{exonic_sibling}` must still convert — the axis is not broken, \
only its intronic positions are unrepresentable"
);
}
}
#[test]
fn a_genomic_special_position_is_refused_rather_than_flattened() {
let provider = identity_provider();
for (descriptor, coord) in [
("NC_000001.11:g.10_qterdelACGTACGTAC", "g"),
("NC_000001.11:g.10_cendelACGTACGTAC", "g"),
("NC_000001.11:g.10_qterdupACGTACGTAC", "g"),
("NC_000001.11:g.10_qterinvACGTACGTAC", "g"),
("NC_000001.11:g.pter_10delACGTACGTAC", "g"),
("NC_012920.1:m.10_qterdelACGTACGTAC", "m"),
("NC_012920.1:m.pter_10delACGTACGTAC", "m"),
("NC_001416.1:o.10_qterdelACGTACGTAC", "o"),
("NC_001416.1:o.pter_10delACGTACGTAC", "o"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, converted) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
let err = match converted {
Err(err) => err,
Ok(spdi) => panic!(
"`{descriptor}` converted {path} to `{spdi}`; the special position \
was flattened onto base 0"
),
};
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"`{descriptor}` {path} must be refused as InvalidPosition, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("names no numeric coordinate"),
"`{descriptor}` {path} was refused for some other reason: {message}"
);
assert!(
message.contains(&format!("{coord}. position")),
"`{descriptor}` {path} named the wrong coordinate axis: {message}"
);
}
}
}
#[test]
fn special_positions_do_not_collapse_onto_one_another() {
const CONVERTIBLE_TODAY: usize = 1;
let provider = identity_provider();
let descriptors = [
"NC_000001.11:g.10_qterdelACGTACGTAC",
"NC_000001.11:g.10_cendelACGTACGTAC",
"NC_000001.11:g.10_19delACGTACGTAC",
];
let mut seen: Vec<(String, String)> = Vec::new();
for descriptor in descriptors {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
if let Ok(spdi) = hgvs_to_spdi(&variant, &provider) {
let triple = spdi.to_string();
if let Some((other, _)) = seen.iter().find(|(_, t)| *t == triple) {
panic!(
"`{descriptor}` and `{other}` are different variants but share the \
triple `{triple}`"
);
}
seen.push((descriptor.to_string(), triple));
}
}
assert_eq!(
seen.len(),
CONVERTIBLE_TODAY,
"{} of {} descriptors converted, expected {CONVERTIBLE_TODAY}: {seen:?}. \
If this grew, a special position is convertible again and the collision check \
above has just become live — read what it now compares before re-pinning this \
number. If it shrank, the loop is comparing nothing at all",
seen.len(),
descriptors.len()
);
assert!(
seen.len() < descriptors.len(),
"every descriptor converted, so the #1643 guard is refusing nothing"
);
}
#[test]
fn distinct_genomic_descriptions_do_not_share_one_triple() {
let provider = identity_provider();
let plain = hgvs_to_spdi(&parse_hgvs("NC_000001.11:g.10delC").unwrap(), &provider)
.expect("the offset-free spelling is legal and must convert")
.to_string();
for descriptor in ["NC_000001.11:g.10+2delC", "NC_000001.11:g.10-2delC"] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
let got = hgvs_to_spdi(&variant, &provider).map(|s| s.to_string());
assert_ne!(
got.as_deref().ok(),
Some(plain.as_str()),
"`{descriptor}` and `NC_000001.11:g.10delC` are different variants \
to `normalize` but one triple to `to_spdi`"
);
}
}
fn assert_end_boundary_refused(
descriptor: &str,
coord: &str,
path: &str,
converted: Result<SpdiVariant, ConversionError>,
) {
let err = match converted {
Err(err) => err,
Ok(spdi) => panic!(
"`{descriptor}` converted {path} to `{spdi}`; the unresolvable end was \
collapsed onto the start"
),
};
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"`{descriptor}` {path} must be refused as InvalidPosition — the same verdict this \
axis already gives an unresolvable START — got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("names no single coordinate"),
"`{descriptor}` {path} was refused for some other reason: {message}"
);
assert!(
message.contains(&format!("{coord}. interval end")),
"`{descriptor}` {path} named the wrong coordinate axis: {message}"
);
}
#[test]
fn an_unresolvable_transcript_end_is_refused_rather_than_collapsed() {
let provider = make_intronic_provider();
for (descriptor, coord) in [
("NM_INTRON.1:n.10_(20_30)delAAAA", "n"),
("NM_INTRON.1:n.10_?delAAAA", "n"),
("NM_INTRON.1:r.10_(20_30)delaaaa", "r"),
("NM_INTRON.1:r.10_?delaaaa", "r"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
assert_end_boundary_refused(
descriptor,
coord,
"without a provider",
hgvs_to_spdi_simple(&variant),
);
assert_end_boundary_refused(
descriptor,
coord,
"with a provider",
hgvs_to_spdi(&variant, &provider),
);
}
for (descriptor, coord) in [
("NM_INTRON.1:c.10_(20_30)delAAAA", "c"),
("NM_INTRON.1:c.10_?delAAAA", "c"),
("NM_INTRON.1:c.10_(20_30)del", "c"),
("NM_INTRON.1:c.10_?del", "c"),
("NM_INTRON.1:r.*5_?delaaaa", "r"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
assert_end_boundary_refused(
descriptor,
coord,
"with a provider",
hgvs_to_spdi(&variant, &provider),
);
}
}
#[test]
fn an_offset_inside_the_range_is_not_what_is_refused() {
let provider = make_intronic_provider();
for descriptor in [
"NM_INTRON.1:c.10_(20+1_21-1)del",
"NM_INTRON.1:c.10_(20+1_21-1)delAAAA",
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
let err = hgvs_to_spdi(&variant, &provider)
.expect_err("a range end boundary names no single coordinate");
let message = err.to_string();
assert!(
message.contains("names no single coordinate"),
"`{descriptor}` must be refused for the absent coordinate: {message}"
);
assert!(
!message.contains("offset"),
"`{descriptor}` was refused for carrying an offset, but an offset is legitimate \
on the `c.` axis — the defect is the absent single coordinate: {message}"
);
}
}
#[test]
fn a_transcript_end_that_names_a_coordinate_still_converts() {
let provider = make_intronic_provider();
let both_paths = [
"NM_INTRON.1:n.10_(13)delAAAA",
"NM_INTRON.1:n.10_13delAAAA",
"NM_INTRON.1:n.10delA",
"NM_INTRON.1:n.10A>G",
"NM_INTRON.1:r.10_(13)delaaaa",
"NM_INTRON.1:r.10_13delaaaa",
"NM_INTRON.1:r.10dela",
];
let provider_only = [
"NM_INTRON.1:c.10_(13)delAAAA",
"NM_INTRON.1:c.10_13delAAAA",
"NM_INTRON.1:c.10delA",
"NM_INTRON.1:c.10del",
];
let mut converted = 0usize;
for descriptor in both_paths {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, result) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
result.unwrap_or_else(|e| {
panic!("`{descriptor}` must still convert {path}, got {e}")
});
converted += 1;
}
}
for descriptor in provider_only {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
hgvs_to_spdi(&variant, &provider)
.unwrap_or_else(|e| panic!("`{descriptor}` must still convert, got {e}"));
converted += 1;
}
assert_eq!(
converted,
both_paths.len() * 2 + provider_only.len(),
"the negative control converted {converted} descriptions; if this shrinks the guard \
has over-generalised, and if the loop stops running it proves nothing"
);
}
#[test]
fn unresolvable_transcript_ends_do_not_collapse_onto_a_resolvable_one() {
const CONVERTIBLE_TODAY: usize = 1;
let provider = make_intronic_provider();
let descriptors = [
"NM_INTRON.1:c.10_(20_30)delAAAA",
"NM_INTRON.1:c.10_?delAAAA",
"NM_INTRON.1:c.10_13delAAAA",
];
let mut seen: Vec<(&str, String)> = Vec::new();
for descriptor in descriptors {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
if let Ok(spdi) = hgvs_to_spdi(&variant, &provider) {
let triple = spdi.to_string();
if let Some((other, _)) = seen.iter().find(|(_, t)| *t == triple) {
panic!(
"`{descriptor}` and `{other}` are different descriptions but share the \
triple `{triple}`"
);
}
seen.push((descriptor, triple));
}
}
assert_eq!(
seen.len(),
CONVERTIBLE_TODAY,
"{} of {} descriptors converted, expected {CONVERTIBLE_TODAY}: {seen:?}. If this \
grew, an unresolvable end is convertible again and the collision check above has \
just become live — read what it now compares before re-pinning this number. If it \
shrank, the loop is comparing nothing at all",
seen.len(),
descriptors.len()
);
}
#[test]
fn an_intronic_start_with_an_unresolvable_end_reports_the_end() {
let provider = make_intronic_provider();
let both_unresolvable = parse_hgvs("NM_INTRON.1:n.10+5_?delAAAA").expect("must parse");
let err = hgvs_to_spdi(&both_unresolvable, &provider)
.expect_err("neither endpoint has an SPDI representation");
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"expected the end-boundary verdict, got {err:?}"
);
assert!(
err.to_string().contains("names no single coordinate"),
"expected the end-boundary reason, got {err}"
);
let intronic_only = parse_hgvs("NM_INTRON.1:n.10+5_20delAAAA").expect("must parse");
let err = hgvs_to_spdi(&intronic_only, &provider)
.expect_err("an intronic transcript position has no SPDI representation");
assert!(
matches!(err, ConversionError::UnrepresentableInSpdi { .. }),
"a resolvable end must leave the intronic decline untouched, got {err:?}"
);
assert!(
err.to_string().contains("genomic projection"),
"the intronic decline lost its own reason: {err}"
);
}
fn three_axis_provider() -> MockProvider {
let mut provider = MockProvider::new();
for accession in ["NC_000001.11", "NC_012920.1", "NC_001416.1"] {
provider.add_genomic_sequence(accession, "ACGTACGTACGTACGTACGTACGTACGT".to_string());
}
provider
}
#[test]
fn a_genomic_offset_inside_a_complex_boundary_is_refused_rather_than_dropped() {
let provider = three_axis_provider();
for (descriptor, coord) in [
("NC_000001.11:g.10_(20+1_30)delACGT", "g"),
("NC_000001.11:g.10_(20-1_30)delACGT", "g"),
("NC_000001.11:g.10_(20_30+1)delACGT", "g"),
("NC_000001.11:g.10_(20+1_30)insTT", "g"),
("NC_000001.11:g.(10+1_11)_20delACGT", "g"),
("NC_012920.1:m.10_(20+1_30)delACGT", "m"),
("NC_001416.1:o.10_(20+1_30)delACGT", "o"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, converted) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
let err = match converted {
Err(err) => err,
Ok(spdi) => panic!(
"`{descriptor}` converted {path} to `{spdi}`; the offset inside the \
complex boundary was dropped"
),
};
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"`{descriptor}` {path} must be refused as InvalidPosition, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("carries a +/- offset"),
"`{descriptor}` {path} was refused for some other reason: {message}"
);
assert!(
message.contains(&format!("{coord}. position")),
"`{descriptor}` {path} named the wrong coordinate axis: {message}"
);
}
}
}
#[test]
fn a_genomic_special_position_inside_a_complex_boundary_is_refused() {
let provider = three_axis_provider();
for (descriptor, coord) in [
("NC_000001.11:g.10_(20_qter)delACGT", "g"),
("NC_000001.11:g.10_(cen_30)delACGT", "g"),
("NC_012920.1:m.10_(20_qter)delACGT", "m"),
("NC_001416.1:o.10_(20_qter)delACGT", "o"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, converted) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
let err = match converted {
Err(err) => err,
Ok(spdi) => panic!(
"`{descriptor}` converted {path} to `{spdi}`; the special position \
inside the complex boundary was flattened"
),
};
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"`{descriptor}` {path} must be refused as InvalidPosition, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("names no numeric coordinate"),
"`{descriptor}` {path} was refused for some other reason: {message}"
);
assert!(
message.contains(&format!("{coord}. position")),
"`{descriptor}` {path} named the wrong coordinate axis: {message}"
);
}
}
}
#[test]
fn an_unresolvable_genomic_end_boundary_is_refused_rather_than_collapsed() {
let provider = three_axis_provider();
for (descriptor, coord) in [
("NC_000001.11:g.10_(20_30)delACGT", "g"),
("NC_000001.11:g.10_?delACGT", "g"),
("NC_000001.11:g.10_(20_30)insTT", "g"),
("NC_000001.11:g.10_(20_30)dupACGT", "g"),
("NC_012920.1:m.10_(20_30)delACGT", "m"),
("NC_012920.1:m.10_?delACGT", "m"),
("NC_001416.1:o.10_(20_30)delACGT", "o"),
("NC_001416.1:o.10_?delACGT", "o"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, converted) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
let err = match converted {
Err(err) => err,
Ok(spdi) => panic!(
"`{descriptor}` converted {path} to `{spdi}`; the end boundary was \
collapsed onto the start position"
),
};
assert!(
matches!(err, ConversionError::InvalidPosition { .. }),
"`{descriptor}` {path} must be refused as InvalidPosition, got {err:?}"
);
let message = err.to_string();
assert!(
message.contains("names no single coordinate"),
"`{descriptor}` {path} was refused for some other reason: {message}"
);
assert!(
message.contains(&format!("{coord}. end boundary")),
"`{descriptor}` {path} named the wrong coordinate axis: {message}"
);
}
}
}
#[test]
fn a_resolvable_genomic_end_boundary_still_converts() {
let provider = three_axis_provider();
for (descriptor, expected) in [
("NC_000001.11:g.10_13delACGT", "NC_000001.11:9:ACGT:"),
("NC_000001.11:g.10_(13)delACGT", "NC_000001.11:9:ACGT:"),
("NC_000001.11:g.(10)_(13)delACGT", "NC_000001.11:9:ACGT:"),
("NC_000001.11:g.10C>G", "NC_000001.11:9:C:G"),
("NC_012920.1:m.10_13delACGT", "NC_012920.1:9:ACGT:"),
("NC_001416.1:o.10_13delACGT", "NC_001416.1:9:ACGT:"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
for (path, converted) in [
("without a provider", hgvs_to_spdi_simple(&variant)),
("with a provider", hgvs_to_spdi(&variant, &provider)),
] {
let spdi = converted
.unwrap_or_else(|err| panic!("`{descriptor}` {path} must convert: {err}"));
assert_eq!(
spdi.to_string(),
expected,
"`{descriptor}` {path} moved to a different triple"
);
}
}
}
#[test]
fn complex_boundaries_do_not_collapse_onto_the_offset_free_spelling() {
const CONVERTIBLE_TODAY: usize = 1;
let provider = three_axis_provider();
let descriptors = [
"NC_000001.11:g.10_(20+1_30)delACGT",
"NC_000001.11:g.10_(20+5_30)delACGT",
"NC_000001.11:g.10_(20-1_30)delACGT",
"NC_000001.11:g.10_(20_30)delACGT",
"NC_000001.11:g.10delACGT",
];
let mut displayed: Vec<String> = descriptors
.iter()
.map(|d| parse_hgvs(d).expect("fixture must parse").to_string())
.collect();
displayed.sort();
displayed.dedup();
assert_eq!(
displayed.len(),
descriptors.len(),
"the descriptions do not survive parse/Display distinctly: {displayed:?}"
);
let mut seen: Vec<(&str, String)> = Vec::new();
for descriptor in descriptors {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
if let Ok(spdi) = hgvs_to_spdi(&variant, &provider) {
let triple = spdi.to_string();
if let Some((other, _)) = seen.iter().find(|(_, t)| *t == triple) {
panic!(
"`{descriptor}` and `{other}` are different descriptions but share \
the triple `{triple}`"
);
}
seen.push((descriptor, triple));
}
}
assert_eq!(
seen.len(),
CONVERTIBLE_TODAY,
"{} of {} descriptors converted, expected {CONVERTIBLE_TODAY}: {seen:?}. \
If this grew, a complex boundary is convertible again and the collision check \
above has just become live — read what it now compares before re-pinning this \
number. If it shrank, `g.10delACGT` stopped converting and the loop is \
comparing nothing at all",
seen.len(),
descriptors.len()
);
}
}