vareffect 0.3.0

Variant consequence prediction and HGVS notation, concordant with Ensembl VEP.
Documentation
//! Integration tests for [`vareffect::VarEffect::left_align_indel`]
//! against the real GRCh38 reference genome binary.
//!
//! These tests are `#[ignore]`-gated because they require the same ~3.1 GB
//! flat binary genome as the other integration tests. Run them with:
//!
//! ```bash
//! FASTA_PATH=data/vareffect/GRCh38.bin cargo test -p vareffect -- --ignored
//! ```
//!
//! The algorithm implements Tan et al. 2015 ("Unified representation of
//! genetic variants") — shift-then-trim to produce the unique left-aligned
//! parsimonious representation.

use std::path::{Path, PathBuf};

use vareffect::{FastaReader, TranscriptStore, VarEffect};

/// Helper: build a minimal `VarEffect` with an empty transcript store and a
/// real FASTA reader. `left_align_indel` only touches the FASTA, so the
/// transcript store can safely be empty.
fn open_var_effect() -> VarEffect {
    let path = std::env::var("FASTA_PATH").expect(
        "FASTA_PATH env var must point to a GRCh38 genome binary (.bin) \
         with its .bin.idx sidecar. Run `vareffect-cli setup` first, then \
         set FASTA_PATH=data/vareffect/GRCh38.bin.",
    );
    let path_buf = PathBuf::from(path);
    let fasta =
        FastaReader::open(Path::new(&path_buf)).expect("opening the reference genome binary");
    let transcripts = TranscriptStore::from_transcripts(Vec::new());
    VarEffect::new(transcripts, fasta)
}

// -----------------------------------------------------------------------
// SNV / MNV passthrough — the algorithm's loop condition is the guard
// -----------------------------------------------------------------------

/// SNVs: rightmost bases differ immediately, shift loop never fires.
#[test]
#[ignore]
fn snv_passthrough() {
    let ve = open_var_effect();
    // TP53 hotspot: chr17:7676154 C>T (GRCh38)
    let result = ve
        .left_align_indel("chr17", 7_676_154, "C", "T")
        .expect("left_align_indel should not error on SNV");
    assert_eq!(result, None, "SNV should pass through unchanged");
}

/// MNVs: rightmost bases differ, no shift.
#[test]
#[ignore]
fn mnv_passthrough() {
    let ve = open_var_effect();
    // Two adjacent substitutions that don't share rightmost base
    let result = ve
        .left_align_indel("chr17", 7_676_154, "CC", "TG")
        .expect("left_align_indel should not error on MNV");
    assert_eq!(result, None, "MNV should pass through unchanged");
}

// -----------------------------------------------------------------------
// Already-leftmost — no shift needed
// -----------------------------------------------------------------------

/// A deletion NOT in a repetitive region should return None.
#[test]
#[ignore]
fn already_leftmost_deletion() {
    let ve = open_var_effect();
    // CFTR deltaF508: chr7:117559590 ATCT>A (GRCh38) — not in a repeat
    // region. The base at chr7:117559593 is not the same as at 117559590,
    // so this should already be leftmost.
    let result = ve
        .left_align_indel("chr7", 117_559_590, "ATCT", "A")
        .expect("left_align_indel should not error");
    assert_eq!(result, None, "non-repeat deletion should be unchanged");
}

// -----------------------------------------------------------------------
// Homopolymer shifts — the core use case
// -----------------------------------------------------------------------

// Reference context (GRCh38): chr17 1-based 43057062 = T, then a GGG run at
// 43057063-43057065, then A at 43057066. Deleting/inserting one G anywhere in
// the run must left-align to the T anchor at 43057062 — independent of which
// right-shifted representation the caller supplies.

/// Right-shifted 1bp deletions in the chr17 GGG run must all left-align to the
/// same canonical deletion `43057062 TG>T`.
#[test]
#[ignore]
fn homopolymer_deletion_shifts_left() {
    let ve = open_var_effect();
    let canonical = Some((43_057_062, "TG".to_string(), "T".to_string()));
    // Delete the 2nd vs the 3rd G of the run — two representations, one variant.
    let from_inner = ve
        .left_align_indel("chr17", 43_057_063, "GG", "G")
        .expect("left_align_indel should not error");
    let from_outer = ve
        .left_align_indel("chr17", 43_057_064, "GG", "G")
        .expect("left_align_indel should not error");
    assert_eq!(
        from_inner, canonical,
        "inner deletion must left-align to TG>T"
    );
    assert_eq!(
        from_outer, canonical,
        "outer deletion must left-align to TG>T"
    );
}

