use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::data::cdot::CdotMapper;
use crate::reference::transcript::{Exon, GenomeBuild, ManeStatus, Strand, Transcript};
use crate::reference::MockProvider;
use crate::FerroError;
pub const TRANSCRIPTS_FASTA: &str = "transcripts.fna";
pub const TRANSCRIPTS_METADATA: &str = "transcripts.metadata.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Provenance {
pub source: String,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TranscriptEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cds_start: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cds_end: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gene_symbol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protein: Option<String>,
pub length: u64,
pub provenance: Provenance,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TranscriptSnapshot {
#[serde(default)]
pub description: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub captured_from: String,
pub transcripts: BTreeMap<String, TranscriptEntry>,
}
impl TranscriptSnapshot {
pub fn from_json_path<P: AsRef<Path>>(path: P) -> Result<Self, FerroError> {
let content = std::fs::read_to_string(path.as_ref())?;
let snapshot: TranscriptSnapshot = serde_json::from_str(&content)?;
Ok(snapshot)
}
pub fn to_json(&self) -> Result<String, FerroError> {
let mut s = serde_json::to_string_pretty(self)?;
s.push('\n');
Ok(s)
}
}
pub fn render_fasta(sequences: &BTreeMap<String, String>) -> String {
let mut out = String::new();
for (accession, bases) in sequences {
out.push('>');
out.push_str(accession);
out.push('\n');
out.push_str(bases);
out.push('\n');
}
out
}
pub fn parse_fasta(text: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
let mut current: Option<(String, String)> = None;
for line in text.lines() {
if let Some(header) = line.strip_prefix('>') {
if let Some((acc, seq)) = current.take() {
map.insert(acc, seq);
}
let acc = header.split_whitespace().next().unwrap_or("").to_string();
current = Some((acc, String::new()));
} else if let Some((_, seq)) = current.as_mut() {
seq.push_str(line.trim());
}
}
if let Some((acc, seq)) = current.take() {
map.insert(acc, seq);
}
map
}
pub fn load_sequences<P: AsRef<Path>>(dir: P) -> Result<BTreeMap<String, String>, FerroError> {
let path = dir.as_ref().join(TRANSCRIPTS_FASTA);
let text = std::fs::read_to_string(&path)?;
Ok(parse_fasta(&text))
}
impl TranscriptSnapshot {
pub fn to_provider(&self, sequences: &BTreeMap<String, String>) -> MockProvider {
let mut provider = MockProvider::new();
for transcript in self.build_transcripts(sequences) {
provider.add_transcript(transcript);
}
provider
}
pub fn to_cdot(&self, sequences: &BTreeMap<String, String>) -> CdotMapper {
CdotMapper::from_transcripts(self.build_transcripts(sequences).iter())
}
fn build_transcripts(&self, sequences: &BTreeMap<String, String>) -> Vec<Transcript> {
self.transcripts
.iter()
.filter_map(|(accession, entry)| {
let bases = sequences.get(accession)?;
Some(
Transcript::new(
accession.clone(),
entry.gene_symbol.clone(),
Strand::Plus,
Some(bases.clone()),
entry.cds_start,
entry.cds_end,
vec![Exon::new(1, 1, entry.length)],
None,
None,
None,
GenomeBuild::default(),
ManeStatus::default(),
None,
None,
)
.with_protein_id(entry.protein.clone()),
)
})
.collect()
}
}
pub fn load_provider<P: AsRef<Path>>(dir: P) -> Result<MockProvider, FerroError> {
let dir = dir.as_ref();
let snapshot = TranscriptSnapshot::from_json_path(dir.join(TRANSCRIPTS_METADATA))?;
let sequences = load_sequences(dir)?;
Ok(snapshot.to_provider(&sequences))
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(length: u64, cds: Option<(u64, u64)>) -> TranscriptEntry {
TranscriptEntry {
cds_start: cds.map(|c| c.0),
cds_end: cds.map(|c| c.1),
gene_symbol: Some("SDHD".to_string()),
protein: Some("NP_002993.1".to_string()),
length,
provenance: Provenance {
source: "manifest:transcript-fasta+cdot(GRCh37)".to_string(),
sha256: "deadbeef".to_string(),
},
}
}
#[test]
fn fasta_round_trips() {
let mut seqs = BTreeMap::new();
seqs.insert("NM_003002.2".to_string(), "ACGTACGT".to_string());
seqs.insert("NR_024274.1".to_string(), "TTTT".to_string());
let rendered = render_fasta(&seqs);
assert_eq!(rendered, ">NM_003002.2\nACGTACGT\n>NR_024274.1\nTTTT\n");
assert_eq!(parse_fasta(&rendered), seqs);
}
#[test]
fn parse_fasta_joins_wrapped_lines() {
let text = ">NM_003002.2 SDHD\nACGT\nACGT\n";
let parsed = parse_fasta(text);
assert_eq!(parsed.get("NM_003002.2"), Some(&"ACGTACGT".to_string()));
}
#[test]
fn metadata_json_round_trips() {
let mut snapshot = TranscriptSnapshot {
description: "test snapshot".to_string(),
captured_from: "2026-06-14".to_string(),
transcripts: BTreeMap::new(),
};
snapshot
.transcripts
.insert("NM_003002.2".to_string(), entry(1382, Some((62, 541))));
snapshot
.transcripts
.insert("NR_024274.1".to_string(), entry(2000, None));
let json = snapshot.to_json().expect("serializes");
assert!(json.ends_with('\n'), "trailing newline for stable --check");
let parsed: TranscriptSnapshot = serde_json::from_str(&json).expect("round-trips");
assert_eq!(parsed, snapshot);
}
#[test]
fn noncoding_entry_omits_cds_fields() {
let mut snapshot = TranscriptSnapshot::default();
snapshot
.transcripts
.insert("NR_024274.1".to_string(), entry(2000, None));
let json = snapshot.to_json().expect("serializes");
assert!(!json.contains("cds_start"), "cds_start omitted for NR_");
assert!(!json.contains("cds_end"), "cds_end omitted for NR_");
}
#[test]
fn unknown_field_is_rejected() {
let json = r#"{"description":"x","transcripts":{},"bogus":1}"#;
assert!(serde_json::from_str::<TranscriptSnapshot>(json).is_err());
}
#[test]
fn to_provider_serves_bases_and_cds() {
use crate::reference::ReferenceProvider;
let bases = "ACGTACGTAC".to_string(); let mut snapshot = TranscriptSnapshot::default();
snapshot.transcripts.insert(
"NM_000001.1".to_string(),
TranscriptEntry {
cds_start: Some(2),
cds_end: Some(7),
gene_symbol: Some("GENE".to_string()),
protein: Some("NP_000001.1".to_string()),
length: bases.len() as u64,
provenance: Provenance {
source: "test".to_string(),
sha256: "test".to_string(),
},
},
);
let mut sequences = BTreeMap::new();
sequences.insert("NM_000001.1".to_string(), bases.clone());
let provider = snapshot.to_provider(&sequences);
assert!(provider.has_transcript("NM_000001.1"));
assert_eq!(
provider.get_sequence("NM_000001.1", 0, 4).expect("bases"),
"ACGT"
);
let tx = provider.get_transcript("NM_000001.1").expect("transcript");
assert_eq!(tx.sequence.as_deref(), Some(bases.as_str()));
assert_eq!(tx.cds_start, Some(2));
assert_eq!(tx.cds_end, Some(7));
assert_eq!(tx.protein_id.as_deref(), Some("NP_000001.1"));
}
#[test]
fn to_cdot_drives_protein_projection() {
use crate::data::projection::Projector;
use crate::{parse_hgvs, VariantProjector};
use std::path::PathBuf;
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/mutalyzer-normalize/reference-snapshot");
let snapshot = TranscriptSnapshot::from_json_path(dir.join(TRANSCRIPTS_METADATA))
.expect("snapshot metadata loads");
let sequences = load_sequences(&dir).expect("snapshot FASTA loads");
let provider = snapshot.to_provider(&sequences);
let cdot = snapshot.to_cdot(&sequences);
let vp = VariantProjector::new(Projector::new(cdot), provider);
let v = parse_hgvs("NM_003002.2:c.273del").expect("parses");
let result = vp.project_variant(&v, "NM_003002.2").expect("projects");
let protein = result.protein.as_ref().expect("protein predicted");
assert_eq!(format!("{protein}"), "NP_002993.1:p.(Asp92ThrfsTer43)");
}
}