use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub use crate::benchmark::types::{ParsedVariantDetails, PositionDetails};
use crate::hgvs::edit::NaEdit;
use crate::hgvs::variant::HgvsVariant;
pub(crate) fn extract_variant_details(variant: &HgvsVariant) -> Option<ParsedVariantDetails> {
match variant {
HgvsVariant::Cds(v) => {
let (variant_type, deleted, inserted) = if let Some(edit) = v.loc_edit.edit.inner() {
extract_na_edit_info(edit)
} else {
("unknown".to_string(), None, None)
};
Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "c".to_string(),
variant_type,
position: PositionDetails {
start: 0, end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted,
inserted,
was_shifted: None,
original_position: None,
})
}
HgvsVariant::Genome(v) => {
let (variant_type, deleted, inserted) = if let Some(edit) = v.loc_edit.edit.inner() {
extract_na_edit_info(edit)
} else {
("unknown".to_string(), None, None)
};
Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "g".to_string(),
variant_type,
position: PositionDetails {
start: 0, end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted,
inserted,
was_shifted: None,
original_position: None,
})
}
HgvsVariant::Tx(v) => {
let (variant_type, deleted, inserted) = if let Some(edit) = v.loc_edit.edit.inner() {
extract_na_edit_info(edit)
} else {
("unknown".to_string(), None, None)
};
Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "n".to_string(),
variant_type,
position: PositionDetails {
start: 0, end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted,
inserted,
was_shifted: None,
original_position: None,
})
}
HgvsVariant::Protein(v) => Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "p".to_string(),
variant_type: "protein_change".to_string(),
position: PositionDetails {
start: 0,
end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted: None,
inserted: None,
was_shifted: None,
original_position: None,
}),
HgvsVariant::Rna(v) => {
let (variant_type, deleted, inserted) = if let Some(edit) = v.loc_edit.edit.inner() {
extract_na_edit_info(edit)
} else {
("unknown".to_string(), None, None)
};
Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "r".to_string(),
variant_type,
position: PositionDetails {
start: 0, end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted,
inserted,
was_shifted: None,
original_position: None,
})
}
HgvsVariant::Mt(v) => {
let (variant_type, deleted, inserted) = if let Some(edit) = v.loc_edit.edit.inner() {
extract_na_edit_info(edit)
} else {
("unknown".to_string(), None, None)
};
Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "m".to_string(),
variant_type,
position: PositionDetails {
start: 0, end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted,
inserted,
was_shifted: None,
original_position: None,
})
}
HgvsVariant::Circular(v) => {
let (variant_type, deleted, inserted) = if let Some(edit) = v.loc_edit.edit.inner() {
extract_na_edit_info(edit)
} else {
("unknown".to_string(), None, None)
};
Some(ParsedVariantDetails {
reference: v.accession.to_string(),
coordinate_system: "o".to_string(),
variant_type,
position: PositionDetails {
start: 0, end: None,
offset: None,
display: v.loc_edit.location.to_string(),
},
deleted,
inserted,
was_shifted: None,
original_position: None,
})
}
HgvsVariant::RnaFusion(_)
| HgvsVariant::GenomeRing(_)
| HgvsVariant::Supernumerary(_)
| HgvsVariant::Allele(_)
| HgvsVariant::NullAllele
| HgvsVariant::UnknownAllele => None,
}
}
pub(crate) fn extract_na_edit_info(edit: &NaEdit) -> (String, Option<String>, Option<String>) {
match edit {
NaEdit::Substitution {
reference,
alternative,
} => (
"substitution".to_string(),
Some(reference.to_string()),
Some(alternative.to_string()),
),
NaEdit::SubstitutionNoRef { alternative } => (
"substitution".to_string(),
None,
Some(alternative.to_string()),
),
NaEdit::Deletion { sequence, length } => {
let deleted = sequence
.as_ref()
.map(|s| s.to_string())
.or_else(|| length.map(|l| format!("{} bp", l)));
("deletion".to_string(), deleted, None)
}
NaEdit::Insertion { sequence } => {
("insertion".to_string(), None, Some(sequence.to_string()))
}
NaEdit::Delins {
sequence,
deleted,
deleted_length,
..
} => {
let deleted = deleted
.as_ref()
.map(|s| s.to_string())
.or_else(|| deleted_length.map(|l| format!("{} bp", l)));
("delins".to_string(), deleted, Some(sequence.to_string()))
}
NaEdit::Duplication {
sequence, length, ..
} => {
let deleted = sequence
.as_ref()
.map(|s| s.to_string())
.or_else(|| length.map(|l| format!("{} bp", l)));
("duplication".to_string(), deleted, None)
}
NaEdit::Inversion { sequence, length } => {
let deleted = sequence
.as_ref()
.map(|s| s.to_string())
.or_else(|| length.map(|l| format!("{} bp", l)));
("inversion".to_string(), deleted, None)
}
NaEdit::Repeat {
sequence, count, ..
} => {
let seq = sequence.as_ref().map(|s| s.to_string());
("repeat".to_string(), seq, Some(format!("{}", count)))
}
NaEdit::Identity { .. } => ("identity".to_string(), None, None),
NaEdit::Unknown { .. } => ("unknown".to_string(), None, None),
NaEdit::NPaddedDeletion { count } => {
("deletion".to_string(), Some(format!("N[{}]", count)), None)
}
NaEdit::BreakpointInsertion { sequence } => {
("insertion".to_string(), None, Some(sequence.to_string()))
}
NaEdit::DupIns { sequence } => ("dupins".to_string(), None, Some(sequence.to_string())),
NaEdit::MultiRepeat { .. } => ("repeat".to_string(), None, None),
NaEdit::Conversion { .. } => ("conversion".to_string(), None, None),
NaEdit::Methylation { .. } => ("methylation".to_string(), None, None),
NaEdit::CopyNumber { .. } => ("copy_number".to_string(), None, None),
NaEdit::Splice { .. } => ("splice".to_string(), None, None),
NaEdit::NoProduct => ("no_product".to_string(), None, None),
NaEdit::PositionOnly => ("position_only".to_string(), None, None),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ToolName {
#[serde(rename = "ferro")]
Ferro,
#[serde(rename = "mutalyzer")]
Mutalyzer,
#[serde(rename = "biocommons")]
Biocommons,
#[serde(rename = "hgvs-rs")]
HgvsRs,
}
impl ToolName {
pub fn as_str(&self) -> &'static str {
match self {
ToolName::Ferro => "ferro",
ToolName::Mutalyzer => "mutalyzer",
ToolName::Biocommons => "biocommons",
ToolName::HgvsRs => "hgvs-rs",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"ferro" => Some(ToolName::Ferro),
"mutalyzer" => Some(ToolName::Mutalyzer),
"biocommons" => Some(ToolName::Biocommons),
"hgvs-rs" | "hgvsrs" => Some(ToolName::HgvsRs),
_ => None,
}
}
pub fn all() -> &'static [ToolName] {
&[
ToolName::Ferro,
ToolName::Mutalyzer,
ToolName::Biocommons,
ToolName::HgvsRs,
]
}
}
impl std::fmt::Display for ToolName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StructuredErrorCategory {
Parse(ParseErrorKind),
Reference(ReferenceErrorKind),
Validation(ValidationErrorKind),
Tool(ToolErrorKind),
Timeout,
Internal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ParseErrorKind {
InvalidSyntax,
InvalidAccession,
InvalidPosition,
InvalidEdit,
UnknownVariantType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReferenceErrorKind {
SequenceNotFound,
TranscriptNotFound,
SequenceMismatch,
ChromosomeNotFound,
DatabaseError,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidationErrorKind {
PositionOutOfBounds,
InvalidRange,
UnsupportedVariant,
IntronicNotSupported,
ProteinNotSupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolErrorKind {
Unavailable,
ConfigurationError,
ExecutionFailed,
IncompatibleVersion,
}
impl std::fmt::Display for StructuredErrorCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StructuredErrorCategory::Parse(kind) => write!(f, "parse_error_{:?}", kind),
StructuredErrorCategory::Reference(kind) => write!(f, "reference_error_{:?}", kind),
StructuredErrorCategory::Validation(kind) => write!(f, "validation_error_{:?}", kind),
StructuredErrorCategory::Tool(kind) => write!(f, "tool_error_{:?}", kind),
StructuredErrorCategory::Timeout => write!(f, "timeout"),
StructuredErrorCategory::Internal => write!(f, "internal_error"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ErrorMode {
Silent,
#[default]
Lenient,
Strict,
}
impl ErrorMode {
pub fn as_str(&self) -> &'static str {
match self {
ErrorMode::Silent => "silent",
ErrorMode::Lenient => "lenient",
ErrorMode::Strict => "strict",
}
}
}
impl std::fmt::Display for ErrorMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SingleRequest {
pub hgvs: String,
pub tools: Option<Vec<ToolName>>,
pub timeout_seconds: Option<u32>,
#[serde(default)]
pub error_mode: ErrorMode,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BatchRequest {
pub variants: Vec<String>,
pub tools: Option<Vec<ToolName>>,
pub timeout_seconds: Option<u32>,
#[serde(default)]
pub error_mode: ErrorMode,
}
#[derive(Debug, Serialize)]
pub struct SingleResponse {
pub input: String,
pub results: Vec<ToolResult>,
pub agreement: AgreementSummary,
pub processing_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct BatchResponse {
pub total_variants: usize,
pub successful_variants: usize,
pub results: Vec<VariantBatchResult>,
pub total_processing_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct VariantBatchResult {
pub input: String,
pub results: Vec<ToolResult>,
pub agreement: AgreementSummary,
}
#[derive(Debug, Serialize)]
pub struct ToolResult {
pub tool: ToolName,
pub success: bool,
pub output: Option<String>,
pub error: Option<String>,
pub error_category: Option<String>,
pub elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<ParsedVariantDetails>,
}
#[derive(Debug, Serialize)]
pub struct ValidateResponse {
pub input: String,
pub valid: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub errors: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub components: Option<ParsedVariantDetails>,
pub processing_time_ms: u64,
#[serde(skip_serializing_if = "std::ops::Not::not", default)]
pub wraps_origin: bool,
}
#[derive(Debug, Serialize)]
pub struct AgreementSummary {
pub all_agree: bool,
pub successful_tools: usize,
pub failed_tools: usize,
pub outputs: HashMap<String, Vec<ToolName>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolStatus {
pub tool: ToolName,
pub available: bool,
pub status: String,
pub last_check: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct HealthResponse {
pub status: String,
pub available_tools: Vec<ToolName>,
pub unavailable_tools: Vec<ToolName>,
pub tools: Vec<ToolStatus>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DetailedHealthResponse {
#[serde(flatten)]
pub basic: HealthResponse,
pub test_results: Vec<ToolTestResults>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolTestResults {
pub tool: ToolName,
pub passed: usize,
pub total: usize,
pub total_tests: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
pub categories: Vec<TestCategory>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestCategory {
pub name: String,
pub tests: Vec<TestResult>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TestStatus {
Pass,
Fail,
Na,
}
#[derive(Debug, Clone, Serialize)]
pub struct TestResult {
pub name: String,
pub variant: String,
pub status: TestStatus,
pub passed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
pub message: String,
pub details: Option<serde_json::Value>,
}
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("Tool unavailable: {0}")]
ToolUnavailable(String),
#[error("Invalid HGVS: {0}")]
InvalidHgvs(String),
#[error("Request timeout")]
Timeout,
#[error("Internal error: {0}")]
InternalError(String),
#[error("Configuration error: {0}")]
ConfigError(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Circuit breaker open - service temporarily unavailable")]
CircuitBreakerOpen,
}
impl ServiceError {
pub fn status_code(&self) -> u16 {
match self {
ServiceError::BadRequest(_) => 400,
ServiceError::InvalidHgvs(_) => 400,
ServiceError::Timeout => 408,
ServiceError::ConfigError(_) => 500,
ServiceError::InternalError(_) => 500,
ServiceError::ToolUnavailable(_) => 503,
ServiceError::CircuitBreakerOpen => 503,
}
}
pub fn to_response(&self) -> ErrorResponse {
ErrorResponse {
error: match self {
ServiceError::BadRequest(_) => "bad_request".to_string(),
ServiceError::InvalidHgvs(_) => "invalid_hgvs".to_string(),
ServiceError::Timeout => "timeout".to_string(),
ServiceError::ConfigError(_) => "config_error".to_string(),
ServiceError::InternalError(_) => "internal_error".to_string(),
ServiceError::ToolUnavailable(_) => "tool_unavailable".to_string(),
ServiceError::CircuitBreakerOpen => "circuit_breaker_open".to_string(),
},
message: self.to_string(),
details: None,
}
}
}
pub mod error_analysis {
use super::*;
pub trait ErrorAnalyzer {
fn analyze_error(&self, error: &str) -> StructuredErrorCategory;
}
pub struct FerroErrorAnalyzer;
impl ErrorAnalyzer for FerroErrorAnalyzer {
fn analyze_error(&self, error: &str) -> StructuredErrorCategory {
if let Some(error_code) = extract_ferro_error_code(error) {
match error_code / 1000 {
1 => {
match error_code {
1001 => {
StructuredErrorCategory::Parse(ParseErrorKind::InvalidAccession)
}
1002 => StructuredErrorCategory::Parse(ParseErrorKind::InvalidSyntax),
1003 => StructuredErrorCategory::Parse(ParseErrorKind::InvalidPosition),
1004 => StructuredErrorCategory::Parse(ParseErrorKind::InvalidEdit),
_ => StructuredErrorCategory::Parse(ParseErrorKind::InvalidSyntax),
}
}
2 => {
match error_code {
2001 => StructuredErrorCategory::Reference(
ReferenceErrorKind::SequenceNotFound,
),
2002 => StructuredErrorCategory::Reference(
ReferenceErrorKind::TranscriptNotFound,
),
2003 => StructuredErrorCategory::Reference(
ReferenceErrorKind::SequenceMismatch,
),
_ => StructuredErrorCategory::Reference(
ReferenceErrorKind::SequenceNotFound,
),
}
}
3 => {
match error_code {
3001 => StructuredErrorCategory::Validation(
ValidationErrorKind::PositionOutOfBounds,
),
3002 => StructuredErrorCategory::Validation(
ValidationErrorKind::InvalidRange,
),
3003 => StructuredErrorCategory::Validation(
ValidationErrorKind::UnsupportedVariant,
),
_ => StructuredErrorCategory::Validation(
ValidationErrorKind::PositionOutOfBounds,
),
}
}
_ => StructuredErrorCategory::Internal,
}
} else {
analyze_generic_error(error)
}
}
}
pub struct MutalyzerErrorAnalyzer;
impl ErrorAnalyzer for MutalyzerErrorAnalyzer {
fn analyze_error(&self, error: &str) -> StructuredErrorCategory {
let lower_error = error.to_lowercase();
if lower_error.contains("parse") || lower_error.contains("syntax") {
StructuredErrorCategory::Parse(ParseErrorKind::InvalidSyntax)
} else if lower_error.contains("sequence") && lower_error.contains("not") {
StructuredErrorCategory::Reference(ReferenceErrorKind::SequenceNotFound)
} else if lower_error.contains("transcript") && lower_error.contains("not") {
StructuredErrorCategory::Reference(ReferenceErrorKind::TranscriptNotFound)
} else if lower_error.contains("esequencemismatch") || lower_error.contains("mismatch")
{
StructuredErrorCategory::Reference(ReferenceErrorKind::SequenceMismatch)
} else if lower_error.contains("position") && lower_error.contains("out") {
StructuredErrorCategory::Validation(ValidationErrorKind::PositionOutOfBounds)
} else if lower_error.contains("unavailable") || lower_error.contains("connection") {
StructuredErrorCategory::Tool(ToolErrorKind::Unavailable)
} else {
analyze_generic_error(error)
}
}
}
pub struct BiocommonsErrorAnalyzer;
impl ErrorAnalyzer for BiocommonsErrorAnalyzer {
fn analyze_error(&self, error: &str) -> StructuredErrorCategory {
let lower_error = error.to_lowercase();
if lower_error.contains("parse") || lower_error.contains("invalid hgvs") {
StructuredErrorCategory::Parse(ParseErrorKind::InvalidSyntax)
} else if lower_error.contains("retrieval") || lower_error.contains("not found") {
StructuredErrorCategory::Reference(ReferenceErrorKind::SequenceNotFound)
} else if lower_error.contains("range") || lower_error.contains("bounds") {
StructuredErrorCategory::Validation(ValidationErrorKind::PositionOutOfBounds)
} else if lower_error.contains("not_supported") || lower_error.contains("unsupported") {
StructuredErrorCategory::Validation(ValidationErrorKind::UnsupportedVariant)
} else if lower_error.contains("esequencemismatch") || lower_error.contains("mismatch")
{
StructuredErrorCategory::Reference(ReferenceErrorKind::SequenceMismatch)
} else if lower_error.contains("subprocess") || lower_error.contains("python") {
StructuredErrorCategory::Tool(ToolErrorKind::ExecutionFailed)
} else {
analyze_generic_error(error)
}
}
}
pub struct HgvsRsErrorAnalyzer;
impl ErrorAnalyzer for HgvsRsErrorAnalyzer {
fn analyze_error(&self, error: &str) -> StructuredErrorCategory {
let lower_error = error.to_lowercase();
if lower_error.contains("parse") || lower_error.contains("syntax") {
StructuredErrorCategory::Parse(ParseErrorKind::InvalidSyntax)
} else if lower_error.contains("validation") {
StructuredErrorCategory::Validation(ValidationErrorKind::UnsupportedVariant)
} else if lower_error.contains("connection") || lower_error.contains("database") {
StructuredErrorCategory::Reference(ReferenceErrorKind::DatabaseError)
} else if lower_error.contains("transcript") {
StructuredErrorCategory::Reference(ReferenceErrorKind::TranscriptNotFound)
} else if lower_error.contains("intronic") {
StructuredErrorCategory::Validation(ValidationErrorKind::IntronicNotSupported)
} else if lower_error.contains("protein") {
StructuredErrorCategory::Validation(ValidationErrorKind::ProteinNotSupported)
} else if lower_error.contains("panic") || lower_error.contains("thread") {
StructuredErrorCategory::Internal
} else {
analyze_generic_error(error)
}
}
}
fn extract_ferro_error_code(error: &str) -> Option<u32> {
for word in error.split_whitespace() {
if let Some(code_part) = word.strip_prefix('E') {
if let Ok(code) = code_part.parse::<u32>() {
if (1000..=9999).contains(&code) {
return Some(code);
}
}
}
}
None
}
fn analyze_generic_error(error: &str) -> StructuredErrorCategory {
let lower_error = error.to_lowercase();
if lower_error.contains("timeout") {
StructuredErrorCategory::Timeout
} else if lower_error.contains("unavailable") || lower_error.contains("unreachable") {
StructuredErrorCategory::Tool(ToolErrorKind::Unavailable)
} else if lower_error.contains("configuration") || lower_error.contains("config") {
StructuredErrorCategory::Tool(ToolErrorKind::ConfigurationError)
} else {
StructuredErrorCategory::Internal
}
}
pub fn get_error_analyzer(tool: ToolName) -> Box<dyn ErrorAnalyzer> {
match tool {
ToolName::Ferro => Box::new(FerroErrorAnalyzer),
ToolName::Mutalyzer => Box::new(MutalyzerErrorAnalyzer),
ToolName::Biocommons => Box::new(BiocommonsErrorAnalyzer),
ToolName::HgvsRs => Box::new(HgvsRsErrorAnalyzer),
}
}
}
pub fn analyze_error_structured(tool: ToolName, error: &str) -> StructuredErrorCategory {
let analyzer = error_analysis::get_error_analyzer(tool);
analyzer.analyze_error(error)
}
pub fn categorize_error(tool: ToolName, error: &str) -> String {
analyze_error_structured(tool, error).to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CoordinateSystem {
C,
G,
P,
N,
}
impl CoordinateSystem {
pub fn as_str(&self) -> &'static str {
match self {
CoordinateSystem::C => "c",
CoordinateSystem::G => "g",
CoordinateSystem::P => "p",
CoordinateSystem::N => "n",
}
}
}
impl std::fmt::Display for CoordinateSystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ConvertRequest {
pub hgvs: String,
pub target_system: CoordinateSystem,
#[serde(default)]
pub include_all: bool,
}
#[derive(Debug, Serialize)]
pub struct ConvertResponse {
pub input: String,
pub source_system: String,
pub target_system: String,
pub converted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub all_conversions: Option<Vec<ConversionResult>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub processing_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct ConversionResult {
pub system: String,
pub hgvs: String,
pub reference: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EffectRequest {
pub hgvs: String,
#[serde(default)]
pub include_nmd: bool,
}
#[derive(Debug, Serialize)]
pub struct EffectResponse {
pub input: String,
pub effect: Option<SequenceEffect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub protein_consequence: Option<ProteinConsequence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nmd_prediction: Option<NmdPrediction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub processing_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct SequenceEffect {
pub so_term: String,
pub name: String,
pub description: String,
pub impact: String,
}
#[derive(Debug, Serialize)]
pub struct ProteinConsequence {
pub hgvs_p: String,
pub ref_aa: String,
pub alt_aa: String,
pub position: u64,
pub is_frameshift: bool,
}
#[derive(Debug, Serialize)]
pub struct NmdPrediction {
pub predicted: bool,
pub confidence: f64,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum GenomeBuild {
#[serde(rename = "GRCh37", alias = "hg19")]
GRCh37,
#[serde(rename = "GRCh38", alias = "hg38")]
GRCh38,
}
impl GenomeBuild {
pub fn as_str(&self) -> &'static str {
match self {
GenomeBuild::GRCh37 => "GRCh37",
GenomeBuild::GRCh38 => "GRCh38",
}
}
}
impl std::fmt::Display for GenomeBuild {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LiftoverRequest {
pub position: String,
pub from_build: GenomeBuild,
pub to_build: GenomeBuild,
}
#[derive(Debug, Serialize)]
pub struct LiftoverResponse {
pub input: String,
pub from_build: String,
pub to_build: String,
pub converted: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hgvs_g: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chain_region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub processing_time_ms: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct VcfToHgvsRequest {
pub chrom: String,
pub pos: u64,
#[serde(rename = "ref")]
pub ref_allele: String,
pub alt: String,
#[serde(default = "default_grch38")]
pub build: GenomeBuild,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<String>,
}
fn default_grch38() -> GenomeBuild {
GenomeBuild::GRCh38
}
#[derive(Debug, Clone, Deserialize)]
pub struct HgvsToVcfRequest {
pub hgvs: String,
#[serde(default = "default_grch38")]
pub build: GenomeBuild,
}
#[derive(Debug, Serialize)]
pub struct VcfToHgvsResponse {
pub vcf: VcfRecord,
pub hgvs_g: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hgvs_c: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hgvs_p: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub processing_time_ms: u64,
}
#[derive(Debug, Serialize)]
pub struct HgvsToVcfResponse {
pub input: String,
pub vcf: Option<VcfRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub processing_time_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VcfRecord {
pub chrom: String,
pub pos: u64,
#[serde(rename = "ref")]
pub ref_allele: String,
pub alt: String,
pub build: String,
}
pub mod health_check {
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthCheckResult {
Healthy,
Degraded { reason: String },
Unhealthy { reason: String },
}
impl HealthCheckResult {
pub fn is_available(&self) -> bool {
matches!(
self,
HealthCheckResult::Healthy | HealthCheckResult::Degraded { .. }
)
}
pub fn status_string(&self) -> &'static str {
match self {
HealthCheckResult::Healthy => "healthy",
HealthCheckResult::Degraded { .. } => "degraded",
HealthCheckResult::Unhealthy { .. } => "unhealthy",
}
}
}
#[async_trait::async_trait]
pub trait HealthChecker {
async fn check_availability(&self) -> HealthCheckResult;
fn tool_name(&self) -> ToolName;
}
#[derive(Debug, Clone)]
pub struct HealthCheckConfig {
pub test_variant: String,
pub timeout_seconds: u64,
pub expected_behaviors: Vec<ExpectedBehavior>,
}
#[derive(Debug, Clone)]
pub enum ExpectedBehavior {
Success,
AcceptableFailure(Vec<StructuredErrorCategory>),
Responsive,
}
impl Default for HealthCheckConfig {
fn default() -> Self {
Self {
test_variant: "NM_000001.2:c.1A>G".to_string(),
timeout_seconds: 10,
expected_behaviors: vec![
ExpectedBehavior::Success,
ExpectedBehavior::AcceptableFailure(vec![
StructuredErrorCategory::Reference(ReferenceErrorKind::SequenceNotFound),
StructuredErrorCategory::Reference(ReferenceErrorKind::TranscriptNotFound),
StructuredErrorCategory::Validation(
ValidationErrorKind::UnsupportedVariant,
),
]),
ExpectedBehavior::Responsive,
],
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn extract(hgvs: &str) -> Option<ParsedVariantDetails> {
let result = crate::hgvs::parser::parse_hgvs_lenient(hgvs).unwrap();
extract_variant_details(&result.result)
}
#[test]
fn test_extract_variant_details_cds() {
let d = extract("NM_000249.4:c.350C>T").expect("cds details");
assert_eq!(d.coordinate_system, "c");
assert_eq!(d.variant_type, "substitution");
assert_eq!(d.reference, "NM_000249.4");
}
#[test]
fn test_extract_variant_details_genomic() {
let d = extract("NC_000007.14:g.117559593G>A").expect("genomic details");
assert_eq!(d.coordinate_system, "g");
assert_eq!(d.variant_type, "substitution");
}
#[test]
fn test_extract_variant_details_noncoding() {
let d = extract("NR_000001.1:n.100A>G").expect("noncoding details");
assert_eq!(d.coordinate_system, "n");
}
#[test]
fn test_extract_variant_details_protein() {
let d = extract("NP_000240.1:p.Val600Glu").expect("protein details");
assert_eq!(d.coordinate_system, "p");
assert_eq!(d.variant_type, "protein_change");
}
#[test]
fn test_extract_variant_details_rna() {
let d = extract("NR_000001.1:r.100a>g").expect("rna details should be populated");
assert_eq!(d.coordinate_system, "r");
}
#[test]
fn test_extract_variant_details_mitochondrial() {
let d =
extract("NC_012920.1:m.8993T>G").expect("mitochondrial details should be populated");
assert_eq!(d.coordinate_system, "m");
}
#[test]
fn test_extract_variant_details_circular() {
let d = extract("J01749.1:o.100A>G").expect("circular details should be populated");
assert_eq!(d.coordinate_system, "o");
}
#[test]
fn test_extract_variant_details_allele_returns_none() {
let result =
crate::hgvs::parser::parse_hgvs_lenient("NM_000088.3:c.[10A>G;20C>T]").unwrap();
assert!(matches!(result.result, HgvsVariant::Allele(_)));
assert!(extract_variant_details(&result.result).is_none());
}
fn edit_info(hgvs: &str) -> (String, Option<String>, Option<String>) {
let result = crate::hgvs::parser::parse_hgvs_lenient(hgvs).unwrap();
match &result.result {
HgvsVariant::Cds(v) => {
let edit = v.loc_edit.edit.inner().expect("inner edit");
extract_na_edit_info(edit)
}
other => panic!("expected a CDS variant, got {other:?}"),
}
}
#[test]
fn test_extract_na_edit_info_substitution() {
let (vtype, deleted, inserted) = edit_info("NM_000249.4:c.350C>T");
assert_eq!(vtype, "substitution");
assert_eq!(deleted, Some("C".to_string()));
assert_eq!(inserted, Some("T".to_string()));
}
#[test]
fn test_extract_na_edit_info_deletion() {
let (vtype, deleted, inserted) = edit_info("NM_000249.4:c.350delC");
assert_eq!(vtype, "deletion");
assert_eq!(deleted, None);
assert!(inserted.is_none());
}
#[test]
fn test_extract_na_edit_info_insertion() {
let (vtype, deleted, inserted) = edit_info("NM_000249.4:c.350_351insATG");
assert_eq!(vtype, "insertion");
assert!(deleted.is_none());
assert_eq!(inserted, Some("ATG".to_string()));
}
#[test]
fn test_extract_na_edit_info_delins() {
let (vtype, deleted, inserted) = edit_info("NM_000249.4:c.350delinsATG");
assert_eq!(vtype, "delins");
assert_eq!(deleted, None, "short form has no explicit deleted");
assert_eq!(inserted, Some("ATG".to_string()));
}
#[test]
fn test_extract_na_edit_info_delins_with_explicit_deleted_seq() {
let (vtype, deleted, inserted) = edit_info("NM_000249.4:c.350_352delATGinsTTCC");
assert_eq!(vtype, "delins");
assert_eq!(deleted, Some("ATG".to_string()));
assert_eq!(inserted, Some("TTCC".to_string()));
}
#[test]
fn test_extract_na_edit_info_delins_with_explicit_deleted_length() {
let (vtype, deleted, inserted) = edit_info("NM_000249.4:c.350_352del3insTA");
assert_eq!(vtype, "delins");
assert_eq!(deleted, Some("3 bp".to_string()));
assert_eq!(inserted, Some("TA".to_string()));
}
#[test]
fn test_extract_na_edit_info_duplication() {
let (vtype, deleted, _inserted) = edit_info("NM_000249.4:c.350dupC");
assert_eq!(vtype, "duplication");
assert_eq!(deleted, None);
}
}