/// Right-shifted 1bp insertions in the chr17 GGG run must all left-align to the
/// same canonical insertion `43057062 T>TG` (the BRCA1 c.5266dup case).
#[test]
#[ignore]
fn homopolymer_insertion_shifts_left() {
    let ve = open_var_effect();
    let canonical = Some((43_057_062, "T".to_string(), "TG".to_string()));
    // Insert a G after the 1st vs the 3rd G of the run — two representations.
    let from_inner = ve
        .left_align_indel("chr17", 43_057_063, "G", "GG")
        .expect("left_align_indel should not error");
    let from_outer = ve
        .left_align_indel("chr17", 43_057_065, "G", "GG")
        .expect("left_align_indel should not error");
    assert_eq!(
        from_inner, canonical,
        "inner insertion must left-align to T>TG"
    );
    assert_eq!(
        from_outer, canonical,
        "outer insertion must left-align to T>TG"
    );
}

// -----------------------------------------------------------------------
// Complex input that is really an indel post-trim
// -----------------------------------------------------------------------

/// `ref=ACG, alt=AG` — shared rightmost G triggers the shift loop,
/// exposing a 1bp deletion of C. The algorithm handles this without
/// a separate post-trim guard.
#[test]
#[ignore]
fn complex_becomes_deletion_after_shift() {
    let ve = open_var_effect();
    // Use a real genomic position. The exact shift depends on the
    // local repeat context, but the key property is that the result
    // must NOT be None (the complex input is an indel in disguise).
    //
    // chr17:7676154 — pick a region where we can construct a test.
    // We just verify the algorithm processes it without error and
    // recognizes it's not a simple substitution.
    let result = ve.left_align_indel("chr17", 7_676_154, "CCC", "CC");
    assert!(result.is_ok(), "should not error on complex-becomes-indel");
    // This IS an indel (1bp deletion), so it may or may not shift
    // depending on the repeat context. The important thing is it
    // doesn't return None thinking it's an MNV.
}

// -----------------------------------------------------------------------
// Idempotency — running twice gives the same result
// -----------------------------------------------------------------------

#[test]
#[ignore]
fn idempotent() {
    let ve = open_var_effect();
    // First pass: normalize a right-shifted deletion in the chr17 GGG run.
    let (pos, ref_a, alt_a) = ve
        .left_align_indel("chr17", 43_057_064, "GG", "G")
        .expect("first pass should not error")
        .expect("a right-shifted homopolymer deletion must left-align");
    // Second pass over the normalized form is a no-op.
    let second = ve
        .left_align_indel("chr17", pos, &ref_a, &alt_a)
        .expect("second pass should not error");
    assert_eq!(
        second, None,
        "already-normalized variant should return None on second pass"
    );
}

// -----------------------------------------------------------------------
// Boundary: position 1
// -----------------------------------------------------------------------

#[test]
#[ignore]
fn position_one_boundary() {
    let ve = open_var_effect();
    // At position 1 the loop guard `pos <= 1` prevents any shift.
    // This should succeed without error and return None or a trimmed
    // form (but never underflow).
    let result = ve.left_align_indel("chr1", 1, "NN", "N");
    assert!(
        result.is_ok(),
        "position 1 should not cause underflow: {:?}",
        result
    );
}

// -----------------------------------------------------------------------
// VCF anchor base preserved after normalization
// -----------------------------------------------------------------------

#[test]
#[ignore]
fn vcf_anchor_preserved() {
    let ve = open_var_effect();
    // After normalization, at least one allele must have length >= 1
    // (VCF anchor base requirement). The parsimony step preserves this.
    let result = ve
        .left_align_indel("chr13", 32_340_301, "GA", "G")
        .expect("should not error");
    if let Some((_, ref ref_a, ref alt_a)) = result {
        assert!(
            !ref_a.is_empty() && !alt_a.is_empty(),
            "both alleles must have at least one base (VCF anchor): ref={ref_a}, alt={alt_a}"
        );
        // For a pure deletion/insertion, the shorter allele should be
        // exactly 1 base (the anchor).
        let shorter = ref_a.len().min(alt_a.len());
        assert!(shorter >= 1, "shorter allele must be >= 1 base");
    }
}

// -----------------------------------------------------------------------
// Regression: duplication must stay an insertion, not collapse to a SNV
// -----------------------------------------------------------------------

/// BRCA1 c.5266dupC (the 5382insC founder frameshift): the right-shifted
/// genomic duplication `17:43057063 G>GG` must left-align to the insertion
/// `17:43057062 T>TG`. The pre-fix per-allele extension collapsed it to the
/// substitution `T>G`, which downstream annotated as a missense SNV at BRCA1
/// residue 1756 instead of a truncating frameshift — the bug this guards.
#[test]
#[ignore]
fn brca1_5266dup_left_aligns_to_insertion_not_snv() {
    let ve = open_var_effect();
    let result = ve
        .left_align_indel("chr17", 43_057_063, "G", "GG")
        .expect("left_align_indel should not error");
    assert_eq!(
        result,
        Some((43_057_062, "T".to_string(), "TG".to_string())),
        "duplication must remain an insertion (T>TG), not collapse to a SNV (T>G)"
    );
}