use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::reference::derived_placement::{DerivedPlacement, DerivedPlacements};
use crate::reference::provider::{select_placement_for_build, GenomicPlacement, ReferenceProvider};
use crate::reference::transcript::Transcript;
use crate::FerroError;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GenomicWindow {
pub contig: String,
pub start: u64,
pub bases: String,
}
impl GenomicWindow {
fn end(&self) -> u64 {
self.start + self.bases.len() as u64
}
fn slice(&self, start: u64, end: u64) -> Option<String> {
if start < self.start || end > self.end() || start > end {
return None;
}
let lo = (start - self.start) as usize;
let hi = (end - self.start) as usize;
Some(self.bases[lo..hi].to_string())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WindowFixture {
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub captured_from: String,
#[serde(default)]
pub contig_lengths: BTreeMap<String, u64>,
pub transcripts: Vec<Transcript>,
#[serde(default)]
pub genomic: Vec<GenomicWindow>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub placements: Vec<DerivedPlacement>,
}
impl WindowFixture {
pub fn from_json_path(path: &Path) -> Result<Self, FerroError> {
let content = std::fs::read_to_string(path)?;
let fixture: WindowFixture = serde_json::from_str(&content)?;
Ok(fixture)
}
pub fn to_json(&self) -> Result<String, FerroError> {
let mut s = serde_json::to_string_pretty(self)?;
s.push('\n');
Ok(s)
}
pub fn to_provider(&self) -> WindowProvider {
let transcripts: HashMap<String, Arc<Transcript>> = self
.transcripts
.iter()
.map(|tx| (tx.id.clone(), Arc::new(tx.clone())))
.collect();
let mut genomic: HashMap<String, Vec<GenomicWindow>> = HashMap::new();
for w in &self.genomic {
genomic.entry(w.contig.clone()).or_default().push(w.clone());
}
let mut placements: HashMap<String, Vec<GenomicPlacement>> = HashMap::new();
let resolved = DerivedPlacements {
description: String::new(),
placements: self.placements.clone(),
..Default::default()
};
for (parent, placement) in resolved.to_placements() {
placements.entry(parent).or_default().push(placement);
}
WindowProvider {
transcripts,
genomic,
contig_lengths: self
.contig_lengths
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect(),
placements,
}
}
}
#[derive(Debug, Clone)]
pub struct WindowProvider {
transcripts: HashMap<String, Arc<Transcript>>,
genomic: HashMap<String, Vec<GenomicWindow>>,
contig_lengths: HashMap<String, u64>,
placements: HashMap<String, Vec<GenomicPlacement>>,
}
impl WindowProvider {
fn is_known_contig(&self, id: &str) -> bool {
if self.genomic.contains_key(id) {
return true;
}
self.transcripts
.values()
.any(|tx| tx.chromosome.as_deref() == Some(id))
}
}
impl ReferenceProvider for WindowProvider {
fn get_transcript(&self, id: &str) -> Result<Arc<Transcript>, FerroError> {
if let Some(tx) = self.transcripts.get(id) {
return Ok(Arc::clone(tx));
}
if !id.contains('.') {
for (key, tx) in &self.transcripts {
if key.split('.').next().unwrap_or(key) == id {
return Ok(Arc::clone(tx));
}
}
}
Err(FerroError::ReferenceNotFound { id: id.to_string() })
}
fn get_sequence(&self, id: &str, start: u64, end: u64) -> Result<String, FerroError> {
if self.is_known_contig(id) {
return self.get_genomic_sequence(id, start, end);
}
let transcript = self.get_transcript(id)?;
transcript
.get_sequence(start, end)
.map(|s| s.to_string())
.ok_or_else(|| FerroError::InvalidCoordinates {
msg: format!(
"transcript {id} has no bases for {start}-{end} in the window fixture"
),
})
}
fn get_genomic_sequence(
&self,
contig: &str,
start: u64,
end: u64,
) -> Result<String, FerroError> {
let windows =
self.genomic
.get(contig)
.ok_or_else(|| FerroError::GenomicReferenceNotAvailable {
contig: contig.to_string(),
start,
end,
})?;
let end = match self.contig_lengths.get(contig) {
Some(&len) => end.min(len),
None => end,
};
for w in windows {
if let Some(s) = w.slice(start, end) {
return Ok(s);
}
}
Err(FerroError::InvalidCoordinates {
msg: format!(
"genomic request {contig}:{start}-{end} is not covered by any committed window \
(regenerate this fixture's reference-windows.json with its \
`examples/extract_*_windows.rs` generator)"
),
})
}
fn has_genomic_data(&self) -> bool {
!self.genomic.is_empty()
}
fn genomic_placement(
&self,
parent: &crate::hgvs::variant::Accession,
) -> Option<GenomicPlacement> {
self.genomic_placement_on_build(parent, None)
}
fn genomic_placement_on_build(
&self,
parent: &crate::hgvs::variant::Accession,
build: Option<&str>,
) -> Option<GenomicPlacement> {
let list = self.placements.get(&parent.full())?;
select_placement_for_build(
list,
build,
crate::liftover::aliases::infer_genome_build_from_accession,
)
}
fn get_sequence_length(&self, id: &str) -> Result<u64, FerroError> {
if let Some(&len) = self.contig_lengths.get(id) {
return Ok(len);
}
if let Ok(tx) = self.get_transcript(id) {
if let Some(seq) = &tx.sequence {
return Ok(seq.len() as u64);
}
}
if let Some(windows) = self.genomic.get(id) {
if let Some(max_end) = windows.iter().map(GenomicWindow::end).max() {
return Ok(max_end);
}
}
Err(FerroError::ReferenceNotFound { id: id.to_string() })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reference::transcript::{Exon, ManeStatus, Strand};
fn tx(id: &str, seq: &str) -> Transcript {
let len = seq.len() as u64;
Transcript::new(
id.to_string(),
Some("GENE".to_string()),
Strand::Plus,
seq.to_string(),
Some(1),
Some(len),
vec![Exon::new(1, 1, len)],
Some("NC_000001.11".to_string()),
None,
None,
Default::default(),
ManeStatus::None,
None,
None,
)
}
fn fixture() -> WindowFixture {
WindowFixture {
description: "test".to_string(),
captured_from: String::new(),
contig_lengths: [
("NC_000006.12".to_string(), 1012u64),
("NC_DEFECT.1".to_string(), 5000u64),
]
.into_iter()
.collect(),
transcripts: vec![tx("NM_TEST.1", "ACGTACGTACGT")],
genomic: vec![
GenomicWindow {
contig: "NC_000006.12".to_string(),
start: 1000,
bases: "AAACCCGGGTTT".to_string(),
},
GenomicWindow {
contig: "NC_DEFECT.1".to_string(),
start: 1000,
bases: "AAACCCGGGTTT".to_string(),
},
],
placements: Vec::new(),
}
}
#[test]
fn serves_ng_placement_from_fixture_and_round_trips() {
use crate::hgvs::variant::Accession;
use crate::reference::Strand;
let mut f = fixture();
f.placements = vec![DerivedPlacement {
parent: "NG_012337.1".to_string(),
nc: "NC_000011.10".to_string(),
nc_start: 112_081_847,
nc_end: 112_097_794,
strand: "+".to_string(),
anchored_by: String::new(),
mismatch_fraction: 0.0,
}];
let reloaded: WindowFixture = serde_json::from_str(&f.to_json().unwrap()).unwrap();
assert_eq!(reloaded.placements, f.placements);
let p = reloaded.to_provider();
let ng = Accession::new("NG", "012337", Some(1));
let placement = p
.genomic_placement(&ng)
.expect("WindowProvider serves the fixture's NG_ placement");
assert_eq!(placement.nc.full(), "NC_000011.10");
assert_eq!(placement.parent_start, 1);
assert_eq!(placement.nc_start, 112_081_847);
assert_eq!(placement.nc_end, 112_097_794);
assert_eq!(placement.strand, Strand::Plus);
assert!(p
.genomic_placement(&Accession::new("NG", "999999", Some(9)))
.is_none());
}
#[test]
fn transcript_exact_and_unversioned_lookup() {
let p = fixture().to_provider();
assert!(p.get_transcript("NM_TEST.1").is_ok());
assert!(p.get_transcript("NM_TEST").is_ok());
assert!(p.get_transcript("NM_TEST.2").is_err());
assert!(p.get_transcript("NM_OTHER.1").is_err());
}
#[test]
fn genomic_window_rebase_and_slice() {
let p = fixture().to_provider();
assert_eq!(
p.get_genomic_sequence("NC_000006.12", 1000, 1012).unwrap(),
"AAACCCGGGTTT"
);
assert_eq!(
p.get_genomic_sequence("NC_000006.12", 1003, 1006).unwrap(),
"CCC"
);
}
#[test]
fn over_range_read_clamps_to_contig_end() {
let p = fixture().to_provider();
assert_eq!(
p.get_genomic_sequence("NC_000006.12", 1006, 9999).unwrap(),
"GGGTTT"
);
}
#[test]
fn within_contig_but_outside_window_is_extraction_defect_error() {
let p = fixture().to_provider();
assert!(p.get_genomic_sequence("NC_000006.12", 999, 1005).is_err());
assert!(p.get_genomic_sequence("NC_DEFECT.1", 1006, 1500).is_err());
assert!(matches!(
p.get_genomic_sequence("NC_000009.12", 1, 2),
Err(FerroError::GenomicReferenceNotAvailable { .. })
));
}
#[test]
fn get_sequence_length_reports_true_contig_length() {
let p = fixture().to_provider();
assert_eq!(p.get_sequence_length("NC_DEFECT.1").unwrap(), 5000);
assert_eq!(p.get_sequence_length("NM_TEST.1").unwrap(), 12);
assert_eq!(p.get_sequence_length("NM_TEST").unwrap(), 12);
}
#[test]
fn get_sequence_dispatches_contig_vs_transcript() {
let p = fixture().to_provider();
assert_eq!(p.get_sequence("NC_000006.12", 1000, 1003).unwrap(), "AAA");
assert_eq!(p.get_sequence("NM_TEST.1", 1, 4).unwrap(), "CGT");
}
#[test]
fn roundtrips_through_json() {
let json = fixture().to_json().unwrap();
let back = serde_json::from_str::<WindowFixture>(&json).unwrap();
let p = back.to_provider();
assert_eq!(
p.get_genomic_sequence("NC_000006.12", 1000, 1012).unwrap(),
"AAACCCGGGTTT"
);
assert!(json.ends_with('\n'));
}
#[test]
fn has_genomic_data_reflects_windows() {
assert!(fixture().to_provider().has_genomic_data());
let empty = WindowFixture {
description: String::new(),
captured_from: String::new(),
contig_lengths: BTreeMap::new(),
transcripts: vec![tx("NM_TEST.1", "ACGT")],
genomic: vec![],
placements: Vec::new(),
};
assert!(!empty.to_provider().has_genomic_data());
}
}