use super::complex::compute_cds_projected_length;
use super::{Consequence, Impact};
use crate::error::VarEffectError;
use crate::locate::LocateIndex;
use crate::transcript::TranscriptStore;
use crate::types::{Strand, TranscriptModel};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SvKind {
Deletion,
Duplication,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CnvConsequenceResult {
pub transcript: String,
pub gene_symbol: String,
pub hgnc_id: Option<String>,
pub strand: Strand,
pub tx_start: u64,
pub tx_end: u64,
pub consequence: Consequence,
pub impact: Impact,
pub spans_whole_transcript: bool,
pub transcript_overlap_fraction: f64,
pub cds_bases_overlapped: u32,
pub exons_overlapped: u16,
pub overlaps_first_exon: bool,
pub overlaps_last_exon: bool,
pub breakpoint_within_transcript: bool,
pub both_breakpoints_within_tx: bool,
}
struct ExonOverlap {
cds_bases_overlapped: u32,
exons_overlapped: u16,
overlaps_first_exon: bool,
overlaps_last_exon: bool,
}
fn compute_exon_overlap(sv_start: u64, sv_end: u64, transcript: &TranscriptModel) -> ExonOverlap {
let last_idx = transcript.exons.len().saturating_sub(1);
let mut exons_overlapped = 0u16;
let mut overlaps_first_exon = false;
let mut overlaps_last_exon = false;
for (i, exon) in transcript.exons.iter().enumerate() {
let lo = sv_start.max(exon.genomic_start);
let hi = sv_end.min(exon.genomic_end);
if lo < hi {
exons_overlapped += 1;
if i == 0 {
overlaps_first_exon = true;
}
if i == last_idx {
overlaps_last_exon = true;
}
}
}
ExonOverlap {
cds_bases_overlapped: compute_cds_projected_length(sv_start, sv_end, transcript),
exons_overlapped,
overlaps_first_exon,
overlaps_last_exon,
}
}
fn annotate_transcript(
sv_start: u64,
sv_end: u64,
sv_kind: SvKind,
transcript: &TranscriptModel,
) -> CnvConsequenceResult {
let spans_whole_transcript = sv_start <= transcript.tx_start && sv_end >= transcript.tx_end;
let both_breakpoints_within_tx = sv_start > transcript.tx_start && sv_end < transcript.tx_end;
let consequence = match (sv_kind, spans_whole_transcript) {
(SvKind::Deletion, true) => Consequence::TranscriptAblation,
(SvKind::Deletion, false) => Consequence::FeatureTruncation,
(SvKind::Duplication, true) => Consequence::TranscriptAmplification,
(SvKind::Duplication, false) => Consequence::FeatureElongation,
};
let tx_span = transcript.tx_end.saturating_sub(transcript.tx_start);
let covered = sv_end
.min(transcript.tx_end)
.saturating_sub(sv_start.max(transcript.tx_start));
let transcript_overlap_fraction = if tx_span == 0 {
0.0
} else {
covered as f64 / tx_span as f64
};
let overlap = compute_exon_overlap(sv_start, sv_end, transcript);
CnvConsequenceResult {
transcript: transcript.accession.clone(),
gene_symbol: transcript.gene_symbol.clone(),
hgnc_id: transcript.hgnc_id.clone(),
strand: transcript.strand,
tx_start: transcript.tx_start,
tx_end: transcript.tx_end,
consequence,
impact: consequence.impact(),
spans_whole_transcript,
transcript_overlap_fraction,
cds_bases_overlapped: overlap.cds_bases_overlapped,
exons_overlapped: overlap.exons_overlapped,
overlaps_first_exon: overlap.overlaps_first_exon,
overlaps_last_exon: overlap.overlaps_last_exon,
breakpoint_within_transcript: !spans_whole_transcript,
both_breakpoints_within_tx,
}
}
pub(crate) fn annotate_interval(
chrom: &str,
start: u64,
end: u64,
sv_kind: SvKind,
store: &TranscriptStore,
) -> Result<Vec<CnvConsequenceResult>, VarEffectError> {
if start >= end {
return Err(VarEffectError::Malformed(format!(
"SV interval start ({start}) must be < end ({end})"
)));
}
let overlaps: Vec<(&TranscriptModel, &LocateIndex)> = store.query_overlap(chrom, start, end);
let mut results = Vec::with_capacity(overlaps.len());
for (tx, _idx) in overlaps {
results.push(annotate_transcript(start, end, sv_kind, tx));
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{
minus_strand_coding, noncoding_2_exon, plus_strand_coding, single_exon_coding,
};
use crate::transcript::TranscriptStore;
fn fixture_store() -> TranscriptStore {
TranscriptStore::from_transcripts(vec![
plus_strand_coding(),
minus_strand_coding(),
noncoding_2_exon(),
single_exon_coding(),
])
}
#[test]
fn deletion_spanning_whole_transcript_is_ablation() {
let store = fixture_store();
let results = annotate_interval("chr1", 500, 6000, SvKind::Deletion, &store).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.consequence, Consequence::TranscriptAblation);
assert_eq!(r.impact, Impact::High);
assert!(r.spans_whole_transcript);
assert!(!r.breakpoint_within_transcript);
assert_eq!(r.transcript_overlap_fraction, 1.0);
assert!(r.overlaps_first_exon && r.overlaps_last_exon);
assert_eq!(r.exons_overlapped, 3);
assert_eq!(r.cds_bases_overlapped, 1500);
}
#[test]
fn duplication_spanning_whole_transcript_is_amplification() {
let store = fixture_store();
let results = annotate_interval("chr1", 500, 6000, SvKind::Duplication, &store).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].consequence, Consequence::TranscriptAmplification);
assert_eq!(results[0].impact, Impact::High);
assert!(results[0].spans_whole_transcript);
}
#[test]
fn partial_deletion_is_truncation_with_geometry() {
let store = fixture_store();
let results = annotate_interval("chr1", 900, 2500, SvKind::Deletion, &store).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.consequence, Consequence::FeatureTruncation);
assert_eq!(r.impact, Impact::High);
assert!(!r.spans_whole_transcript);
assert!(r.breakpoint_within_transcript);
assert!(r.overlaps_first_exon);
assert!(!r.overlaps_last_exon);
assert_eq!(r.exons_overlapped, 1);
assert_eq!(r.cds_bases_overlapped, 500);
assert!(r.transcript_overlap_fraction > 0.0 && r.transcript_overlap_fraction < 1.0);
}
#[test]
fn partial_duplication_is_elongation() {
let store = fixture_store();
let results = annotate_interval("chr1", 3200, 4200, SvKind::Duplication, &store).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.consequence, Consequence::FeatureElongation);
assert!(!r.spans_whole_transcript);
assert!(!r.overlaps_first_exon);
assert!(r.overlaps_last_exon);
assert_eq!(r.exons_overlapped, 2);
}
#[test]
fn minus_strand_first_exon_is_high_coordinate() {
let store = fixture_store();
let results = annotate_interval("chr17", 18500, 21000, SvKind::Deletion, &store).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.strand, Strand::Minus);
assert_eq!(r.consequence, Consequence::FeatureTruncation);
assert!(r.overlaps_first_exon);
assert!(!r.overlaps_last_exon);
}
#[test]
fn noncoding_transcript_has_zero_cds_overlap() {
let store = fixture_store();
let results = annotate_interval("chr2", 50, 700, SvKind::Deletion, &store).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.consequence, Consequence::TranscriptAblation);
assert!(r.spans_whole_transcript);
assert_eq!(r.cds_bases_overlapped, 0);
assert_eq!(r.exons_overlapped, 2);
}
#[test]
fn no_overlap_returns_empty_not_error() {
let store = fixture_store();
let results = annotate_interval("chr1", 100, 200, SvKind::Deletion, &store).unwrap();
assert!(results.is_empty());
}
#[test]
fn unknown_chrom_returns_empty_not_error() {
let store = fixture_store();
let results = annotate_interval("chrZZ", 100, 5000, SvKind::Deletion, &store).unwrap();
assert!(results.is_empty());
}
#[test]
fn full_span_is_not_both_breakpoints_within() {
let store = fixture_store();
let results = annotate_interval("chr1", 500, 6000, SvKind::Deletion, &store).unwrap();
assert!(!results[0].both_breakpoints_within_tx);
}
#[test]
fn intragenic_deletion_touching_no_terminal_exon() {
let store = fixture_store();
let r = &annotate_interval("chr1", 3200, 3800, SvKind::Deletion, &store).unwrap()[0];
assert!(!r.spans_whole_transcript);
assert!(!r.overlaps_first_exon && !r.overlaps_last_exon);
assert!(
r.both_breakpoints_within_tx,
"both breakpoints strictly inside the transcript"
);
}
#[test]
fn intragenic_deletion_touching_first_exon_is_still_within() {
let store = fixture_store();
let r = &annotate_interval("chr1", 1500, 3200, SvKind::Deletion, &store).unwrap()[0];
assert!(r.overlaps_first_exon);
assert!(!r.overlaps_last_exon);
assert!(
r.both_breakpoints_within_tx,
"first-exon-touching but bounded on both sides -> intragenic"
);
}
#[test]
fn single_exon_intragenic_deletion() {
let store = fixture_store();
let r = &annotate_interval("chr3", 3000, 4000, SvKind::Deletion, &store).unwrap()[0];
assert!(!r.spans_whole_transcript);
assert!(
r.overlaps_first_exon && r.overlaps_last_exon,
"single exon is both first and last"
);
assert_eq!(r.exons_overlapped, 1);
assert!(
r.both_breakpoints_within_tx,
"single-exon intragenic deletion is contained in the transcript"
);
}
#[test]
fn single_exon_full_span_is_not_within() {
let store = fixture_store();
let r = &annotate_interval("chr3", 1000, 6000, SvKind::Deletion, &store).unwrap()[0];
assert!(r.spans_whole_transcript);
assert!(!r.both_breakpoints_within_tx);
}
#[test]
fn inverted_interval_errors() {
let store = fixture_store();
assert!(matches!(
annotate_interval("chr1", 5000, 5000, SvKind::Deletion, &store),
Err(VarEffectError::Malformed(_))
));
assert!(matches!(
annotate_interval("chr1", 5000, 1000, SvKind::Deletion, &store),
Err(VarEffectError::Malformed(_))
));
}
}