use crate::coords::hgvs_pos_to_index;
use crate::error::FerroError;
use crate::error_handling::ResolvedAction;
use crate::hgvs::edit::{Base, NaEdit, RepeatUnit, Sequence};
use crate::normalize::config::NormalizeConfig;
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub warning: Option<String>,
pub actual_ref: Option<String>,
pub stated_ref: Option<String>,
}
impl ValidationResult {
pub fn ok() -> Self {
Self {
valid: true,
warning: None,
actual_ref: None,
stated_ref: None,
}
}
pub fn mismatch(stated: String, actual: String) -> Self {
Self {
valid: false,
warning: Some(format!(
"Reference mismatch: stated '{}' but actual is '{}'",
stated, actual
)),
actual_ref: Some(actual),
stated_ref: Some(stated),
}
}
}
pub fn validate_reference(edit: &NaEdit, ref_seq: &[u8], start: u64, end: u64) -> ValidationResult {
match edit {
NaEdit::Substitution { reference, .. } => validate_single_base(reference, ref_seq, start),
NaEdit::Deletion { sequence, length } => {
if let Some(seq) = sequence {
validate_sequence(seq.bases(), ref_seq, start, end)
} else if let Some(n) = length {
validate_stated_length(*n, start, end)
} else {
ValidationResult::ok()
}
}
NaEdit::Delins {
deleted,
deleted_length,
substitution_reference,
..
} => {
if let Some(seq) = deleted {
validate_sequence(seq.bases(), ref_seq, start, end)
} else if let Some(seq) = substitution_reference {
validate_sequence(seq.bases(), ref_seq, start, end)
} else if let Some(n) = deleted_length {
validate_stated_length(*n, start, end)
} else {
ValidationResult::ok()
}
}
NaEdit::Duplication {
sequence,
length,
uncertain_extent,
} => {
if let Some(seq) = sequence {
validate_sequence(seq.bases(), ref_seq, start, end)
} else if let Some(n) = length {
if uncertain_extent.is_some() {
ValidationResult::ok()
} else {
validate_stated_length(*n, start, end)
}
} else {
ValidationResult::ok()
}
}
NaEdit::Inversion { sequence, .. } => {
if let Some(seq) = sequence {
validate_sequence(seq.bases(), ref_seq, start, end)
} else {
ValidationResult::ok()
}
}
NaEdit::Repeat {
sequence: Some(unit),
additional_counts,
trailing,
..
} => {
if trailing.is_some() || !additional_counts.is_empty() {
ValidationResult::ok()
} else {
validate_repeat_tract(unit, ref_seq, start, end)
}
}
NaEdit::MultiRepeat { units } => validate_multirepeat_tract(units, ref_seq, start, end),
_ => ValidationResult::ok(),
}
}
fn validate_repeat_tract(
unit: &Sequence,
ref_seq: &[u8],
start: u64,
end: u64,
) -> ValidationResult {
let unit_bases = unit.bases();
if unit_bases.is_empty() {
return ValidationResult::ok();
}
let start_idx = hgvs_pos_to_index(start);
let end_idx = end as usize;
if end_idx > ref_seq.len() || start_idx > end_idx {
return ValidationResult::ok();
}
let span = end_idx - start_idx;
let unit_len = unit_bases.len();
let unit_str: String = unit_bases.iter().map(|b| b.to_char()).collect();
let actual_bytes = &ref_seq[start_idx..end_idx];
let actual_str: String = actual_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
if !span.is_multiple_of(unit_len) {
return ValidationResult::mismatch(
format!("{}[k] (k whole copies)", unit_str),
format!(
"{} ({} bp; unit_len {} does not divide span)",
actual_str, span, unit_len
),
);
}
let k = span / unit_len;
let mut expected = Vec::with_capacity(span);
let unit_bytes: Vec<u8> = unit_bases.iter().map(|b| b.to_u8()).collect();
for _ in 0..k {
expected.extend_from_slice(&unit_bytes);
}
let matches = expected
.iter()
.zip(actual_bytes.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if matches {
return ValidationResult::ok();
}
let expected_str: String = expected
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
ValidationResult::mismatch(
format!("{}[{}] ({})", unit_str, k, expected_str),
actual_str,
)
}
fn validate_multirepeat_tract(
units: &[RepeatUnit],
ref_seq: &[u8],
start: u64,
end: u64,
) -> ValidationResult {
use crate::hgvs::edit::RepeatCount;
if units.is_empty() {
return ValidationResult::ok();
}
let mut prefix_bytes: Vec<u8> = Vec::new();
let mut prefix_str = String::new();
let mut prefix_units = 0usize;
for u in units {
let n = match u.count {
RepeatCount::Exact(n) => n,
_ => break,
};
let unit_bytes: Vec<u8> = u.sequence.bases().iter().map(|b| b.to_u8()).collect();
let unit_str: String = u.sequence.bases().iter().map(|b| b.to_char()).collect();
prefix_str.push_str(&format!("{}[{}]", unit_str, n));
for _ in 0..n {
prefix_bytes.extend_from_slice(&unit_bytes);
}
prefix_units += 1;
}
let all_exact = prefix_units == units.len();
let mut suffix_bytes: Vec<u8> = Vec::new();
let mut suffix_str = String::new();
let mut suffix_units = 0usize;
if !all_exact {
for u in units[prefix_units..].iter().rev() {
let n = match u.count {
RepeatCount::Exact(n) => n,
_ => break,
};
let unit_bytes: Vec<u8> = u.sequence.bases().iter().map(|b| b.to_u8()).collect();
let unit_str: String = u.sequence.bases().iter().map(|b| b.to_char()).collect();
suffix_str.insert_str(0, &format!("{}[{}]", unit_str, n));
let mut new_bytes =
Vec::with_capacity(suffix_bytes.len() + unit_bytes.len() * n as usize);
for _ in 0..n {
new_bytes.extend_from_slice(&unit_bytes);
}
new_bytes.extend_from_slice(&suffix_bytes);
suffix_bytes = new_bytes;
suffix_units += 1;
}
}
if prefix_units == 0 && suffix_units == 0 {
return ValidationResult::ok();
}
let start_idx = hgvs_pos_to_index(start);
let end_idx = end as usize;
if end_idx > ref_seq.len() || start_idx > end_idx {
return ValidationResult::ok();
}
let actual_bytes = &ref_seq[start_idx..end_idx];
if all_exact {
let actual_str: String = actual_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
if prefix_bytes.len() != actual_bytes.len() {
return ValidationResult::mismatch(
format!(
"{} ({} bp from declared multi-repeat units)",
prefix_str,
prefix_bytes.len()
),
format!("{} ({} bp)", actual_str, actual_bytes.len()),
);
}
let matches = prefix_bytes
.iter()
.zip(actual_bytes.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if matches {
return ValidationResult::ok();
}
let expected_bases_str: String = prefix_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
return ValidationResult::mismatch(
format!("{} ({})", prefix_str, expected_bases_str),
actual_str,
);
}
debug_assert!(
prefix_units + suffix_units <= units.len(),
"prefix+suffix overlap invariant violated: prefix_units={} \
suffix_units={} units.len()={}",
prefix_units,
suffix_units,
units.len(),
);
let middle_units = &units[prefix_units..units.len() - suffix_units];
let mut middle_min_bp: usize = 0;
for u in middle_units {
let min_copies = match u.count {
RepeatCount::Exact(n) => n as usize,
RepeatCount::Range(lo, _) => lo as usize,
RepeatCount::UncertainRange(lo, _) => lo as usize,
RepeatCount::MinUncertain(lo) => lo as usize,
RepeatCount::MaxUncertain(_) | RepeatCount::Unknown => 0,
};
middle_min_bp = middle_min_bp.saturating_add(min_copies * u.sequence.bases().len());
}
let declared_min_bp = prefix_bytes.len() + middle_min_bp + suffix_bytes.len();
if declared_min_bp > actual_bytes.len() {
let combined_str = if prefix_units == 0 {
suffix_str.clone()
} else if suffix_units == 0 {
prefix_str.clone()
} else {
format!("{}…{}", prefix_str, suffix_str)
};
let actual_str: String = actual_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
let (declared_note, actual_note) = if middle_min_bp > 0 {
(
format!(
" (>= {} bp from declared anchored units; middle requires >= {} bp)",
declared_min_bp, middle_min_bp,
),
format!(
" ({} bp; reference span too short for prefix+middle_min+suffix)",
actual_bytes.len()
),
)
} else {
(
format!(" ({} bp from declared anchored units)", declared_min_bp),
format!(
" ({} bp; reference span too short for prefix+suffix)",
actual_bytes.len()
),
)
};
return ValidationResult::mismatch(
format!("{}{}", combined_str, declared_note),
format!("{}{}", actual_str, actual_note),
);
}
if !prefix_bytes.is_empty() {
let prefix_actual = &actual_bytes[..prefix_bytes.len()];
let matches = prefix_bytes
.iter()
.zip(prefix_actual.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if !matches {
let expected_bases_str: String = prefix_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
let prefix_actual_str: String = prefix_actual
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
return ValidationResult::mismatch(
format!("{} ({})", prefix_str, expected_bases_str),
prefix_actual_str,
);
}
}
if !suffix_bytes.is_empty() {
let suffix_start = actual_bytes.len() - suffix_bytes.len();
let suffix_actual = &actual_bytes[suffix_start..];
let matches = suffix_bytes
.iter()
.zip(suffix_actual.iter())
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if !matches {
let expected_bases_str: String = suffix_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
let suffix_actual_str: String = suffix_actual
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
return ValidationResult::mismatch(
format!("{} ({})", suffix_str, expected_bases_str),
suffix_actual_str,
);
}
}
ValidationResult::ok()
}
fn validate_single_base(stated: &Base, ref_seq: &[u8], pos: u64) -> ValidationResult {
let idx = hgvs_pos_to_index(pos);
if idx >= ref_seq.len() {
return ValidationResult::mismatch(
stated.to_char().to_string(),
"(position out of range)".to_string(),
);
}
let actual_byte = ref_seq[idx];
let stated_byte = stated.to_u8();
if actual_byte.eq_ignore_ascii_case(&stated_byte) {
ValidationResult::ok()
} else {
let actual_char = (actual_byte as char).to_ascii_uppercase();
ValidationResult::mismatch(stated.to_char().to_string(), actual_char.to_string())
}
}
fn validate_sequence(stated: &[Base], ref_seq: &[u8], start: u64, end: u64) -> ValidationResult {
let start_idx = hgvs_pos_to_index(start);
let end_idx = end as usize;
if end_idx > ref_seq.len() {
let stated_str: String = stated.iter().map(|b| b.to_char()).collect();
return ValidationResult::mismatch(stated_str, "(position out of range)".to_string());
}
if start_idx > end_idx {
let stated_str: String = stated.iter().map(|b| b.to_char()).collect();
return ValidationResult::mismatch(stated_str, "(inverted range)".to_string());
}
let actual_bytes = &ref_seq[start_idx..end_idx];
if stated.len() != actual_bytes.len() {
let stated_str: String = stated.iter().map(|b| b.to_char()).collect();
let actual_str: String = actual_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
return ValidationResult::mismatch(stated_str, actual_str);
}
for (stated_base, &actual_byte) in stated.iter().zip(actual_bytes.iter()) {
let stated_byte = stated_base.to_u8();
if !actual_byte.eq_ignore_ascii_case(&stated_byte) {
let stated_str: String = stated.iter().map(|b| b.to_char()).collect();
let actual_str: String = actual_bytes
.iter()
.map(|&b| (b as char).to_ascii_uppercase())
.collect();
return ValidationResult::mismatch(stated_str, actual_str);
}
}
ValidationResult::ok()
}
#[must_use]
fn validate_stated_length(declared: u64, start: u64, end: u64) -> ValidationResult {
if end < start {
return ValidationResult::ok();
}
let span = end - start + 1;
if declared == span {
ValidationResult::ok()
} else {
ValidationResult::mismatch(format!("length {declared}"), format!("span {span}"))
}
}
pub fn apply_validation_policy(
result: &ValidationResult,
config: &NormalizeConfig,
variant_str: &str,
) -> Result<(), FerroError> {
if result.valid {
return Ok(());
}
let action = config.ref_mismatch_action();
match action {
ResolvedAction::Reject => Err(FerroError::ReferenceMismatch {
location: variant_str.to_string(),
expected: result.stated_ref.clone().unwrap_or_else(|| "?".to_string()),
found: result.actual_ref.clone().unwrap_or_else(|| "?".to_string()),
}),
ResolvedAction::WarnCorrect => {
if let Some(ref warning) = result.warning {
eprintln!("Warning: {} in '{}'", warning, variant_str);
}
Ok(())
}
ResolvedAction::SilentCorrect | ResolvedAction::Accept => {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hgvs::edit::{InsertedSequence, Sequence, UncertainDupExtent};
use std::str::FromStr;
#[test]
fn test_validate_substitution_match() {
let edit = NaEdit::Substitution {
reference: Base::A,
alternative: Base::G,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 1);
assert!(result.valid);
}
#[test]
fn test_validate_substitution_mismatch() {
let edit = NaEdit::Substitution {
reference: Base::G, alternative: Base::A,
};
let ref_seq = b"ATGC"; let result = validate_reference(&edit, ref_seq, 1, 1);
assert!(!result.valid);
assert_eq!(result.stated_ref, Some("G".to_string()));
assert_eq!(result.actual_ref, Some("A".to_string()));
}
#[test]
fn test_validate_deletion_match() {
let edit = NaEdit::Deletion {
sequence: Some(Sequence::from_str("ATG").unwrap()),
length: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(result.valid);
}
#[test]
fn test_validate_deletion_mismatch() {
let edit = NaEdit::Deletion {
sequence: Some(Sequence::from_str("GGG").unwrap()),
length: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(!result.valid);
assert_eq!(result.stated_ref, Some("GGG".to_string()));
assert_eq!(result.actual_ref, Some("ATG".to_string()));
}
#[test]
fn test_validate_no_sequence() {
let edit = NaEdit::Deletion {
sequence: None,
length: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(result.valid);
}
#[test]
fn test_validate_delins_deleted_mismatch() {
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::from_str("AT").unwrap()),
deleted: Some(Sequence::from_str("GGG").unwrap()),
deleted_length: None,
substitution_reference: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(!result.valid);
assert_eq!(result.stated_ref, Some("GGG".to_string()));
assert_eq!(result.actual_ref, Some("ATG".to_string()));
}
#[test]
fn test_validate_delins_deleted_match() {
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::from_str("AT").unwrap()),
deleted: Some(Sequence::from_str("ATG").unwrap()),
deleted_length: None,
substitution_reference: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(result.valid);
}
#[test]
fn test_validate_delins_no_deleted() {
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::from_str("AT").unwrap()),
deleted: None,
deleted_length: None,
substitution_reference: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(result.valid);
}
#[test]
fn test_validate_delins_deleted_length_match() {
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::from_str("AT").unwrap()),
deleted: None,
deleted_length: Some(3),
substitution_reference: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 1, 3);
assert!(result.valid);
}
#[test]
fn test_validate_case_insensitive() {
let edit = NaEdit::Substitution {
reference: Base::A,
alternative: Base::G,
};
let ref_seq = b"atgc"; let result = validate_reference(&edit, ref_seq, 1, 1);
assert!(result.valid);
}
#[test]
fn test_apply_policy_strict() {
let result = ValidationResult::mismatch("G".to_string(), "A".to_string());
let config = NormalizeConfig::strict();
let err = apply_validation_policy(&result, &config, "c.1G>T");
assert!(err.is_err());
}
#[test]
fn test_apply_policy_lenient() {
let result = ValidationResult::mismatch("G".to_string(), "A".to_string());
let config = NormalizeConfig::lenient();
let ok = apply_validation_policy(&result, &config, "c.1G>T");
assert!(ok.is_ok()); }
#[test]
fn test_apply_policy_silent() {
let result = ValidationResult::mismatch("G".to_string(), "A".to_string());
let config = NormalizeConfig::silent();
let ok = apply_validation_policy(&result, &config, "c.1G>T");
assert!(ok.is_ok());
}
#[test]
fn test_validate_sequence_inverted_range() {
let edit = NaEdit::Deletion {
sequence: Some(Sequence::from_str("ATG").unwrap()),
length: None,
};
let ref_seq = b"ATGC";
let result = validate_reference(&edit, ref_seq, 3, 1);
assert!(!result.valid);
}
#[test]
fn test_validate_deletion_length_mismatch() {
let edit = NaEdit::Deletion {
sequence: None,
length: Some(4),
};
let result = validate_reference(&edit, b"ATGC", 45, 45);
assert!(!result.valid);
assert_eq!(result.stated_ref, Some("length 4".to_string()));
}
#[test]
fn test_validate_deletion_length_match() {
let edit = NaEdit::Deletion {
sequence: None,
length: Some(4),
};
let result = validate_reference(&edit, b"ATGC", 1, 4);
assert!(result.valid);
}
#[test]
fn test_validate_deletion_length_inverted_range_no_panic() {
let edit = NaEdit::Deletion {
sequence: None,
length: Some(4),
};
let result = validate_reference(&edit, b"ATGC", 3, 1);
assert!(result.valid);
}
#[test]
fn test_validate_duplication_length_mismatch() {
let edit = NaEdit::Duplication {
sequence: None,
length: Some(4),
uncertain_extent: None,
};
let result = validate_reference(&edit, b"ATGC", 45, 45);
assert!(!result.valid);
assert_eq!(result.stated_ref, Some("length 4".to_string()));
}
#[test]
fn test_validate_duplication_uncertain_extent_skipped() {
let edit = NaEdit::Duplication {
sequence: None,
length: Some(4),
uncertain_extent: Some(UncertainDupExtent::Unknown),
};
let result = validate_reference(&edit, b"ATGC", 45, 45);
assert!(result.valid);
}
#[test]
fn test_validate_delins_deleted_length_mismatch() {
let edit = NaEdit::Delins {
sequence: InsertedSequence::Literal(Sequence::from_str("ATC").unwrap()),
deleted: None,
deleted_length: Some(4),
substitution_reference: None,
};
let result = validate_reference(&edit, b"ATGC", 45, 45);
assert!(!result.valid);
assert_eq!(result.stated_ref, Some("length 4".to_string()));
}
}