use crate::hgvs::interval::{Interval, TxInterval};
use crate::hgvs::location::TxPos;
use crate::hgvs::uncertainty::Mu;
use crate::hgvs::variant::HgvsVariant;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NonCodingZone {
Downstream,
Upstream,
}
impl NonCodingZone {
#[must_use]
pub fn marker(self) -> &'static str {
match self {
Self::Downstream => "*",
Self::Upstream => "-",
}
}
#[must_use]
pub fn relation(self) -> &'static str {
match self {
Self::Downstream => "after the last nucleotide of",
Self::Upstream => "before the first nucleotide of",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NonCodingZoneMarker {
pub zone: NonCodingZone,
pub stated: String,
pub numbers_a_bare_transcript: bool,
}
impl NonCodingZoneMarker {
fn boundary_clause(&self) -> &'static str {
if self.numbers_a_bare_transcript {
":54 separately forbids describing a variant in a nucleotide beyond that \
transcript's boundaries, using that transcript reference sequence. "
} else {
""
}
}
#[must_use]
pub fn refusal(&self) -> String {
format!(
"`n.{stated}` uses the `{marker}` marker, which background/numbering.md:52 does not \
put on the non-coding DNA axis: that axis is numbered `n.1`, `n.2`, `n.3`, ..., from \
the first to the last nucleotide of the reference sequence, and :53 grants intronic \
offsets as its only other zone. There is no `{marker}` zone here for `{stated}` to \
be numbered from, so it names a nucleotide {relation} the reference sequence. \
{boundary}The `-`/`*` zones exist on the CODING axis (`c.`), where they are anchored \
to the CDS and are still inside the transcript; name the nucleotide on a reference \
that contains it instead.",
stated = self.stated,
marker = self.zone.marker(),
relation = self.zone.relation(),
boundary = self.boundary_clause(),
)
}
#[must_use]
pub fn warning(&self) -> String {
format!(
"`n.{stated}` names a nucleotide {relation} the reference sequence; \
background/numbering.md:52 numbers the non-coding axis from the first nucleotide to \
the last, and :53 grants intronic offsets as its only other zone. {boundary}Accepted \
as authored: this mode does not validate input conformance.",
stated = self.stated,
relation = self.zone.relation(),
boundary = self.boundary_clause(),
)
}
}
fn zone_of(pos: &TxPos, want: NonCodingZone, bare: bool) -> Option<NonCodingZoneMarker> {
let states = match want {
NonCodingZone::Downstream => pos.downstream,
NonCodingZone::Upstream => !pos.downstream && pos.base < 0,
};
states.then(|| NonCodingZoneMarker {
zone: want,
stated: pos.to_string(),
numbers_a_bare_transcript: bare,
})
}
fn zone_in_interval(
interval: &TxInterval,
want: NonCodingZone,
bare: bool,
) -> Option<NonCodingZoneMarker> {
let in_mu = |mu: &Mu<TxPos>| mu.inner().and_then(|p| zone_of(p, want, bare));
let in_boundary = |boundary: &crate::hgvs::interval::UncertainBoundary<TxPos>| match boundary {
crate::hgvs::interval::UncertainBoundary::Single(mu) => in_mu(mu),
crate::hgvs::interval::UncertainBoundary::Range { start, end } => {
in_mu(start).or_else(|| in_mu(end))
}
};
let Interval { start, end } = interval;
in_boundary(start).or_else(|| in_boundary(end))
}
#[must_use]
pub fn noncoding_zone_marker(
variant: &HgvsVariant,
want: NonCodingZone,
) -> Option<NonCodingZoneMarker> {
match variant {
HgvsVariant::Tx(v) => zone_in_interval(
&v.loc_edit.location,
want,
v.accession.genomic_context.is_none(),
),
HgvsVariant::Allele(allele) => allele
.variants
.iter()
.find_map(|inner| noncoding_zone_marker(inner, want)),
HgvsVariant::Supernumerary(inner) => noncoding_zone_marker(inner, want),
HgvsVariant::Genome(_)
| HgvsVariant::Cds(_)
| HgvsVariant::Rna(_)
| HgvsVariant::Mt(_)
| HgvsVariant::Circular(_)
| HgvsVariant::GenomeRing(_)
| HgvsVariant::Protein(_)
| HgvsVariant::RnaFusion(_)
| HgvsVariant::NullAllele
| HgvsVariant::UnknownAllele => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse_hgvs;
fn tx_with(accession: &str, start: TxPos, end: TxPos) -> HgvsVariant {
let base = parse_hgvs(&format!("{accession}:n.10_20del")).expect("template parses");
let HgvsVariant::Tx(mut v) = base else {
unreachable!("`n.` parses as Tx")
};
v.loc_edit.location = TxInterval::new(start, end);
HgvsVariant::Tx(v)
}
fn upstream_marker_of(input: &str) -> Option<NonCodingZoneMarker> {
noncoding_zone_marker(
&parse_hgvs(input).expect("input parses"),
NonCodingZone::Upstream,
)
}
#[test]
fn a_downstream_marker_is_found() {
let v = tx_with("NR_037639.1", TxPos::downstream(5), TxPos::downstream(5));
let found = noncoding_zone_marker(&v, NonCodingZone::Downstream).expect("`*5` is stated");
assert_eq!(found.zone, NonCodingZone::Downstream);
assert_eq!(found.stated, "*5");
}
#[test]
fn the_two_zones_do_not_answer_for_each_other() {
let star = tx_with("NR_037639.1", TxPos::downstream(5), TxPos::downstream(5));
assert!(noncoding_zone_marker(&star, NonCodingZone::Upstream).is_none());
assert!(upstream_marker_of("NR_037639.1:n.-100_-50del").is_some());
assert!(noncoding_zone_marker(
&parse_hgvs("NR_037639.1:n.-100_-50del").unwrap(),
NonCodingZone::Downstream
)
.is_none());
}
#[test]
fn an_upstream_marker_is_found() {
let found = upstream_marker_of("NR_037639.1:n.-100_-50del").expect("`-100` is stated");
assert_eq!(found.zone, NonCodingZone::Upstream);
assert_eq!(found.stated, "-100");
}
#[test]
fn either_endpoint_alone_is_enough() {
assert_eq!(
upstream_marker_of("NR_037639.1:n.-3_5del")
.expect("start is outside")
.zone,
NonCodingZone::Upstream
);
let end_outside = tx_with("NR_037639.1", TxPos::new(40), TxPos::downstream(3));
assert_eq!(
noncoding_zone_marker(&end_outside, NonCodingZone::Downstream)
.expect("end is outside")
.zone,
NonCodingZone::Downstream
);
}
#[test]
fn intronic_offsets_are_not_markers() {
assert!(upstream_marker_of("NG_012337.1(NR_037639.1):n.5+3A>G").is_none());
assert!(upstream_marker_of("NG_012337.1(NR_037639.1):n.6-3A>G").is_none());
assert!(upstream_marker_of("NG_012337.1(NR_037639.1):n.100+10del").is_none());
}
#[test]
fn in_transcript_positions_are_not_markers() {
assert!(upstream_marker_of("NR_024540.1:n.1A>G").is_none());
assert!(upstream_marker_of("NR_024540.1:n.1786A>G").is_none());
assert!(upstream_marker_of("NR_037639.1:n.100_200del").is_none());
}
#[test]
fn the_coding_axis_states_no_marker() {
for input in ["NM_000492.4:c.*1A>G", "NM_000492.4:c.-1A>G"] {
let v = parse_hgvs(input).expect("parses");
assert!(noncoding_zone_marker(&v, NonCodingZone::Upstream).is_none());
assert!(noncoding_zone_marker(&v, NonCodingZone::Downstream).is_none());
}
}
#[test]
fn the_rna_axis_states_no_marker() {
for input in ["NM_003002.4:r.*5a>g", "NM_003002.4:r.-5a>g"] {
let v = parse_hgvs(input).expect("parses");
assert!(noncoding_zone_marker(&v, NonCodingZone::Upstream).is_none());
assert!(noncoding_zone_marker(&v, NonCodingZone::Downstream).is_none());
}
}
#[test]
fn an_allele_member_is_reached() {
assert_eq!(
upstream_marker_of("NR_037639.1:n.[100del;-5A>G]")
.expect("the second member states it")
.zone,
NonCodingZone::Upstream
);
}
#[test]
fn the_boundary_clause_is_scoped_to_a_bare_transcript() {
let bare = upstream_marker_of("NR_003529.3:n.-40000del").expect("bare transcript");
assert!(bare.numbers_a_bare_transcript);
assert!(bare.refusal().contains(":54"));
assert!(bare.warning().contains(":54"));
let selector =
upstream_marker_of("NG_007485.1(NR_003529.3):n.-40000del").expect("selector form");
assert!(!selector.numbers_a_bare_transcript);
assert!(
!selector.refusal().contains(":54"),
"the genomic reference DOES contain the nucleotide, so :54 is not \
the operative clause: {}",
selector.refusal()
);
assert!(!selector.warning().contains(":54"));
assert!(selector.refusal().contains("numbering.md:52"));
}
}