use std::collections::BTreeMap;
use std::path::Path;
use crate::cli::project::{project_axis, select_axis, Axis, AxisOutcome};
use crate::conformance::reference_snapshot::{parse_fasta, render_fasta};
use crate::conformance::reference_window::WindowFixture;
use crate::hgvs::variant::HgvsVariant;
use crate::project::VariantProjector;
use crate::reference::ReferenceProvider;
use crate::FerroError;
pub const AXES: [(char, Axis); 5] = [
('g', Axis::Genomic),
('c', Axis::Coding),
('n', Axis::Noncoding),
('r', Axis::Rna),
('p', Axis::Protein),
];
pub const SLICE_TRANSCRIPTS_FASTA: &str = "spec_enumeration_transcripts.fna";
pub const SLICE_WINDOWS_FASTA: &str = "spec_enumeration_windows.fna";
pub fn split_slice_sequences(
fixture: &mut WindowFixture,
) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
let mut transcripts = BTreeMap::new();
for tx in &mut fixture.transcripts {
if let Some(seq) = tx.sequence.take() {
transcripts.insert(tx.id.clone(), seq);
}
}
let mut windows = BTreeMap::new();
for w in &mut fixture.genomic {
windows.insert(window_key(&w.contig, w.start), std::mem::take(&mut w.bases));
}
(transcripts, windows)
}
pub fn attach_slice_sequences(
fixture: &mut WindowFixture,
transcripts: &BTreeMap<String, String>,
windows: &BTreeMap<String, String>,
) -> Result<(), FerroError> {
for tx in &mut fixture.transcripts {
if let Some(seq) = transcripts.get(&tx.id) {
tx.sequence = Some(seq.clone());
}
}
for w in &mut fixture.genomic {
let key = window_key(&w.contig, w.start);
let bases = windows
.get(&key)
.ok_or(FerroError::ReferenceNotFound { id: key })?;
w.bases = bases.clone();
}
Ok(())
}
fn window_key(contig: &str, start: u64) -> String {
format!("{contig}:{start}")
}
pub fn render_slice(fixture: &WindowFixture) -> Result<(String, String, String), FerroError> {
let mut fixture = fixture.clone();
let (transcripts, windows) = split_slice_sequences(&mut fixture);
Ok((
fixture.to_json()?,
render_fasta(&transcripts),
render_fasta(&windows),
))
}
pub fn load_slice<P: AsRef<Path>>(metadata_path: P) -> Result<WindowFixture, FerroError> {
let metadata_path = metadata_path.as_ref();
let dir = metadata_path.parent().unwrap_or(Path::new("."));
let mut fixture = WindowFixture::from_json_path(metadata_path)?;
let transcripts = parse_fasta(&std::fs::read_to_string(dir.join(SLICE_TRANSCRIPTS_FASTA))?);
let windows = parse_fasta(&std::fs::read_to_string(dir.join(SLICE_WINDOWS_FASTA))?);
attach_slice_sequences(&mut fixture, &transcripts, &windows)?;
Ok(fixture)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AxisResult {
Rendered(String),
Unavailable(String),
Error(String),
NotApplicable(String),
}
impl AxisResult {
pub fn as_observed(&self) -> String {
match self {
AxisResult::Rendered(s) => s.clone(),
AxisResult::Unavailable(r) => format!("unavailable: {r}"),
AxisResult::Error(e) => format!("error: {e}"),
AxisResult::NotApplicable(r) => format!("not applicable: {r}"),
}
}
}
#[derive(Debug, Clone)]
pub struct PassResult {
pub transcript: Option<String>,
pub axes: BTreeMap<char, AxisResult>,
}
impl PassResult {
fn all_not_applicable(reason: &str) -> Self {
PassResult {
transcript: None,
axes: AXES
.iter()
.map(|(c, _)| (*c, AxisResult::NotApplicable(reason.to_string())))
.collect(),
}
}
}
pub fn transcript_for<P: ReferenceProvider + Clone>(
_projector: &VariantProjector<P>,
variant: &HgvsVariant,
) -> Option<String> {
match variant {
HgvsVariant::Cds(_) | HgvsVariant::Tx(_) | HgvsVariant::Rna(_) => {
variant.accession().map(|a| a.transcript_accession())
}
_ => None,
}
}
pub fn project_all_axes<P: ReferenceProvider + Clone>(
projector: &VariantProjector<P>,
variant: &HgvsVariant,
) -> PassResult {
let Some(transcript) = transcript_for(projector, variant) else {
return PassResult::all_not_applicable(
"input carries no transcript frame to project from (bare g./p./m./o. \
or an allele list); `ferro project` declines these too",
);
};
let axes = match projector.project_variant(variant, &transcript) {
Ok(projection) => AXES
.iter()
.map(|(code, axis)| {
let result = match select_axis(&projection, *axis) {
AxisOutcome::Rendered { output, .. } => AxisResult::Rendered(output),
AxisOutcome::Unavailable { reason, .. } => AxisResult::Unavailable(reason),
};
(*code, result)
})
.collect(),
Err(_) => {
let verdict = match project_axis(projector, variant, Axis::Genomic, Some(&transcript)) {
Ok(AxisOutcome::Rendered { output, .. }) => AxisResult::Rendered(output),
Ok(AxisOutcome::Unavailable { reason, .. }) => AxisResult::Unavailable(format!(
"projection unavailable for all axes: {reason}"
)),
Err(e) => AxisResult::Error(format!("projection failed for all axes: {e}")),
};
AXES.iter()
.map(|(code, _)| (*code, verdict.clone()))
.collect()
}
};
PassResult {
transcript: Some(transcript),
axes,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::cdot::{CdotMapper, CdotTranscript};
use crate::data::projection::Projector;
use crate::reference::mock::MockProvider;
use crate::reference::transcript::{Exon, ManeStatus, Strand as TxStrand, Transcript};
use crate::reference::Strand;
fn fixture() -> VariantProjector<MockProvider> {
let mut cdot = CdotMapper::new();
cdot.add_transcript(
"NM_TEST.1".to_string(),
CdotTranscript {
cds_start_incomplete: false,
gene_name: Some("TESTGENE".to_string()),
contig: "NC_000001.11".to_string(),
strand: Strand::Plus,
exons: vec![[1000, 1009, 0, 9]],
cds_start: Some(0),
cds_end: Some(9),
gene_id: None,
protein: Some("NP_TEST.1".to_string()),
exon_cigars: Vec::new(),
},
);
let mut provider = MockProvider::new();
provider.add_transcript(Transcript::new(
"NM_TEST.1".to_string(),
Some("TESTGENE".to_string()),
TxStrand::Plus,
"ATGCGCTAA".to_string(),
Some(1),
Some(9),
vec![Exon::new(1, 1, 9)],
Some("NC_000001.11".to_string()),
Some(1000),
Some(1008),
Default::default(),
ManeStatus::default(),
None,
None,
));
provider.add_genomic_sequence(
"NC_000001.11",
format!("{}ATGCGCTAA{}", "N".repeat(1000), "N".repeat(100)),
);
VariantProjector::new(Projector::new(cdot), provider)
}
#[test]
fn axes_cover_the_five_projection_targets_exactly_once() {
let mut codes: Vec<char> = AXES.iter().map(|(c, _)| *c).collect();
codes.sort_unstable();
assert_eq!(codes, vec!['c', 'g', 'n', 'p', 'r']);
}
#[test]
fn coding_input_projects_every_axis_from_one_pass() {
let vp = fixture();
let v = crate::parse_hgvs("NM_TEST.1:c.4C>A").expect("parses");
let pass = project_all_axes(&vp, &v);
assert_eq!(pass.transcript.as_deref(), Some("NM_TEST.1"));
assert_eq!(pass.axes.len(), AXES.len());
assert_eq!(
pass.axes[&'c'],
AxisResult::Rendered("NM_TEST.1:c.4C>A".to_string())
);
assert!(
matches!(
&pass.axes[&'g'],
AxisResult::Unavailable(reason)
if !reason.contains("no g. representation for this variant")
&& reason.contains("NM_TEST.1")
&& reason.contains("genomic reference")
&& reason.contains("NC_000001.11(NM_TEST.1)")
),
"the g. decline must name the accession, the missing genomic \
reference, and the remedy, and must not fall back to the \
axis-code string: {:?}",
pass.axes[&'g']
);
assert_eq!(
pass.axes[&'p'],
AxisResult::Rendered("NP_TEST.1:p.(Arg2Ser)".to_string())
);
}
#[test]
fn bare_genomic_input_is_not_applicable_not_an_arbitrary_transcript() {
let vp = fixture();
let v = crate::parse_hgvs("NC_000001.11:g.1004C>A").expect("parses");
let pass = project_all_axes(&vp, &v);
assert_eq!(pass.transcript, None);
for (code, _) in AXES {
assert!(
matches!(pass.axes[&code], AxisResult::NotApplicable(_)),
"axis {code} should be not-applicable for a bare genomic input"
);
}
}
#[test]
fn protein_input_has_no_transcript_frame() {
let vp = fixture();
let v = crate::parse_hgvs("NP_TEST.1:p.Arg2Ser").expect("parses");
assert_eq!(transcript_for(&vp, &v), None);
}
#[test]
fn unknown_transcript_is_a_pinned_error_not_a_silent_drop() {
let vp = fixture();
let v = crate::parse_hgvs("NM_ABSENT.1:c.4C>A").expect("parses");
let pass = project_all_axes(&vp, &v);
assert_eq!(pass.transcript.as_deref(), Some("NM_ABSENT.1"));
for (code, _) in AXES {
assert!(
matches!(pass.axes[&code], AxisResult::Error(_)),
"axis {code} must surface the unknown transcript as a hard error, \
not drop it or soften it to unavailable: got {:?}",
pass.axes[&code]
);
}
}
#[test]
fn observed_rendering_distinguishes_the_outcome_kinds() {
assert_eq!(
AxisResult::Rendered("NM_TEST.1:c.4C>A".to_string()).as_observed(),
"NM_TEST.1:c.4C>A"
);
assert_eq!(
AxisResult::Unavailable("no p. representation".to_string()).as_observed(),
"unavailable: no p. representation"
);
assert_eq!(
AxisResult::Error("boom".to_string()).as_observed(),
"error: boom"
);
}
}