genome-sh 0.1.0

The jq of genomics. Fast, local, human-readable variant analysis.
use anyhow::Result;
use colored::Colorize;

use crate::variant::AnnotatedVariant;

pub fn print(variant: &AnnotatedVariant) -> Result<()> {
    let rsid = variant.rsid.as_deref().unwrap_or(".");
    let gene = variant.gene.as_deref().unwrap_or("Unknown gene");
    let significance = variant
        .clinvar
        .as_ref()
        .map(|c| c.significance.as_str())
        .unwrap_or("No clinical data");

    let sig_colored = colorize_significance(significance);

    let width = 62;
    let border = "".repeat(width);

    println!("{border}");
    println!(
        "{rsid} · {gene} · {sig_colored}{:>pad$}│",
        "",
        pad = width.saturating_sub(rsid.len() + gene.len() + significance.len() + 8)
    );
    println!("{border}");

    // Location.
    println!(
        "│  {:<label_width$} {}:{} ({}){}",
        "Location".bold(),
        variant.chrom,
        variant.pos,
        variant.assembly,
        " ".repeat(
            width
                .saturating_sub(13 + variant.chrom.len() + format!("{}", variant.pos).len() + variant.assembly.len() + 4)
        ),
        label_width = 12,
    );

    // Change.
    println!(
        "│  {:<12} {} > {} {}",
        "Change".bold(),
        variant.reference,
        variant.alt,
        " ".repeat(width.saturating_sub(12 + variant.reference.len() + variant.alt.len() + 7)),
    );

    // Gene.
    println!(
        "│  {:<12} {}{}",
        "Gene".bold(),
        gene,
        " ".repeat(width.saturating_sub(14 + gene.len())),
    );

    // ClinVar section.
    if let Some(clinvar) = &variant.clinvar {
        println!("│{:>width$}│", "", width = width);
        println!(
            "{}{}",
            "Clinical".bold(),
            " ".repeat(width.saturating_sub(10)),
        );

        let stars = "".repeat(clinvar.review_stars as usize);
        let empty_stars = "".repeat(4 - clinvar.review_stars as usize);
        println!(
            "│  ├─ {:<10} {} ({}{} reviewed){}",
            "ClinVar",
            sig_colored,
            stars,
            empty_stars,
            " ".repeat(
                width
                    .saturating_sub(18 + significance.len() + 4 + 10)
            ),
        );

        if !clinvar.conditions.is_empty() {
            let condition = truncate(&clinvar.conditions, 40);
            println!(
                "│  ├─ {:<10} {}{}",
                "Condition",
                condition,
                " ".repeat(width.saturating_sub(17 + condition.len())),
            );
        }

        if let Some(reviewed) = &clinvar.last_reviewed {
            println!(
                "│  └─ {:<10} {}{}",
                "Reviewed",
                reviewed,
                " ".repeat(width.saturating_sub(17 + reviewed.len())),
            );
        }
    }

    // gnomAD section.
    if let Some(gnomad) = &variant.gnomad {
        println!("│{:>width$}│", "", width = width);
        println!(
            "{}{}",
            "Population Frequency".bold(),
            " ".repeat(width.saturating_sub(22)),
        );

        let af = format_af(gnomad.af_global);
        println!(
            "│  ├─ {:<14} {}{}",
            "Global",
            af,
            " ".repeat(width.saturating_sub(19 + af.len())),
        );

        let populations = [
            ("European", gnomad.af_nfe),
            ("African", gnomad.af_afr),
            ("East Asian", gnomad.af_eas),
            ("South Asian", gnomad.af_sas),
            ("Latino", gnomad.af_amr),
        ];

        for (i, (name, af_val)) in populations.iter().enumerate() {
            let af_str = format_af(*af_val);
            let prefix = if i < populations.len() - 1 {
                "├─"
            } else {
                "└─"
            };
            println!(
                "{prefix} {:<14} {}{}",
                name,
                af_str,
                " ".repeat(width.saturating_sub(21 + af_str.len())),
            );
        }
    }

    // PharmGKB section.
    if let Some(pharmgkb) = &variant.pharmgkb {
        println!("│{:>width$}│", "", width = width);
        println!(
            "{}{}",
            "Pharmacogenomics".bold(),
            " ".repeat(width.saturating_sub(18)),
        );
        println!(
            "│  └─ {} ({}){}",
            pharmgkb.drug,
            pharmgkb.evidence_level,
            " ".repeat(
                width.saturating_sub(7 + pharmgkb.drug.len() + pharmgkb.evidence_level.len() + 3)
            ),
        );
    }

    println!("{border}");
    println!();

    Ok(())
}

fn colorize_significance(significance: &str) -> String {
    if significance.contains("Pathogenic") && !significance.contains("Likely") {
        significance.red().bold().to_string()
    } else if significance.contains("Likely pathogenic") {
        significance.yellow().bold().to_string()
    } else if significance.contains("Benign") {
        significance.green().to_string()
    } else if significance.contains("Uncertain") || significance.contains("VUS") {
        significance.blue().to_string()
    } else {
        significance.dimmed().to_string()
    }
}

fn format_af(af: f64) -> String {
    if af == 0.0 {
        "Not observed".to_string()
    } else if af < 0.0001 {
        format!("{af:.6} (ultra-rare)")
    } else if af < 0.01 {
        let one_in = (1.0 / af).round() as u64;
        format!("{af:.5} (1 in {one_in})")
    } else {
        format!("{af:.4} ({:.1}%)", af * 100.0)
    }
}

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        s.to_string()
    } else {
        format!("{}...", &s[..max - 3])
    }
}