use crate::error::FerroError;
use crate::hgvs::variant::HgvsVariant;
use crate::normalize::footprint::WriteFootprint;
use crate::reference::ReferenceProvider;
use crate::spdi::convert::{apply_alphabet, AlphabetMode};
use crate::spdi::SpdiVariant;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppliedVariant {
pub accession: String,
pub start: u64,
pub reference: String,
pub resulting: String,
}
pub const MAX_APPLY_WINDOW: u64 = 100_000;
pub const MAX_SHIFT_TRACT: u64 = 32_768;
pub fn apply_to_reference<P: ReferenceProvider + ?Sized>(
variant: &HgvsVariant,
provider: &P,
) -> Result<AppliedVariant, FerroError> {
apply_to_reference_padded(variant, provider, 0).map(|(applied, _)| applied)
}
pub(crate) fn apply_to_reference_padded<P: ReferenceProvider + ?Sized>(
variant: &HgvsVariant,
provider: &P,
pad_3prime: u64,
) -> Result<(AppliedVariant, bool), FerroError> {
let decline = |reason: &str| FerroError::UnsupportedVariant {
variant_type: format!("{variant}: cannot apply to reference — {reason}"),
};
let (accession, triples) = variant_edit_triples(variant, provider).ok_or_else(|| {
decline(
"no single resulting sequence is defined for it — it is a multi-molecule \
or null allele, spans more than one accession, or carries an edit SPDI \
cannot represent",
)
})?;
let (mut start, mut end) = (u64::MAX, u64::MIN);
for triple in &triples {
start = start.min(triple.position);
let triple_end = triple
.position
.checked_add(triple.deletion.len() as u64)
.ok_or_else(|| decline("its span overflows the coordinate space"))?;
end = end.max(triple_end);
}
if start > end {
return Err(decline("its members name no reference span"));
}
if end - start > MAX_APPLY_WINDOW {
return Err(decline(&format!(
"it spans {} bases, more than the {MAX_APPLY_WINDOW}-base limit",
end - start
)));
}
let requested_end = end.saturating_add(pad_3prime);
let known_length = provider.get_sequence_length(&accession).ok();
let wanted_end = known_length.map_or(requested_end, |length| requested_end.min(length));
let reference = fetch_window(provider, &accession, start, wanted_end).ok_or_else(|| {
if wanted_end > end {
decline(
"its reference window could not be widened far enough to settle where the \
change ends — the provider served neither the wider span nor a length to \
bound it by",
)
} else {
decline("its reference window could not be read")
}
})?;
let window_is_final =
wanted_end == requested_end || known_length.is_some_and(|length| wanted_end == length);
let resulting = apply_triples(&reference, start, &triples).ok_or_else(|| {
decline(
"its members overlap, or a stated reference base disagrees with the \
reference",
)
})?;
Ok((
AppliedVariant {
accession,
start,
reference,
resulting,
},
window_is_final,
))
}
pub fn canonical_spdi<P: ReferenceProvider + ?Sized>(
variant: &HgvsVariant,
provider: &P,
) -> Result<SpdiVariant, FerroError> {
const FIRST_PAD: u64 = 64;
let alphabet = key_alphabet(variant);
let mut pad = 0u64;
loop {
let (applied, window_is_final) = apply_to_reference_padded(variant, provider, pad)?;
let (offset, deletion, insertion) =
trim_common_flanks(applied.reference.as_bytes(), applied.resulting.as_bytes());
let is_no_op = deletion.is_empty() && insertion.is_empty();
let reaches_edge = offset + deletion.len() == applied.reference.len();
if is_no_op || !reaches_edge || !window_is_final {
return Ok(SpdiVariant {
sequence: applied.accession,
position: applied.start + offset as u64,
deletion: apply_alphabet(&String::from_utf8_lossy(deletion), alphabet),
insertion: apply_alphabet(&String::from_utf8_lossy(insertion), alphabet),
});
}
if pad >= MAX_SHIFT_TRACT {
return Err(FerroError::UnsupportedVariant {
variant_type: format!(
"{variant}: cannot derive a stable key — it sits in a repeat tract \
still running past {MAX_SHIFT_TRACT} bases 3' of it, so how far the \
change shifts depends on how much reference is read"
),
});
}
pad = if pad == 0 {
FIRST_PAD
} else {
pad.saturating_mul(2)
};
}
}
fn key_alphabet(variant: &HgvsVariant) -> AlphabetMode {
fn is_rna(variant: &HgvsVariant) -> bool {
matches!(variant, HgvsVariant::Rna(_))
}
match variant {
HgvsVariant::Allele(allele) if allele.variants.iter().any(is_rna) => AlphabetMode::Rna,
single if is_rna(single) => AlphabetMode::Rna,
_ => AlphabetMode::Dna,
}
}
fn trim_common_flanks<'a>(reference: &'a [u8], resulting: &'a [u8]) -> (usize, &'a [u8], &'a [u8]) {
let max_prefix = reference.len().min(resulting.len());
let mut prefix = 0;
while prefix < max_prefix && reference[prefix].eq_ignore_ascii_case(&resulting[prefix]) {
prefix += 1;
}
let mut suffix = 0;
while suffix < max_prefix - prefix
&& reference[reference.len() - 1 - suffix]
.eq_ignore_ascii_case(&resulting[resulting.len() - 1 - suffix])
{
suffix += 1;
}
(
prefix,
&reference[prefix..reference.len() - suffix],
&resulting[prefix..resulting.len() - suffix],
)
}
pub(crate) fn apply_triples(
reference: &str,
win_start: u64,
triples: &[SpdiVariant],
) -> Option<String> {
apply_triples_classified(reference, win_start, triples).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApplyDecline {
ReferenceMismatch,
MembersOverlap,
ContractViolated,
}
impl ApplyDecline {
#[cfg(test)]
pub(crate) const ALL: [Self; 3] = [
Self::ReferenceMismatch,
Self::MembersOverlap,
Self::ContractViolated,
];
pub(crate) fn governing(self, other: Self) -> Self {
if other.precedence() < self.precedence() {
other
} else {
self
}
}
const fn precedence(self) -> u8 {
match self {
Self::ContractViolated => 0,
Self::ReferenceMismatch => 1,
Self::MembersOverlap => 2,
}
}
}
pub(crate) fn apply_triples_classified(
reference: &str,
win_start: u64,
triples: &[SpdiVariant],
) -> Result<String, ApplyDecline> {
let ref_bytes = reference.as_bytes();
let mut bytes = ref_bytes.to_vec();
let mut ordered: Vec<&SpdiVariant> = triples.iter().collect();
ordered.sort_by_key(|t| {
(
std::cmp::Reverse(t.position),
std::cmp::Reverse(t.deletion.len()),
)
});
if !triples_are_disjoint(&ordered) {
return Err(ApplyDecline::MembersOverlap);
}
for t in ordered {
let rel = t
.position
.checked_sub(win_start)
.ok_or(ApplyDecline::ContractViolated)? as usize;
let end = rel
.checked_add(t.deletion.len())
.ok_or(ApplyDecline::ContractViolated)?;
if end > ref_bytes.len() {
return Err(ApplyDecline::ContractViolated);
}
if !ref_bytes[rel..end].eq_ignore_ascii_case(t.deletion.as_bytes()) {
return Err(ApplyDecline::ReferenceMismatch);
}
if end > bytes.len() {
return Err(ApplyDecline::ContractViolated);
}
bytes.splice(rel..end, t.insertion.bytes());
}
String::from_utf8(bytes).map_err(|_| ApplyDecline::ContractViolated)
}
fn triples_are_disjoint(ordered: &[&SpdiVariant]) -> bool {
let footprints: Vec<WriteFootprint<u64>> =
ordered.iter().map(|t| triple_footprint(t)).collect();
for (i, first) in footprints.iter().enumerate() {
if footprints[i + 1..]
.iter()
.any(|second| first.obstructs_splice_of(second))
{
return false;
}
}
true
}
fn triple_footprint(triple: &SpdiVariant) -> WriteFootprint<u64> {
let length = triple.deletion.len() as u64;
if length == 0 {
return WriteFootprint::at_junction(triple.position);
}
WriteFootprint::spanning(
triple.position + 1,
triple.position + length,
!triple.insertion.is_empty(),
)
}
pub(crate) fn variant_edit_triples<P: ReferenceProvider + ?Sized>(
variant: &HgvsVariant,
provider: &P,
) -> Option<(String, Vec<SpdiVariant>)> {
variant_edit_triples_reason(variant, provider).ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NoTriples {
Untransliterable,
SelfContradictory,
}
pub(crate) fn variant_edit_triples_reason<P: ReferenceProvider + ?Sized>(
variant: &HgvsVariant,
provider: &P,
) -> Result<(String, Vec<SpdiVariant>), NoTriples> {
use crate::hgvs::variant::AllelePhase;
let members: Vec<&HgvsVariant> = match variant {
HgvsVariant::Allele(allele) => {
if allele.phase != AllelePhase::Cis {
return Err(NoTriples::Untransliterable);
}
allele.variants.iter().collect()
}
HgvsVariant::NullAllele | HgvsVariant::UnknownAllele => {
return Err(NoTriples::Untransliterable)
}
single => vec![single],
};
if members.is_empty() {
return Err(NoTriples::Untransliterable);
}
let mut accession: Option<String> = None;
let mut triples = Vec::with_capacity(members.len());
for member in members {
let spdi =
crate::spdi::hgvs_to_spdi(member, provider).map_err(|_| NoTriples::Untransliterable)?;
match &accession {
None => accession = Some(spdi.sequence.clone()),
Some(acc) if *acc != spdi.sequence => return Err(NoTriples::Untransliterable),
Some(_) => {}
}
triples.push(spdi);
}
let mut zero_width: Vec<u64> = triples
.iter()
.filter(|t| t.deletion.is_empty())
.map(|t| t.position)
.collect();
zero_width.sort_unstable();
if zero_width.windows(2).any(|pair| pair[0] == pair[1]) {
return Err(NoTriples::SelfContradictory);
}
accession
.map(|acc| (acc, triples))
.ok_or(NoTriples::Untransliterable)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NotComparable {
InputDenotesNoSequence,
OutputUntransliterable,
UncertainAllele,
UnresolvableSpecialPosition,
AccessionChanged,
WindowTooWide,
ReferenceUnreadable,
}
impl std::fmt::Display for NotComparable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let reason = match self {
Self::InputDenotesNoSequence => "the input denotes no single sequence",
Self::OutputUntransliterable => "the output cannot be transliterated to SPDI",
Self::UncertainAllele => "one side is a predicted `[(…)]` allele",
Self::UnresolvableSpecialPosition => "one side names pter/qter/cen",
Self::AccessionChanged => "the two descriptions name different accessions",
Self::WindowTooWide => "their union spans more than MAX_APPLY_WINDOW bases",
Self::ReferenceUnreadable => "the provider could not serve the union window",
};
f.write_str(reason)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DenotedSequenceComparison {
Agree,
Differ {
accession: String,
start: u64,
reference: String,
from_input: String,
from_output: String,
},
OutputContradictsItself,
NotComparable(NotComparable),
}
fn is_uncertain_allele(variant: &HgvsVariant) -> bool {
matches!(variant, HgvsVariant::Allele(allele) if allele.uncertain)
}
fn names_a_special_position(variant: &HgvsVariant) -> bool {
use crate::hgvs::interval::{Interval, UncertainBoundary};
use crate::hgvs::location::{CdsPos, GenomePos};
fn boundary_is_special<T>(boundary: &UncertainBoundary<T>, special: fn(&T) -> bool) -> bool {
match boundary {
UncertainBoundary::Single(mu) => mu.inner().is_some_and(special),
UncertainBoundary::Range { start, end } => {
start.inner().is_some_and(special) || end.inner().is_some_and(special)
}
}
}
fn interval_is_special<T>(interval: &Interval<T>, special: fn(&T) -> bool) -> bool {
boundary_is_special(&interval.start, special) || boundary_is_special(&interval.end, special)
}
fn genomic(interval: &Interval<GenomePos>) -> bool {
interval_is_special(interval, |p| p.special.is_some())
}
match variant {
HgvsVariant::Allele(allele) => allele.variants.iter().any(names_a_special_position),
HgvsVariant::Genome(v) => genomic(&v.loc_edit.location),
HgvsVariant::Mt(v) => genomic(&v.loc_edit.location),
HgvsVariant::Circular(v) => genomic(&v.loc_edit.location),
HgvsVariant::Cds(v) => {
interval_is_special(&v.loc_edit.location, |p: &CdsPos| p.special.is_some())
}
_ => false,
}
}
pub fn compare_denoted_sequences<P: ReferenceProvider + ?Sized>(
input: &HgvsVariant,
output: &HgvsVariant,
provider: &P,
) -> DenotedSequenceComparison {
use DenotedSequenceComparison as Outcome;
if names_a_special_position(input) || names_a_special_position(output) {
return Outcome::NotComparable(NotComparable::UnresolvableSpecialPosition);
}
if is_uncertain_allele(input) || is_uncertain_allele(output) {
return Outcome::NotComparable(NotComparable::UncertainAllele);
}
let Ok((accession, input_triples)) = variant_edit_triples_reason(input, provider) else {
return Outcome::NotComparable(NotComparable::InputDenotesNoSequence);
};
let (output_accession, output_triples) = match variant_edit_triples_reason(output, provider) {
Ok(pair) => pair,
Err(NoTriples::SelfContradictory) => return Outcome::OutputContradictsItself,
Err(NoTriples::Untransliterable) => {
return Outcome::NotComparable(NotComparable::OutputUntransliterable)
}
};
if accession != output_accession {
return Outcome::NotComparable(NotComparable::AccessionChanged);
}
let (mut start, mut end) = (u64::MAX, u64::MIN);
for triple in input_triples.iter().chain(&output_triples) {
start = start.min(triple.position);
let Some(triple_end) = triple.position.checked_add(triple.deletion.len() as u64) else {
return Outcome::NotComparable(NotComparable::WindowTooWide);
};
end = end.max(triple_end);
}
if start > end || end - start > MAX_APPLY_WINDOW {
return Outcome::NotComparable(NotComparable::WindowTooWide);
}
let Some(reference) = fetch_window(provider, &accession, start, end) else {
return Outcome::NotComparable(NotComparable::ReferenceUnreadable);
};
let from_input = match splice_denoted_sequence(&reference, start, &input_triples) {
Ok(bases) => bases,
Err(_) => return Outcome::NotComparable(NotComparable::InputDenotesNoSequence),
};
let from_output = match splice_denoted_sequence(&reference, start, &output_triples) {
Ok(bases) => bases,
Err(SpliceFailure::Overlapping) => return Outcome::OutputContradictsItself,
Err(SpliceFailure::StatedBasesMismatch) => {
return Outcome::NotComparable(NotComparable::OutputUntransliterable)
}
};
if same_bases(&from_input, &from_output) {
Outcome::Agree
} else {
Outcome::Differ {
accession,
start,
reference,
from_input,
from_output,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpliceFailure {
Overlapping,
StatedBasesMismatch,
}
fn splice_denoted_sequence(
reference: &str,
win_start: u64,
triples: &[SpdiVariant],
) -> Result<String, SpliceFailure> {
let reference = reference.as_bytes();
let mut ordered: Vec<&SpdiVariant> = triples.iter().collect();
ordered.sort_by_key(|t| {
(
std::cmp::Reverse(t.position),
std::cmp::Reverse(t.deletion.len()),
)
});
let mut edited = reference.to_vec();
let mut claimed_from = reference.len();
for triple in ordered {
let Some(start) = triple
.position
.checked_sub(win_start)
.and_then(|offset| usize::try_from(offset).ok())
else {
return Err(SpliceFailure::StatedBasesMismatch);
};
let Some(end) = start.checked_add(triple.deletion.len()) else {
return Err(SpliceFailure::StatedBasesMismatch);
};
if end > reference.len() {
return Err(SpliceFailure::StatedBasesMismatch);
}
if end > claimed_from {
return Err(SpliceFailure::Overlapping);
}
if !same_bases_bytes(&reference[start..end], triple.deletion.as_bytes()) {
return Err(SpliceFailure::StatedBasesMismatch);
}
if end > edited.len() {
return Err(SpliceFailure::StatedBasesMismatch);
}
edited.splice(start..end, triple.insertion.bytes());
claimed_from = start;
}
String::from_utf8(edited).map_err(|_| SpliceFailure::StatedBasesMismatch)
}
fn canonical_base(b: u8) -> u8 {
match b.to_ascii_uppercase() {
b'U' => b'T',
other => other,
}
}
fn same_bases(left: &str, right: &str) -> bool {
same_bases_bytes(left.as_bytes(), right.as_bytes())
}
fn same_bases_bytes(left: &[u8], right: &[u8]) -> bool {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(l, r)| canonical_base(*l) == canonical_base(*r))
}
pub(crate) fn fetch_window<P: ReferenceProvider + ?Sized>(
provider: &P,
accession: &str,
start: u64,
end: u64,
) -> Option<String> {
let bases = provider
.get_genomic_sequence(accession, start, end)
.or_else(|_| provider.get_sequence(accession, start, end))
.ok()?;
if bases.len() as u64 != end - start {
return None;
}
Some(bases)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hgvs::parser::parse_hgvs;
use crate::reference::MockProvider;
fn provider() -> MockProvider {
let mut provider = MockProvider::new();
provider.add_genomic_sequence("NC_KEY.1", "GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT");
provider.add_genomic_sequence("NC_OTHER.1", "TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT");
provider
}
fn key(descriptor: &str) -> SpdiVariant {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
canonical_spdi(&variant, &provider())
.unwrap_or_else(|e| panic!("`{descriptor}` must canonicalize: {e}"))
}
#[test]
fn a_spanning_delins_and_its_decomposition_share_one_key() {
let spanning = key("NC_KEY.1:g.3_7delinsGGCTA");
let decomposed = key("NC_KEY.1:g.[3A>G;4T>G;5T>C;6A>T;7C>A]");
assert_eq!(
spanning, decomposed,
"the same edit written two ways must give one key"
);
assert_eq!(spanning.sequence, "NC_KEY.1");
assert_eq!(
(spanning.deletion.as_str(), spanning.insertion.as_str()),
("ATTAC", "GGCTA")
);
}
#[test]
fn member_order_does_not_change_the_key() {
assert_eq!(key("NC_KEY.1:g.[3A>G;7C>A]"), key("NC_KEY.1:g.[7C>A;3A>G]"));
}
#[test]
fn the_pad_grows_until_it_contains_the_roll_then_declines() {
let homopolymer = |run: usize| {
let mut provider = MockProvider::new();
provider.add_genomic_sequence("NC_RUN.1", format!("C{}C", "A".repeat(run)));
provider
};
let provider = homopolymer(300);
let key_of = |descriptor: &str| {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
canonical_spdi(&variant, &provider).expect("must canonicalize")
};
let first = key_of("NC_RUN.1:g.2del");
assert_eq!(first, key_of("NC_RUN.1:g.150del"));
assert_eq!(first, key_of("NC_RUN.1:g.301del"));
assert_eq!(
(first.position, first.deletion.as_str()),
(300, "A"),
"the deletion must roll to the 3' end of the run"
);
let huge = homopolymer(MAX_SHIFT_TRACT as usize + 1_000);
let variant = parse_hgvs("NC_RUN.1:g.2del").expect("fixture must parse");
let error = canonical_spdi(&variant, &huge)
.expect_err("a tract past the cap has no window-independent key");
let message = error.to_string();
assert!(
message.contains("repeat tract"),
"the decline must name the tract, not read as a generic failure; got: {message}"
);
}
#[test]
fn coincident_insertions_are_declined_rather_than_ordered() {
for descriptor in [
"NC_KEY.1:g.[5_6insA;5_6insC]",
"NC_KEY.1:g.[5_6insC;5_6insA]",
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
assert!(
canonical_spdi(&variant, &provider()).is_err(),
"`{descriptor}` has no order between its two insertions, so no single \
key describes it — declining is the answer, not picking one"
);
}
let ordered = key("NC_KEY.1:g.5_6insAC");
assert_eq!((ordered.position, ordered.insertion.as_str()), (8, "CA"));
}
#[test]
fn one_change_spelled_two_ways_in_a_tract_keys_once() {
let first = key("NC_KEY.1:g.1del");
assert_eq!(first, key("NC_KEY.1:g.2del"));
assert_eq!((first.position, first.deletion.as_str()), (1, "G"));
let inner = key("NC_KEY.1:g.9del");
assert_eq!(inner, key("NC_KEY.1:g.10del"));
assert_eq!((inner.position, inner.deletion.as_str()), (9, "G"));
let rolled = key("NC_KEY.1:g.3_5del");
assert_eq!(rolled, key("NC_KEY.1:g.4_6del"));
assert_eq!((rolled.position, rolled.deletion.as_str()), (3, "TTA"));
let inserted = key("NC_KEY.1:g.13_14insT");
assert_eq!(inserted, key("NC_KEY.1:g.14dup"));
assert_eq!((inserted.position, inserted.insertion.as_str()), (14, "T"));
}
#[test]
fn the_key_is_the_minimal_changed_block() {
let deletion = key("NC_KEY.1:g.3_5del");
assert_eq!(deletion.deletion, "TTA");
assert_eq!(deletion.insertion, "");
let insertion = key("NC_KEY.1:g.5_6insCCC");
assert_eq!(insertion.deletion, "");
assert_eq!(insertion.insertion, "CCC");
}
#[test]
fn different_edits_get_different_keys() {
assert_ne!(key("NC_KEY.1:g.3A>G"), key("NC_KEY.1:g.3A>C"));
assert_ne!(key("NC_KEY.1:g.3A>G"), key("NC_KEY.1:g.4T>G"));
assert_ne!(key("NC_KEY.1:g.3_5del"), key("NC_KEY.1:g.3_6del"));
}
#[test]
fn apply_to_reference_returns_both_windows() {
let variant = parse_hgvs("NC_KEY.1:g.3_7delinsGGCTA").unwrap();
let applied = apply_to_reference(&variant, &provider()).expect("applies");
assert_eq!(applied.accession, "NC_KEY.1");
assert_eq!(applied.start, 2, "0-based interbase start of g.3");
assert_eq!(applied.reference, "ATTAC");
assert_eq!(applied.resulting, "GGCTA");
}
#[test]
fn shapes_without_one_resulting_sequence_decline() {
for (descriptor, why) in [
(
"NC_KEY.1:g.[3_5del;4T>G]",
"overlapping members — applying them depends on order",
),
(
"NC_KEY.1:g.[3A>G(;)7C>A]",
"trans phase — two molecules, not one sequence",
),
(
"[NC_KEY.1:g.3A>G;NC_OTHER.1:g.7T>G]",
"members on different accessions",
),
] {
let variant = parse_hgvs(descriptor)
.unwrap_or_else(|e| panic!("fixture `{descriptor}` must parse: {e}"));
assert!(
apply_to_reference(&variant, &provider()).is_err(),
"`{descriptor}` must decline ({why})"
);
assert!(
canonical_spdi(&variant, &provider()).is_err(),
"`{descriptor}` must decline for the key too ({why})"
);
}
}
#[test]
fn an_insertion_interior_to_a_deletion_is_declined_by_the_applier() {
let descriptor = "NC_KEY.1:g.[10_20del;14_15insCC]";
let variant = parse_hgvs(descriptor).expect("fixture must parse");
assert!(
apply_to_reference(&variant, &provider()).is_err(),
"`{descriptor}`: the insertion's junction is strictly interior to \
the deletion, so the 3' -> 5' walk cannot execute the pair in one \
pass and the applier must decline rather than return a sequence"
);
assert!(
canonical_spdi(&variant, &provider()).is_err(),
"`{descriptor}`: the published key must decline it for the same \
reason — a key may not be derived from a splice that cannot run"
);
}
#[test]
fn an_insertion_flush_against_that_deletion_still_applies() {
for descriptor in [
"NC_KEY.1:g.[10_20del;20_21insCC]",
"NC_KEY.1:g.[10_20del;9_10insCC]",
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
assert!(
apply_to_reference(&variant, &provider()).is_ok(),
"`{descriptor}`: a junction flush against a deletion's edge is \
not interior to it, so the pair splices in one walk"
);
}
}
#[test]
fn a_plain_single_member_variant_applies() {
let variant = parse_hgvs("NC_KEY.1:g.3A>G").expect("must parse");
assert!(apply_to_reference(&variant, &provider()).is_ok());
assert!(canonical_spdi(&variant, &provider()).is_ok());
}
#[test]
fn an_unreadable_reference_declines() {
let variant = parse_hgvs("NC_ABSENT.1:g.3A>G").unwrap();
assert!(apply_to_reference(&variant, &provider()).is_err());
}
fn transcript_provider(spell_uracil: bool) -> MockProvider {
use crate::reference::transcript::{Exon, GenomeBuild, ManeStatus, Strand, Transcript};
const DNA: &str = "GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT";
let sequence = if spell_uracil {
DNA.replace('T', "U")
} else {
DNA.to_string()
};
let length = sequence.len() as u64;
let mut provider = MockProvider::new();
provider.add_transcript(Transcript::new(
"NR_KEY.1".to_string(),
Some("SYNTH".to_string()),
Strand::Plus,
sequence.clone(),
None,
None,
vec![Exon::with_genomic(1, 1, length, 1, length)],
Some("chr_key".to_string()),
Some(1),
Some(length),
GenomeBuild::GRCh38,
ManeStatus::None,
None,
None,
));
provider.add_genomic_sequence("chr_key", sequence);
provider
}
#[test]
fn the_alphabet_fold_reconciles_case_and_bounds_the_uracil_case() {
for descriptor in [
"NC_KEY.1:g.3_7delinsGGCTA",
"NC_KEY.1:g.3_5del",
"NC_KEY.1:g.3_5dup",
"NC_KEY.1:g.3_7inv",
"NC_KEY.1:g.3A>G",
"NC_KEY.1:g.5_6insAC",
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
let mut masked = MockProvider::new();
masked.add_genomic_sequence(
"NC_KEY.1",
"GGATTACAGGCATTAGCCTGAGGATTACAGGCATTAGCCT".to_ascii_lowercase(),
);
let upper =
canonical_spdi(&variant, &provider()).expect("the uppercase reference keys");
let lower = canonical_spdi(&variant, &masked).expect("the soft-masked reference keys");
assert_eq!(
upper, lower,
"`{descriptor}` must key identically on a soft-masked reference"
);
assert!(
upper.deletion.chars().all(|c| !c.is_ascii_lowercase())
&& upper.insertion.chars().all(|c| !c.is_ascii_lowercase()),
"`{descriptor}` emitted lowercase bases: {upper}"
);
}
let dna = transcript_provider(false);
let uracil = transcript_provider(true);
let keyed = |descriptor: &str, provider: &MockProvider| {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
canonical_spdi(&variant, provider)
.ok()
.map(|spdi| spdi.to_string())
};
for descriptor in [
"NR_KEY.1:r.[3a>g;7c>a]",
"NR_KEY.1:r.3a>g",
"NR_KEY.1:r.14dup",
] {
assert_eq!(
keyed(descriptor, &dna),
keyed(descriptor, &uracil),
"`{descriptor}` is a shape the fold does reconcile"
);
}
assert_eq!(
keyed("NR_KEY.1:r.[3a>g;7c>a]", &uracil).as_deref(),
Some("NR_KEY.1:2:ATTAC:GTTAA"),
"and it reconciles by folding, not by refusing both sides"
);
for descriptor in [
"NR_KEY.1:r.3_5del",
"NR_KEY.1:r.3_7delinsggcua",
"NR_KEY.1:r.3_7inv",
] {
assert!(
keyed(descriptor, &dna).is_some(),
"`{descriptor}` keys on the RefSeq spelling"
);
assert_eq!(
keyed(descriptor, &uracil),
None,
"`{descriptor}` is declined on a uracil provider — `apply_triples` \
validates the stated deletion case-insensitively, not alphabet-insensitively"
);
}
assert_eq!(
(
keyed("NR_KEY.1:r.13_14insu", &dna).as_deref(),
keyed("NR_KEY.1:r.13_14insu", &uracil).as_deref()
),
(Some("NR_KEY.1:14::T"), Some("NR_KEY.1:13::T")),
"the 3' roll is cut short on a uracil provider"
);
}
#[test]
fn a_mixed_axis_cis_allele_folds_on_any_rna_member() {
let uracil = transcript_provider(true);
let keyed = |descriptor: &str| {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
canonical_spdi(&variant, &uracil)
.unwrap_or_else(|e| panic!("`{descriptor}` must key: {e}"))
.to_string()
};
assert_eq!(
keyed("[NR_KEY.1:r.3a>g;NR_KEY.1:n.7C>A]"),
"NR_KEY.1:2:ATTAC:GTTAA",
"one `r.` member folds the whole key"
);
assert_eq!(
keyed("NR_KEY.1:n.[3A>G;7C>A]"),
"NR_KEY.1:2:AUUAC:GUUAA",
"the same allele with no `r.` member is not folded — the control that \
the `r.` member is what selects `AlphabetMode::Rna`"
);
}
#[test]
fn an_insertion_flush_against_a_deletion_or_inversion_applies() {
for (descriptor, resulting) in [
("NC_KEY.1:g.[10_11insAC;11_13del]", "AC"),
("NC_KEY.1:g.[11_13del;10_11insAC]", "AC"),
("NC_KEY.1:g.[10_11insAC;11_13inv]", "ACATG"),
("NC_KEY.1:g.[11_13inv;10_11insAC]", "ACATG"),
] {
let variant = parse_hgvs(descriptor).expect("fixture must parse");
let applied = apply_to_reference(&variant, &provider())
.unwrap_or_else(|e| panic!("`{descriptor}` must apply: {e}"));
assert_eq!(applied.start, 10, "0-based interbase start of base 11");
assert_eq!(applied.reference, "CAT", "bases 11-13 of the fixture");
assert_eq!(
applied.resulting, resulting,
"`{descriptor}`: the insertion lands 5' of the span, which then \
rewrites its own bases"
);
}
}
#[test]
fn an_insertion_interior_to_an_inversion_declines() {
let variant = parse_hgvs("NC_KEY.1:g.[10_13inv;11_12insAC]").expect("must parse");
assert!(
apply_to_reference(&variant, &provider()).is_err(),
"an insertion interior to an inversion has no single resulting sequence"
);
}
fn triple(position: u64, deletion: &str, insertion: &str) -> SpdiVariant {
SpdiVariant {
sequence: "NC_TEST.1".to_string(),
position,
deletion: deletion.to_string(),
insertion: insertion.to_string(),
}
}
#[test]
fn a_flush_insertion_is_disjoint_in_either_member_order() {
let deletion = triple(4, "TTTTT", "");
let insertion = triple(4, "", "A");
for (label, triples) in [
("deletion first", vec![deletion.clone(), insertion.clone()]),
("insertion first", vec![insertion.clone(), deletion.clone()]),
] {
let mut ordered: Vec<&SpdiVariant> = triples.iter().collect();
ordered.sort_by_key(|t| std::cmp::Reverse(t.position));
assert!(
triples_are_disjoint(&ordered),
"{label}: an insertion at the 5' edge of a deletion claims no \
base, so it is disjoint from it whichever order the members \
were written in",
);
}
}
#[test]
fn disjointness_does_not_depend_on_member_order() {
let cases = [
(triple(4, "TTTTT", ""), triple(4, "", "A")),
(triple(10, "AC", "G"), triple(10, "", "TT")),
(triple(7, "G", ""), triple(7, "", "C")),
];
for (span, zero_width) in cases {
let forward = {
let v = [span.clone(), zero_width.clone()];
let mut o: Vec<&SpdiVariant> = v.iter().collect();
o.sort_by_key(|t| std::cmp::Reverse(t.position));
triples_are_disjoint(&o)
};
let reverse = {
let v = [zero_width.clone(), span.clone()];
let mut o: Vec<&SpdiVariant> = v.iter().collect();
o.sort_by_key(|t| std::cmp::Reverse(t.position));
triples_are_disjoint(&o)
};
assert_eq!(
forward, reverse,
"disjointness of {span:?} and {zero_width:?} changed with member \
order: {forward} vs {reverse}",
);
}
}
#[test]
fn all_declines_are_listed_exactly_once() {
let mut ranks: Vec<u8> = ApplyDecline::ALL.iter().map(|d| d.precedence()).collect();
ranks.sort_unstable();
let expected: Vec<u8> = (0..ApplyDecline::ALL.len() as u8).collect();
assert_eq!(
ranks,
expected,
"ApplyDecline::ALL must hold every variant exactly once, with distinct \
precedences; got {ranks:?} for {:?}",
ApplyDecline::ALL,
);
}
#[test]
fn a_reference_mismatch_governs_a_member_overlap() {
assert_eq!(
ApplyDecline::ReferenceMismatch.governing(ApplyDecline::MembersOverlap),
ApplyDecline::ReferenceMismatch,
);
assert_eq!(
ApplyDecline::MembersOverlap.governing(ApplyDecline::ReferenceMismatch),
ApplyDecline::ReferenceMismatch,
);
}
#[test]
fn a_contract_violation_governs_every_other_decline() {
for other in ApplyDecline::ALL {
assert_eq!(
ApplyDecline::ContractViolated.governing(other),
ApplyDecline::ContractViolated,
"ContractViolated must govern {other:?}",
);
assert_eq!(
other.governing(ApplyDecline::ContractViolated),
ApplyDecline::ContractViolated,
"ContractViolated must govern {other:?} from the other side too",
);
}
}
#[test]
fn governing_is_symmetric_and_idempotent() {
for first in ApplyDecline::ALL {
assert_eq!(first.governing(first), first, "{first:?} is not idempotent");
for second in ApplyDecline::ALL {
assert_eq!(
first.governing(second),
second.governing(first),
"governing is not symmetric for {first:?} and {second:?}",
);
}
}
}
}