use std::collections::BTreeMap;
use crate::hgvs::edit::NaEdit;
use crate::hgvs::interval::{Interval, UncertainBoundary};
use crate::hgvs::location::{CdsPos, GenomePos, RnaPos, TxPos};
use crate::hgvs::uncertainty::Mu;
use crate::hgvs::variant::{Accession, AllelePhase, HgvsVariant, LocEdit};
use crate::normalize::footprint::WriteFootprint;
use crate::normalize::merge::Region;
use crate::normalize::NormalizationWarning;
use smol_str::SmolStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct AxisPos {
rank: u8,
coord: i64,
}
impl AxisPos {
fn new(region: Region, coord: i64) -> Self {
let rank = match region {
Region::FivePrimeUtr | Region::TxUpstream => 0,
Region::Genome | Region::Cds | Region::Rna | Region::Tx => 1,
Region::ThreePrimeUtr | Region::TxDownstream => 2,
};
Self { rank, coord }
}
fn is_immediately_followed_by(self, next: Self) -> bool {
if self.rank == next.rank {
return next.coord == self.coord + 1;
}
next.rank == self.rank + 1 && next.coord == 1 && (self.rank != 0 || self.coord == -1)
}
}
pub(crate) fn detect_overlap_conflicts(
variants: &[HgvsVariant],
phase: AllelePhase,
) -> Vec<NormalizationWarning> {
if phase != AllelePhase::Cis || variants.len() < 2 {
return Vec::new();
}
let mut groups: BTreeMap<GroupKey, Vec<usize>> = BTreeMap::new();
for (idx, variant) in variants.iter().enumerate() {
let Some(key) = group_key(variant) else {
continue;
};
groups.entry(key).or_default().push(idx);
}
let mut warnings = Vec::new();
for (key, indices) in &groups {
if indices.len() < 2 {
continue;
}
let edit_kinds: Vec<String> = indices
.iter()
.filter_map(|&i| edit_kind(&variants[i]).map(|s| s.to_string()))
.collect();
debug_assert_eq!(edit_kinds.len(), indices.len());
let location_str = location_for_variant(&variants[indices[0]])
.expect("group_key established a renderable location");
warnings.push(NormalizationWarning::OverlapConflict {
accession: key.accession.to_string(),
coordinate_system: key.coord_system.to_string(),
location: location_str,
edit_kinds,
});
}
for (i, first) in variants.iter().enumerate() {
let Some((coord_system, first_accession, first_footprint)) = member_footprint(first) else {
continue;
};
let Some(first_bases) = first_footprint.bases else {
continue;
};
for (j, second) in variants.iter().enumerate().skip(i + 1) {
let Some((second_system, second_accession, second_footprint)) =
member_footprint(second)
else {
continue;
};
let Some(second_bases) = second_footprint.bases else {
continue;
};
if first_accession != second_accession || coord_system != second_system {
continue;
}
if first_bases == second_bases {
continue;
}
let bases_only = |bases: (AxisPos, AxisPos), preserves: bool| {
WriteFootprint::spanning(bases.0, bases.1, preserves)
};
if !bases_only(first_bases, first_footprint.preserves_bases)
.conflicts_with(&bases_only(second_bases, second_footprint.preserves_bases))
{
continue;
}
let kinds: Vec<String> = [i, j]
.iter()
.filter_map(|&idx| member_kind(&variants[idx]).map(|s| s.to_string()))
.collect();
warnings.push(NormalizationWarning::OverlapConflict {
accession: first_accession.to_string(),
coordinate_system: coord_system.to_string(),
location: location_for_variant(first)
.expect("member_footprint established a renderable location"),
edit_kinds: kinds,
});
}
}
warnings
}
fn member_footprint(
variant: &HgvsVariant,
) -> Option<(&'static str, &Accession, WriteFootprint<AxisPos>)> {
let (coord_system, start, end) = simple_span(variant)?;
let accession = variant.accession()?;
let edit = inner_edit(variant)?;
let footprint = match edit {
NaEdit::Substitution { .. }
| NaEdit::SubstitutionNoRef { .. }
| NaEdit::Delins { .. }
| NaEdit::Inversion { .. } => WriteFootprint::spanning(start, end, true),
NaEdit::Deletion { .. } => WriteFootprint::spanning(start, end, false),
NaEdit::Duplication {
uncertain_extent: None,
..
} => WriteFootprint::at_junction(end),
NaEdit::Duplication { .. } => WriteFootprint::spanning(start, end, true),
NaEdit::Insertion { .. } => {
if !start.is_immediately_followed_by(end) {
return None;
}
WriteFootprint::at_junction(start)
}
NaEdit::Repeat {
sequence,
count,
additional_counts,
..
} => repeat_footprint(sequence.as_ref(), count, additional_counts, start, end)?,
_ => return None,
};
Some((coord_system, accession, footprint))
}
fn repeat_footprint(
sequence: Option<&crate::hgvs::edit::Sequence>,
count: &crate::hgvs::edit::RepeatCount,
additional_counts: &[crate::hgvs::edit::RepeatCount],
start: AxisPos,
end: AxisPos,
) -> Option<WriteFootprint<AxisPos>> {
use crate::hgvs::edit::RepeatCount;
if !additional_counts.is_empty() {
return None;
}
let RepeatCount::Exact(copies) = count else {
return None;
};
let unit_len = u64::try_from(sequence?.0.len()).ok()?;
if start.rank != end.rank || start.coord > end.coord {
return None;
}
let tract = end.coord.checked_sub(start.coord)?.checked_add(1)?;
let resulting = i64::try_from(unit_len.checked_mul(*copies)?).ok()?;
if resulting >= tract {
return Some(WriteFootprint::at_junction(end));
}
let removed_from = AxisPos {
rank: start.rank,
coord: start.coord.checked_add(resulting)?,
};
Some(WriteFootprint::spanning(removed_from, end, false))
}
fn member_kind(variant: &HgvsVariant) -> Option<&'static str> {
if matches!(inner_edit(variant), Some(NaEdit::Insertion { .. })) {
return Some("ins");
}
edit_kind(variant)
}
pub(crate) fn detect_insertion_overlaps(
variants: &[HgvsVariant],
phase: AllelePhase,
) -> Vec<NormalizationWarning> {
junction_overlaps(variants, phase, true)
}
pub(crate) fn detect_interior_junction_conflicts(
variants: &[HgvsVariant],
phase: AllelePhase,
) -> Vec<NormalizationWarning> {
junction_overlaps(variants, phase, false)
}
fn junction_overlaps(
variants: &[HgvsVariant],
phase: AllelePhase,
include_same_junction: bool,
) -> Vec<NormalizationWarning> {
if phase != AllelePhase::Cis || variants.len() < 2 {
return Vec::new();
}
struct Insertion {
idx: usize,
accession: SmolStr,
coord_system: &'static str,
gap: AxisPos,
kind: &'static str,
}
struct Span {
idx: usize,
accession: SmolStr,
coord_system: &'static str,
start: AxisPos,
end: AxisPos,
preserves_bases: bool,
}
let mut insertions: Vec<Insertion> = Vec::new();
let mut spans: Vec<Span> = Vec::new();
for (idx, variant) in variants.iter().enumerate() {
let Some((coord_system, accession, footprint)) = member_footprint(variant) else {
continue;
};
let Some(kind) = member_kind(variant) else {
continue;
};
if let Some(gap) = footprint.junction {
insertions.push(Insertion {
idx,
accession: accession.full_smol(),
coord_system,
gap,
kind,
});
}
if let Some((start, end)) = footprint.bases {
spans.push(Span {
idx,
accession: accession.full_smol(),
coord_system,
start,
end,
preserves_bases: footprint.preserves_bases,
});
}
}
let mut warnings = Vec::new();
type JunctionKey = (SmolStr, &'static str, AxisPos);
type Occupants = Vec<(usize, &'static str)>;
let mut by_junction: BTreeMap<JunctionKey, Occupants> = BTreeMap::new();
for ins in &insertions {
by_junction
.entry((ins.accession.clone(), ins.coord_system, ins.gap))
.or_default()
.push((ins.idx, ins.kind));
}
for ((accession, coord_system, _gap), occupants) in &by_junction {
if !include_same_junction || occupants.len() < 2 {
continue;
}
let insertions_here = occupants.iter().filter(|(_, k)| *k == "ins").count();
let junction_writers_here = occupants.len() - insertions_here;
if insertions_here <= 1 && junction_writers_here <= 1 {
continue;
}
let location = location_for_variant(&variants[occupants[0].0])
.expect("same-junction occupant has a renderable location");
warnings.push(NormalizationWarning::OverlapConflict {
accession: accession.to_string(),
coordinate_system: coord_system.to_string(),
location,
edit_kinds: occupants.iter().map(|(_, k)| k.to_string()).collect(),
});
}
for span in &spans {
if !span.preserves_bases {
continue;
}
let interior = insertions.iter().filter(|ins| {
ins.idx != span.idx
&& ins.accession == span.accession
&& ins.coord_system == span.coord_system
&& span.start <= ins.gap
&& ins.gap < span.end
});
let mut edit_kinds = vec![member_kind(&variants[span.idx])
.expect("span edit has a known kind")
.to_string()];
edit_kinds.extend(interior.map(|ins| ins.kind.to_string()));
if edit_kinds.len() < 2 {
continue;
}
warnings.push(NormalizationWarning::OverlapConflict {
accession: span.accession.to_string(),
coordinate_system: span.coord_system.to_string(),
location: location_for_variant(&variants[span.idx])
.expect("span edit has a renderable location"),
edit_kinds,
});
}
warnings
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct GroupKey {
accession: SmolStr,
coord_system: &'static str,
start: AxisPos,
end: AxisPos,
}
fn group_key(variant: &HgvsVariant) -> Option<GroupKey> {
let (coord_system, accession, footprint) = member_footprint(variant)?;
let (start, end) = footprint.bases?;
edit_kind(variant)?;
Some(GroupKey {
accession: accession.full_smol(),
coord_system,
start,
end,
})
}
fn inner_edit(variant: &HgvsVariant) -> Option<&NaEdit> {
match variant {
HgvsVariant::Genome(g) => g.loc_edit.edit.inner(),
HgvsVariant::Cds(c) => c.loc_edit.edit.inner(),
HgvsVariant::Tx(t) => t.loc_edit.edit.inner(),
HgvsVariant::Rna(r) => r.loc_edit.edit.inner(),
HgvsVariant::Mt(m) => m.loc_edit.edit.inner(),
_ => None,
}
}
fn simple_span(variant: &HgvsVariant) -> Option<(&'static str, AxisPos, AxisPos)> {
fn na_span<L>(
loc_edit: &LocEdit<Interval<L>, NaEdit>,
range_fn: impl Fn(&Interval<L>) -> Option<(AxisPos, AxisPos)>,
) -> Option<(AxisPos, AxisPos)> {
if !loc_edit.edit.is_certain() {
return None;
}
range_fn(&loc_edit.location)
}
match variant {
HgvsVariant::Genome(g) => na_span(&g.loc_edit, genome_range).map(|(s, e)| ("g", s, e)),
HgvsVariant::Cds(c) => na_span(&c.loc_edit, cds_range).map(|(s, e)| ("c", s, e)),
HgvsVariant::Tx(t) => na_span(&t.loc_edit, tx_range).map(|(s, e)| ("n", s, e)),
HgvsVariant::Rna(r) => na_span(&r.loc_edit, rna_range).map(|(s, e)| ("r", s, e)),
HgvsVariant::Mt(m) => na_span(&m.loc_edit, genome_range).map(|(s, e)| ("m", s, e)),
_ => None,
}
}
fn edit_kind(variant: &HgvsVariant) -> Option<&'static str> {
let inner: Option<&NaEdit> = match variant {
HgvsVariant::Genome(g) => g.loc_edit.edit.inner(),
HgvsVariant::Cds(c) => c.loc_edit.edit.inner(),
HgvsVariant::Tx(t) => t.loc_edit.edit.inner(),
HgvsVariant::Rna(r) => r.loc_edit.edit.inner(),
HgvsVariant::Mt(m) => m.loc_edit.edit.inner(),
_ => None,
};
let edit = inner?;
Some(match edit {
NaEdit::Substitution { .. } | NaEdit::SubstitutionNoRef { .. } => "sub",
NaEdit::Deletion { .. } => "del",
NaEdit::Delins { .. } => "delins",
NaEdit::Duplication { .. } => "dup",
NaEdit::Inversion { .. } => "inv",
NaEdit::Repeat { .. } => "repeat",
_ => return None,
})
}
fn location_for_variant(variant: &HgvsVariant) -> Option<String> {
match variant {
HgvsVariant::Genome(g) => Some(format_interval(&g.loc_edit.location)),
HgvsVariant::Cds(c) => Some(format_interval(&c.loc_edit.location)),
HgvsVariant::Tx(t) => Some(format_interval(&t.loc_edit.location)),
HgvsVariant::Rna(r) => Some(format_interval(&r.loc_edit.location)),
HgvsVariant::Mt(m) => Some(format_interval(&m.loc_edit.location)),
_ => None,
}
}
fn format_interval<P: std::fmt::Display + PartialEq>(interval: &Interval<P>) -> String {
let start = render_boundary(&interval.start);
let end = render_boundary(&interval.end);
if start == end {
start
} else {
format!("{}_{}", start, end)
}
}
fn render_boundary<P: std::fmt::Display>(boundary: &UncertainBoundary<P>) -> String {
match boundary {
UncertainBoundary::Single(Mu::Certain(p)) => p.to_string(),
UncertainBoundary::Single(Mu::Uncertain(_))
| UncertainBoundary::Single(Mu::Unknown)
| UncertainBoundary::Range { .. } => {
unreachable!("simple_range gates these out")
}
}
}
fn genome_range(interval: &Interval<GenomePos>) -> Option<(AxisPos, AxisPos)> {
let s = simple_genome(interval.start.as_single()?)?;
let e = simple_genome(interval.end.as_single()?)?;
Some((
AxisPos::new(Region::Genome, s),
AxisPos::new(Region::Genome, e),
))
}
fn simple_genome(mu: &Mu<GenomePos>) -> Option<i64> {
let pos = match mu {
Mu::Certain(p) => p,
_ => return None,
};
if pos.is_special() || pos.offset.is_some() {
return None;
}
i64::try_from(pos.base).ok()
}
fn cds_range(interval: &Interval<CdsPos>) -> Option<(AxisPos, AxisPos)> {
let (rs, s) = simple_cds(interval.start.as_single()?)?;
let (re, e) = simple_cds(interval.end.as_single()?)?;
Some((AxisPos::new(rs, s), AxisPos::new(re, e)))
}
fn simple_cds(mu: &Mu<CdsPos>) -> Option<(Region, i64)> {
let pos = match mu {
Mu::Certain(p) => p,
_ => return None,
};
if pos.is_unknown() || pos.is_intronic() {
return None;
}
if pos.is_3utr() {
return (pos.base >= 1).then_some((Region::ThreePrimeUtr, pos.base));
}
if pos.base < 0 {
return Some((Region::FivePrimeUtr, pos.base));
}
if pos.base > 0 {
return Some((Region::Cds, pos.base));
}
None
}
fn tx_range(interval: &Interval<TxPos>) -> Option<(AxisPos, AxisPos)> {
let (rs, s) = simple_tx(interval.start.as_single()?)?;
let (re, e) = simple_tx(interval.end.as_single()?)?;
Some((AxisPos::new(rs, s), AxisPos::new(re, e)))
}
fn simple_tx(mu: &Mu<TxPos>) -> Option<(Region, i64)> {
let pos = match mu {
Mu::Certain(p) => p,
_ => return None,
};
if pos.is_intronic() {
return None;
}
if pos.is_downstream() {
return (pos.base >= 1).then_some((Region::TxDownstream, pos.base));
}
if pos.base < 0 {
return Some((Region::TxUpstream, pos.base));
}
if pos.base > 0 {
return Some((Region::Tx, pos.base));
}
None
}
fn rna_range(interval: &Interval<RnaPos>) -> Option<(AxisPos, AxisPos)> {
let (rs, s) = simple_rna(interval.start.as_single()?)?;
let (re, e) = simple_rna(interval.end.as_single()?)?;
Some((AxisPos::new(rs, s), AxisPos::new(re, e)))
}
fn simple_rna(mu: &Mu<RnaPos>) -> Option<(Region, i64)> {
let pos = match mu {
Mu::Certain(p) => p,
_ => return None,
};
if pos.is_intronic() {
return None;
}
if pos.is_3utr() {
return (pos.base >= 1).then_some((Region::ThreePrimeUtr, pos.base));
}
if pos.base < 0 {
return Some((Region::FivePrimeUtr, pos.base));
}
if pos.base > 0 {
return Some((Region::Cds, pos.base));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structural_accession_equality_matches_the_rendered_form() {
let descs = [
"NC_000001.11:g.1A>G",
"NC_000001.11:g.2A>G", "NC_000001.10:g.1A>G", "NM_000088.3:c.1A>G", "ENST00000123.1:c.1A>G", "NC_000013.11(NM_004119.3):c.1A>G", "NC_000013.11(NM_004120.3):c.1A>G", "LRG_1t1:c.1A>G", "GRCh37(chr1):g.1A>G", "GRCh38(chr1):g.1A>G", "GRCh38(chr2):g.1A>G", "chr1:g.1A>G", ];
let accs: Vec<Accession> = descs
.iter()
.map(|d| {
let variant = crate::parse_hgvs(d)
.unwrap_or_else(|e| panic!("descriptor `{d}` must parse: {e}"));
variant
.accession()
.unwrap_or_else(|| panic!("descriptor `{d}` must carry an accession"))
.clone()
})
.collect();
for (i, a) in accs.iter().enumerate() {
for (j, b) in accs.iter().enumerate() {
assert_eq!(
a == b,
a.full_smol() == b.full_smol(),
"structural vs rendered accession equality disagree for {} / {}",
descs[i],
descs[j]
);
}
}
}
use crate::parse_hgvs;
fn parse_allele(s: &str) -> (Vec<HgvsVariant>, AllelePhase) {
let v = parse_hgvs(s).expect("parse");
match v {
HgvsVariant::Allele(a) => (a.variants, a.phase),
other => panic!("expected allele in test, got {:?}", other),
}
}
#[test]
fn same_position_two_subs_emits_one_warning() {
let (variants, phase) = parse_allele("NC_000001.11:g.[100G>A;100A>C]");
let warnings = detect_overlap_conflicts(&variants, phase);
assert_eq!(
warnings.len(),
1,
"expected one warning, got {:?}",
warnings
);
let NormalizationWarning::OverlapConflict {
accession,
coordinate_system,
location,
edit_kinds,
..
} = &warnings[0]
else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert_eq!(accession, "NC_000001.11");
assert_eq!(coordinate_system, "g");
assert_eq!(location, "100");
assert_eq!(edit_kinds, &vec!["sub".to_string(), "sub".to_string()]);
}
#[test]
fn same_position_sub_plus_del_emits_one_warning() {
let (variants, phase) = parse_allele("NC_000001.11:g.[100del;100A>C]");
let warnings = detect_overlap_conflicts(&variants, phase);
assert_eq!(warnings.len(), 1);
let NormalizationWarning::OverlapConflict { edit_kinds, .. } = &warnings[0] else {
panic!();
};
assert!(edit_kinds.contains(&"del".to_string()));
assert!(edit_kinds.contains(&"sub".to_string()));
}
#[test]
fn coincident_range_del_inv_emits_one_warning() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[100_103del;100_103inv]");
let warnings = detect_overlap_conflicts(&variants, phase);
assert_eq!(warnings.len(), 1);
let NormalizationWarning::OverlapConflict {
location,
edit_kinds,
..
} = &warnings[0]
else {
panic!();
};
assert_eq!(location, "100_103");
assert!(edit_kinds.contains(&"del".to_string()));
assert!(edit_kinds.contains(&"inv".to_string()));
}
#[test]
fn three_subs_at_one_base_emit_one_group_warning() {
let (variants, phase) = parse_allele("NC_000001.11:g.[100A>C;100A>G;100A>T]");
let warnings = detect_overlap_conflicts(&variants, phase);
assert_eq!(warnings.len(), 1, "groups, not pairs");
let NormalizationWarning::OverlapConflict { edit_kinds, .. } = &warnings[0] else {
panic!();
};
assert_eq!(edit_kinds.len(), 3);
}
#[test]
fn adjacent_subs_no_warning() {
let (variants, phase) = parse_allele("NC_000001.11:g.[100A>C;101A>G]");
assert!(detect_overlap_conflicts(&variants, phase).is_empty());
}
#[test]
fn multi_accession_no_warning() {
let (variants, phase) = parse_allele("[NC_000001.11:g.100A>C;NC_000002.11:g.100A>G]");
assert!(detect_overlap_conflicts(&variants, phase).is_empty());
}
#[test]
fn insertion_at_boundary_no_warning() {
let (variants, phase) = parse_allele("NC_000001.11:g.[100A>C;100_101insT]");
assert!(detect_overlap_conflicts(&variants, phase).is_empty());
}
#[test]
fn two_insertions_at_same_junction_emit_one_warning() {
let (variants, phase) = parse_allele("NG_012337.1:g.[4_5insT;4_5insA]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let NormalizationWarning::OverlapConflict {
location,
edit_kinds,
..
} = &warnings[0]
else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert_eq!(location, "4_5");
assert_eq!(edit_kinds, &vec!["ins".to_string(), "ins".to_string()]);
}
#[test]
fn same_junction_insertions_render_utr_location_with_star_prefix() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[*1_*2insT;*1_*2insA]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let NormalizationWarning::OverlapConflict { location, .. } = &warnings[0] else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert_eq!(location, "*1_*2");
}
#[test]
fn overlapping_duplication_spans_are_not_a_coincident_conflict() {
for input in [
"NG_012337.1:g.[10_14dup;12_16dup]",
"NG_012337.1:g.[10_14dup;10_16dup]",
] {
let (variants, phase) = parse_allele(input);
let warnings = detect_overlap_conflicts(&variants, phase);
assert!(
warnings.is_empty(),
"`{input}`: two duplications write at different junctions, so \
their overlapping read spans are not a coincident-bounds \
conflict; got {warnings:?}"
);
}
}
#[test]
fn insertion_interior_to_delins_emits_one_warning() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[274_275delinsT;274_275insA]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let NormalizationWarning::OverlapConflict { edit_kinds, .. } = &warnings[0] else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert!(edit_kinds.contains(&"delins".to_string()));
assert!(edit_kinds.contains(&"ins".to_string()));
}
#[test]
fn insertion_interior_to_deletion_is_not_a_conflict() {
let (variants, phase) = parse_allele("NG_012337.1:g.[4_7del;5_6insAA]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert!(
warnings.is_empty(),
"an insertion interior to a pure deletion composes uniquely, so it \
must not be reported; got {warnings:?}"
);
}
#[test]
fn insertions_sharing_junction_and_interior_to_span_emit_two_warnings() {
let (variants, phase) = parse_allele("NG_012337.1:g.[4_7delinsGG;5_6insA;5_6insT]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 2, "expected two warnings, got {warnings:?}");
assert!(
warnings
.iter()
.all(|w| matches!(w, NormalizationWarning::OverlapConflict { .. })),
"all warnings must be OverlapConflict: {warnings:?}"
);
}
#[test]
fn duplication_interior_to_inversion_emits_one_warning() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[5_9inv;5_6dup]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let NormalizationWarning::OverlapConflict {
location,
edit_kinds,
..
} = &warnings[0]
else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert_eq!(location, "5_9", "the warning names the enclosing span");
assert_eq!(edit_kinds, &vec!["inv".to_string(), "dup".to_string()]);
}
#[test]
fn duplication_conflict_is_detected_in_either_member_order() {
for allele in ["NM_TEST.1:c.[5_9inv;5_6dup]", "NM_TEST.1:c.[5_6dup;5_9inv]"] {
let (variants, phase) = parse_allele(allele);
assert_eq!(
detect_insertion_overlaps(&variants, phase).len(),
1,
"expected one warning for {allele}"
);
}
}
#[test]
fn lone_duplication_does_not_conflict_with_itself() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[5_6dup;20A>G]");
assert!(detect_insertion_overlaps(&variants, phase).is_empty());
}
#[test]
fn duplication_abutting_a_span_edge_no_warning() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[1_4dup;5_9inv]");
assert!(detect_insertion_overlaps(&variants, phase).is_empty());
}
#[test]
fn two_duplications_at_one_junction_emit_one_warning() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[3_6dup;5_6dup]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let NormalizationWarning::OverlapConflict { edit_kinds, .. } = &warnings[0] else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert_eq!(edit_kinds, &vec!["dup".to_string(), "dup".to_string()]);
}
#[test]
fn repeat_interior_to_inversion_emits_one_warning() {
let (variants, phase) = parse_allele("NC_000001.11:g.[1005_1009inv;1005_1006A[4]]");
let warnings = detect_insertion_overlaps(&variants, phase);
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let NormalizationWarning::OverlapConflict { edit_kinds, .. } = &warnings[0] else {
panic!("expected OverlapConflict, got {:?}", warnings[0]);
};
assert_eq!(edit_kinds, &vec!["inv".to_string(), "repeat".to_string()]);
}
#[test]
fn duplication_and_insertion_at_one_junction_do_not_conflict() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[5_6dup;6_7insA]");
assert!(
detect_insertion_overlaps(&variants, phase).is_empty(),
"a dup and an insertion at one junction compose"
);
}
#[test]
fn two_insertions_at_one_junction_still_conflict() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[6_7insA;6_7insG]");
assert_eq!(
detect_insertion_overlaps(&variants, phase).len(),
1,
"two insertions at one junction have no defined order"
);
}
#[test]
fn two_insertions_at_different_junctions_no_warning() {
let (variants, phase) = parse_allele("NG_012337.1:g.[4_5insT;8_9insA]");
assert!(detect_insertion_overlaps(&variants, phase).is_empty());
}
#[test]
fn insertions_flanking_a_sub_no_warning() {
let (variants, phase) = parse_allele("NM_TEST.1:c.[273_274insT;274G>T;274_275insA]");
assert!(
detect_insertion_overlaps(&variants, phase).is_empty(),
"non-overlapping flanking insertions must not warn: {:?}",
detect_insertion_overlaps(&variants, phase)
);
}
#[test]
fn insertion_overlap_only_in_cis() {
let (variants, phase) = parse_allele("NG_012337.1:g.[4_5insT];[4_5insA]");
assert!(detect_insertion_overlaps(&variants, phase).is_empty());
}
#[test]
fn end_to_end_normalize_emits_warning() {
use crate::normalize::Normalizer;
use crate::reference::mock::MockProvider;
let normalizer = Normalizer::new(MockProvider::new());
let v = parse_hgvs("NC_000001.11:g.[100G>A;100A>C]").expect("parse");
let result = normalizer
.normalize_with_diagnostics(&v)
.expect("normalize");
assert!(
result
.warnings
.iter()
.any(|w| w.code() == "OVERLAP_CONFLICTING_EDITS"),
"expected OVERLAP_CONFLICTING_EDITS in warnings, got {:?}",
result.warnings.iter().map(|w| w.code()).collect::<Vec<_>>(),
);
let out = result.result.to_string();
assert!(
out.contains("100G>A") && out.contains("100A>C"),
"expected pass-through, got {out}"
);
}
const FOOTPRINT_MATRIX: &[(&str, bool, &str)] = &[
("NC_TEST.1:g.[10_14del;12_16del]", true, "partial overlap"),
("NC_TEST.1:g.[10_14del;10_16del]", true, "nested"),
("NC_TEST.1:g.[10_14del;10_14del]", true, "coincident"),
("NC_TEST.1:g.[10_14del;15_19del]", false, "flush, disjoint"),
("NC_TEST.1:g.[10_14inv;12_16inv]", true, "partial overlap"),
(
"NC_TEST.1:g.[10_14delinsAA;12_16del]",
true,
"partial overlap",
),
("NC_TEST.1:g.[12G>A;12G>C]", true, "coincident single base"),
("NC_TEST.1:g.[12G>A;13G>C]", false, "adjacent single bases"),
(
"NC_TEST.1:g.[10_14dup;12_16dup]",
false,
"junctions 14 and 16 differ (#1448)",
),
(
"NC_TEST.1:g.[10_14dup;10_16dup]",
false,
"nested reads, junctions 14 and 16 differ",
),
(
"NC_TEST.1:g.[10_14dup;12_14dup]",
true,
"one junction, two writers",
),
(
"NC_TEST.1:g.[10_14dup;12G>A]",
false,
"sub inside the READ span, not the write (#1411)",
),
(
"NC_TEST.1:g.[10_14dup;14G>A]",
false,
"sub at the dup's last base; junction is 3' of it",
),
(
"NC_TEST.1:g.[10_20inv;14_15dup]",
true,
"dup junction interior to a span that keeps its bases",
),
(
"NC_TEST.1:g.[14_15insA;14_15insT]",
true,
"one junction, two writers",
),
(
"NC_TEST.1:g.[14_15insA;15_16insT]",
false,
"different junctions",
),
(
"NC_TEST.1:g.[10_20delinsAA;14_15insT]",
true,
"interior junction, bases survive",
),
(
"NC_TEST.1:g.[10_20del;14_15insT]",
false,
"interior junction, del keeps nothing (#1406)",
),
(
"NC_TEST.1:g.[10_14del;14_15insT]",
false,
"junction flush against the 3' edge",
),
(
"NC_TEST.1:g.[10_14del;9_10insT]",
false,
"junction flush against the 5' edge",
),
(
"NC_TEST.1:g.[10_14A[3];12_16del]",
true,
"removed range 13-14 intersects the deletion",
),
(
"NC_TEST.1:g.[10_14A[3];13_17del]",
true,
"removed range 13-14 intersects the deletion",
),
(
"NC_TEST.1:g.[10_14A[3];10_11del]",
false,
"the deletion sits in the KEPT prefix, untouched reference",
),
(
"NC_TEST.1:g.[10_20A[3];14_15insT]",
false,
"interior to the REMOVED range, which keeps nothing",
),
(
"NC_TEST.1:g.[10_14A[3];12_14dup]",
false,
"dup junction 14 sits at the 3' edge, not inside",
),
(
"NC_TEST.1:g.[10_11A[3];10_11insT]",
false,
"repeat writes at junction 11, insertion at 10",
),
(
"NC_TEST.1:g.[10_11A[3];11_12insT]",
false,
"both write at junction 11, and duplication.md:90 orders that pair",
),
(
"NC_TEST.1:g.[10_11A[3];10_11dup]",
true,
"the two spellings of one shape collide identically",
),
];
#[test]
fn the_detectors_agree_with_the_write_footprint_definition() {
let mut wrong = Vec::new();
for (input, expected, why) in FOOTPRINT_MATRIX {
let (variants, phase) = parse_allele(input);
let coincident = detect_overlap_conflicts(&variants, phase);
let junction = detect_insertion_overlaps(&variants, phase);
let got = !coincident.is_empty() || !junction.is_empty();
if got != *expected {
wrong.push(format!(
" {input}\n expected conflict={expected} ({why}), got {got} \
[coincident={}, junction={}]",
coincident.len(),
junction.len()
));
}
}
assert!(
wrong.is_empty(),
"{} of {} rows disagree with the write-footprint definition:\n{}",
wrong.len(),
FOOTPRINT_MATRIX.len(),
wrong.join("\n")
);
}
#[test]
fn overlapping_duplication_spans_are_silent_in_the_junction_detector_too() {
for input in [
"NG_012337.1:g.[10_14dup;12_16dup]",
"NG_012337.1:g.[10_14dup;10_16dup]",
] {
let (variants, phase) = parse_allele(input);
let warnings = detect_insertion_overlaps(&variants, phase);
assert!(
warnings.is_empty(),
"`{input}`: since #1437 a `dup` is a junction occupant and not a \
span, so two `dup`s at different junctions collide in neither \
branch; got {warnings:?}"
);
}
}
}