diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..341586da --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,39 @@ +#!/bin/sh +# Reject a commit that would (re)introduce rustfmt drift. +# +# `cargo fmt` clean is a per-commit gate for this repo, but nothing enforced it, and the drift that +# accumulated once reached 116 files — at which point every feature branch either carried a pile of +# unrelated reformatting into review or had to be de-noised by hand. This is the enforcement. +# +# It runs `--check` only: it never rewrites your working tree mid-commit, because a hook that edits +# files behind you produces a commit whose contents you did not read. It tells you what is wrong and +# lets you fix it. +# +# Deliberately local rather than CI: the point is to catch drift before it is committed, not to fail +# a build after the fact. +# +# Enable (once per clone): git config core.hooksPath .githooks +# Bypass for one commit: git commit --no-verify + +# No toolchain (docs-only checkout, CI image without rust) — do not block the commit. +command -v cargo >/dev/null 2>&1 || exit 0 + +# Nothing Rust-shaped staged → nothing this hook has an opinion about. Also covers the merge/revert +# case, where the tree is whatever the other side committed and blocking is unhelpful. +staged_rs=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs') +[ -n "$staged_rs" ] || exit 0 + +out=$(cargo fmt --all --check 2>&1) +[ $? -eq 0 ] && exit 0 + +# `--check` reports "Diff in ::" per hunk; collapse to the file list. +files=$(printf '%s\n' "$out" | sed -n 's|^Diff in \(.*\):[0-9]*:$|\1|p' | sort -u) + +echo "rustfmt: the working tree is not formatted." >&2 +echo >&2 +printf '%s\n' "$files" | sed 's|^| |' >&2 +echo >&2 +echo " Fix: cargo fmt --all" >&2 +echo " Review: cargo fmt --all --check" >&2 +echo " Skip: git commit --no-verify (leaves the drift for someone else)" >&2 +exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index f688f8b2..bb17447f 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,10 +20,25 @@ cargo test --workspace # Lint gate (must be clean per commit) cargo clippy --all-targets -- -D warnings +# Format gate (must be clean per commit; enforced by the pre-commit hook below) +cargo fmt --all + # Run a single test cargo test -p navigator-analysis some_test_name ``` +**Enable the pre-commit hook once per clone** — it runs `cargo fmt --all --check` (~0.6s, no +compilation) and rejects a commit that would reintroduce formatting drift: + +```bash +git config core.hooksPath .githooks +``` + +Hooks are per-clone git config, so this is not automatic on checkout. Without it the gate is +advisory: the drift once reached 116 files, at which point every feature branch either carried a +pile of unrelated reformatting into review or had to be de-noised by hand. `git commit --no-verify` +bypasses it when you genuinely need to. + The built binary is named `navigator` (`target/debug/navigator` or `target/release/navigator`). Run with no subcommand to launch the GUI; run with `ingest` / `subjects` / `show` / `projects` for headless mode. ## Architecture Overview diff --git a/crates/navigator-analysis/examples/archaic_callable_dump.rs b/crates/navigator-analysis/examples/archaic_callable_dump.rs index a2fbbc63..7b50bdcb 100644 --- a/crates/navigator-analysis/examples/archaic_callable_dump.rs +++ b/crates/navigator-analysis/examples/archaic_callable_dump.rs @@ -13,7 +13,9 @@ use navigator_analysis::archaic::ArchaicCallable; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_callable_dump [min_frac] [contig ...]"); + let path = a + .next() + .expect("usage: archaic_callable_dump [min_frac] [contig ...]"); let min_frac: f64 = a.next().and_then(|s| s.parse().ok()).unwrap_or(0.0); let want: Vec = a.collect(); diff --git a/crates/navigator-analysis/examples/archaic_classify_dump.rs b/crates/navigator-analysis/examples/archaic_classify_dump.rs index 2150622d..3307c2af 100644 --- a/crates/navigator-analysis/examples/archaic_classify_dump.rs +++ b/crates/navigator-analysis/examples/archaic_classify_dump.rs @@ -15,7 +15,9 @@ use navigator_analysis::archaic::ArchaicClassify; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_classify_dump [contig ...]"); + let path = a + .next() + .expect("usage: archaic_classify_dump [contig ...]"); let want: Vec = a.collect(); let cls = ArchaicClassify::from_bytes(&std::fs::read(&path)?).map_err(|e| e.to_string())?; diff --git a/crates/navigator-analysis/examples/archaic_match_probe.rs b/crates/navigator-analysis/examples/archaic_match_probe.rs index 45c12138..d8552eab 100644 --- a/crates/navigator-analysis/examples/archaic_match_probe.rs +++ b/crates/navigator-analysis/examples/archaic_match_probe.rs @@ -53,7 +53,11 @@ fn main() -> Result<(), Box> { contig, &classify, pos_map, - |p| seq.get((p - 1).max(0) as usize).copied().map(|b| b.to_ascii_uppercase()), + |p| { + seq.get((p - 1).max(0) as usize) + .copied() + .map(|b| b.to_ascii_uppercase()) + }, &callable, 0.5, ); @@ -61,7 +65,11 @@ fn main() -> Result<(), Box> { eprintln!( "{contig}: {} informative diagnostic sites, {carried} carried ({:.1}%)", obs.len(), - if obs.is_empty() { 0.0 } else { carried as f64 * 100.0 / obs.len() as f64 } + if obs.is_empty() { + 0.0 + } else { + carried as f64 * 100.0 / obs.len() as f64 + } ); lengths.push((contig.clone(), seq.len() as i32)); observations.insert(contig.clone(), obs); diff --git a/crates/navigator-analysis/examples/archaic_outgroup_density.rs b/crates/navigator-analysis/examples/archaic_outgroup_density.rs index 78e356e9..08a26637 100644 --- a/crates/navigator-analysis/examples/archaic_outgroup_density.rs +++ b/crates/navigator-analysis/examples/archaic_outgroup_density.rs @@ -20,7 +20,9 @@ use navigator_analysis::archaic::ArchaicOutgroup; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_outgroup_density [window_bp] [contig ...]"); + let path = a + .next() + .expect("usage: archaic_outgroup_density [window_bp] [contig ...]"); let window: i64 = a.next().and_then(|s| s.parse().ok()).unwrap_or(1000); let want: Vec = a.collect(); diff --git a/crates/navigator-analysis/examples/archaic_panel_dump.rs b/crates/navigator-analysis/examples/archaic_panel_dump.rs index c9ea5d47..52805f8c 100644 --- a/crates/navigator-analysis/examples/archaic_panel_dump.rs +++ b/crates/navigator-analysis/examples/archaic_panel_dump.rs @@ -21,7 +21,9 @@ use navigator_analysis::archaic::{ArchaicMarkerPanel, ARCHAIC_GENOMES}; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let path = a.next().expect("usage: archaic_panel_dump [contig ...]"); + let path = a + .next() + .expect("usage: archaic_panel_dump [contig ...]"); let want: Vec = a.collect(); let panel = ArchaicMarkerPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| e.to_string())?; diff --git a/crates/navigator-analysis/examples/archaic_private_dump.rs b/crates/navigator-analysis/examples/archaic_private_dump.rs index 86c573be..7454bd4e 100644 --- a/crates/navigator-analysis/examples/archaic_private_dump.rs +++ b/crates/navigator-analysis/examples/archaic_private_dump.rs @@ -17,8 +17,12 @@ use navigator_analysis::caller::SiteGenotype; fn main() -> Result<(), Box> { let mut a = std::env::args().skip(1); - let calls_path = a.next().expect("usage: archaic_private_dump "); - let og_path = a.next().expect("usage: archaic_private_dump "); + let calls_path = a + .next() + .expect("usage: archaic_private_dump "); + let og_path = a + .next() + .expect("usage: archaic_private_dump "); let calls: Vec = serde_json::from_str(&std::fs::read_to_string(&calls_path)?)?; let og = ArchaicOutgroup::from_bytes(&std::fs::read(&og_path)?).map_err(|e| e.to_string())?; diff --git a/crates/navigator-analysis/examples/archaic_segments_probe.rs b/crates/navigator-analysis/examples/archaic_segments_probe.rs index 2d8052e3..5fc3b67f 100644 --- a/crates/navigator-analysis/examples/archaic_segments_probe.rs +++ b/crates/navigator-analysis/examples/archaic_segments_probe.rs @@ -15,15 +15,37 @@ fn main() -> Result<(), Box> { let cls = ArchaicClassify::from_bytes(&std::fs::read(a.next().unwrap())?).map_err(|e| e.to_string())?; let cal = ArchaicCallable::from_bytes(&std::fs::read(a.next().unwrap())?).map_err(|e| e.to_string())?; println!("calls {} callable track {:.1} Mb", calls.len(), cal.callable_mb()); - let r = call_archaic_segments(&calls, &og, &cls, &cal, &GeneticMap::from_markers(Vec::new()), &ArchaicConfig::default()); + let r = call_archaic_segments( + &calls, + &og, + &cls, + &cal, + &GeneticMap::from_markers(Vec::new()), + &ArchaicConfig::default(), + ); let s = &r.summary; - println!("segments {} total {:.2} Mb = {:.2}% of {:.1} Mb callable", s.n_segments, s.total_mb, s.pct_callable, s.callable_mb); - println!(" Neanderthal {:.2} Mb Denisovan {:.2} Mb Unknown {:.2} Mb", s.neanderthal_mb, s.denisovan_mb, s.unknown_mb); + println!( + "segments {} total {:.2} Mb = {:.2}% of {:.1} Mb callable", + s.n_segments, s.total_mb, s.pct_callable, s.callable_mb + ); + println!( + " Neanderthal {:.2} Mb Denisovan {:.2} Mb Unknown {:.2} Mb", + s.neanderthal_mb, s.denisovan_mb, s.unknown_mb + ); for seg in r.segments.iter().take(6) { - println!(" {} {}-{} ({:.2} Mb) post {:.2} private {} ({:.0}/Mb) {:?} nea{} den{}", - seg.contig, seg.start, seg.end, seg.length_mb(), seg.posterior, seg.n_private, - seg.n_private as f64 / seg.length_mb().max(1e-9), seg.source, - seg.neanderthal_matches, seg.denisovan_matches); + println!( + " {} {}-{} ({:.2} Mb) post {:.2} private {} ({:.0}/Mb) {:?} nea{} den{}", + seg.contig, + seg.start, + seg.end, + seg.length_mb(), + seg.posterior, + seg.n_private, + seg.n_private as f64 / seg.length_mb().max(1e-9), + seg.source, + seg.neanderthal_matches, + seg.denisovan_matches + ); } Ok(()) } diff --git a/crates/navigator-analysis/examples/cram_query_probe.rs b/crates/navigator-analysis/examples/cram_query_probe.rs index 82ff1818..b6ef6a35 100644 --- a/crates/navigator-analysis/examples/cram_query_probe.rs +++ b/crates/navigator-analysis/examples/cram_query_probe.rs @@ -34,15 +34,24 @@ fn main() { let t = Instant::now(); let n: usize = reader.query(&header, &one(pos)).expect("q1").count(); - println!("first 1bp query ({n:>4} rec) : {:>8.2?} <- includes any lazy setup", t.elapsed()); + println!( + "first 1bp query ({n:>4} rec) : {:>8.2?} <- includes any lazy setup", + t.elapsed() + ); let t = Instant::now(); let n: usize = reader.query(&header, &one(pos)).expect("q2").count(); - println!("same query again ({n:>4} rec): {:>8.2?} <- warm: is the cost per-query or one-off?", t.elapsed()); + println!( + "same query again ({n:>4} rec): {:>8.2?} <- warm: is the cost per-query or one-off?", + t.elapsed() + ); let t = Instant::now(); let n: usize = reader.query(&header, &one(pos + 5_000_000)).expect("q3").count(); - println!("distant 1bp query ({n:>4} rec): {:>8.2?} <- new container: does it re-decode?", t.elapsed()); + println!( + "distant 1bp query ({n:>4} rec): {:>8.2?} <- new container: does it re-decode?", + t.elapsed() + ); let region: Region = format!("{contig}:{pos}-{}", pos + span).parse().expect("region"); let t = Instant::now(); @@ -70,7 +79,11 @@ fn main() { r.sequence().as_ref().to_vec(), ) }; - let mine: Vec<_> = reader.query(&header, ®ion).expect("mine").map(|r| key(&r.expect("rec"))).collect(); + let mine: Vec<_> = reader + .query(&header, ®ion) + .expect("mine") + .map(|r| key(&r.expect("rec"))) + .collect(); let repo = navigator_analysis::reader::build_repository(refp).expect("repo"); let mut oracle = cram::io::indexed_reader::Builder::default() @@ -84,7 +97,10 @@ fn main() { .expect("noodles query") .map(|r| key(&r.expect("rec"))) .collect(); - println!("\nVERIFY: noodles' own Query took {:?} for the same region", t.elapsed()); + println!( + "\nVERIFY: noodles' own Query took {:?} for the same region", + t.elapsed() + ); println!(" ours {} records, noodles {} records", mine.len(), theirs.len()); assert_eq!(mine, theirs, "container skipping changed the records returned"); println!(" IDENTICAL — container skipping is lossless"); diff --git a/crates/navigator-analysis/examples/denovo_profile.rs b/crates/navigator-analysis/examples/denovo_profile.rs index 72ce49ff..7a4931c4 100644 --- a/crates/navigator-analysis/examples/denovo_profile.rs +++ b/crates/navigator-analysis/examples/denovo_profile.rs @@ -27,7 +27,8 @@ fn main() { &contig, ¶ms, &navigator_analysis::CancelToken::none(), - ).expect("call_denovo"); + ) + .expect("call_denovo"); eprintln!( "call_denovo({contig}): {} variants in {:.1}s (realign={})", calls.len(), diff --git a/crates/navigator-analysis/examples/profile_analysis.rs b/crates/navigator-analysis/examples/profile_analysis.rs index 998fa5b4..2d13fdf8 100644 --- a/crates/navigator-analysis/examples/profile_analysis.rs +++ b/crates/navigator-analysis/examples/profile_analysis.rs @@ -36,16 +36,24 @@ fn main() { coverage::estimate_molecule_lengths(bam, Some(reference)).ok() }); timed("coverage SEQUENTIAL whole-genome", || { - coverage::collect_coverage_callable(bam, reference, ¶ms, None).map(|_| ()).err() + coverage::collect_coverage_callable(bam, reference, ¶ms, None) + .map(|_| ()) + .err() }); timed("coverage SEQUENTIAL scoped chrY+chrM", || { - coverage::collect_coverage_callable(bam, reference, ¶ms, Some(&ym)).map(|_| ()).err() + coverage::collect_coverage_callable(bam, reference, ¶ms, Some(&ym)) + .map(|_| ()) + .err() }); timed("coverage PARALLEL whole-genome", || { - unified::collect_unified_metrics_parallel(bam, reference, ¶ms, None).map(|_| ()).err() + unified::collect_unified_metrics_parallel(bam, reference, ¶ms, None) + .map(|_| ()) + .err() }); timed("coverage PARALLEL scoped chrY+chrM", || { - unified::collect_unified_metrics_parallel(bam, reference, ¶ms, Some(&ym)).map(|_| ()).err() + unified::collect_unified_metrics_parallel(bam, reference, ¶ms, Some(&ym)) + .map(|_| ()) + .err() }); // chrY haplogroup genotyping pass: a region query over chrY tallying ~200k target sites @@ -53,6 +61,8 @@ fn main() { let hp = HaploidCallerParams::default(); let targets: HashSet = (1..=200_000u32).map(|i| i as i64 * 300).collect(); timed("chrY genotyping call_bases_at (200k sites)", || { - caller::call_bases_at(bam, "chrY", &targets, &hp, Some(reference)).map(|_| ()).err() + caller::call_bases_at(bam, "chrY", &targets, &hp, Some(reference)) + .map(|_| ()) + .err() }); } diff --git a/crates/navigator-analysis/examples/reassembly_probe.rs b/crates/navigator-analysis/examples/reassembly_probe.rs index 659212aa..643a3119 100644 --- a/crates/navigator-analysis/examples/reassembly_probe.rs +++ b/crates/navigator-analysis/examples/reassembly_probe.rs @@ -26,9 +26,7 @@ use std::path::Path; use bio::alignment::pairwise::{Aligner as PwAligner, Scoring}; use bio::alignment::poa::Aligner as PoaAligner; use bio::alignment::AlignmentOperation; -use bio::stats::pairhmm::{ - EmissionParameters, GapParameters, PairHMM, StartEndGapParameters, XYEmission, -}; +use bio::stats::pairhmm::{EmissionParameters, GapParameters, PairHMM, StartEndGapParameters, XYEmission}; use bio::stats::{LogProb, Prob}; use navigator_analysis::reader::{open_indexed, read_contig_sequence}; use noodles::core::Region; @@ -124,7 +122,12 @@ fn window_reads(cram: &Path, refp: &Path, contig: &str, pos: i64, lo: i64, hi: i } // Keep reads that carry enough window sequence to anchor a realignment. if win.len() >= 30 { - reads.push(WinRead { bases: win, quals: winq, mapq, covers_pos }); + reads.push(WinRead { + bases: win, + quals: winq, + mapq, + covers_pos, + }); } } (reads, pile) @@ -149,11 +152,23 @@ fn consensus_base_at(consensus: &[u8], win_ref: &[u8], win_start: i64, pos: i64) if std::env::var("PROBE_DEBUG").is_ok() { eprintln!( "DEBUG consensus.len={} win_ref.len={} xstart={} ystart={} xend={} yend={} score={}", - consensus.len(), win_ref.len(), aln.xstart, aln.ystart, aln.xend, aln.yend, aln.score + consensus.len(), + win_ref.len(), + aln.xstart, + aln.ystart, + aln.xend, + aln.yend, + aln.score ); eprintln!(" consensus raw[..20]: {:?}", &consensus[..consensus.len().min(20)]); - eprintln!(" consensus str[..40]: {}", String::from_utf8_lossy(&consensus[..consensus.len().min(40)])); - eprintln!(" win_ref str[..40]: {}", String::from_utf8_lossy(&win_ref[..win_ref.len().min(40)])); + eprintln!( + " consensus str[..40]: {}", + String::from_utf8_lossy(&consensus[..consensus.len().min(40)]) + ); + eprintln!( + " win_ref str[..40]: {}", + String::from_utf8_lossy(&win_ref[..win_ref.len().min(40)]) + ); } let mut xi = aln.xstart; // consensus index let mut yi = aln.ystart; // win_ref index (ref coord = win_start + yi) @@ -288,12 +303,13 @@ fn main() { let total: u32 = pile.iter().sum(); // Candidate alt = the most common non-reference base at pos. let ref_i = base_index(ref_base as u8).unwrap_or(0); - let alt_i = (0..4) - .filter(|&i| i != ref_i) - .max_by_key(|&i| pile[i]) - .unwrap_or(ref_i); + let alt_i = (0..4).filter(|&i| i != ref_i).max_by_key(|&i| pile[i]).unwrap_or(ref_i); let alt_base = charb(alt_i); - let alt_frac = if total > 0 { (total - pile[ref_i]) as f64 / total as f64 } else { 0.0 }; + let alt_frac = if total > 0 { + (total - pile[ref_i]) as f64 / total as f64 + } else { + 0.0 + }; // Reference vs alternate haplotype over the window (alt = ref with the SNV at pos). let win_ref: Vec = refseq[(lo - 1) as usize..(hi as usize).min(refseq.len())].to_vec(); diff --git a/crates/navigator-analysis/examples/reassembly_validate.rs b/crates/navigator-analysis/examples/reassembly_validate.rs index 7161a672..59d63cb8 100644 --- a/crates/navigator-analysis/examples/reassembly_validate.rs +++ b/crates/navigator-analysis/examples/reassembly_validate.rs @@ -26,7 +26,10 @@ fn main() { let hi = (pos + 5) as usize; let called = |reassembly: bool| -> Option<(char, char, u32, u32, Option)> { - let params = HaploidCallerParams { reassembly, ..HaploidCallerParams::default() }; + let params = HaploidCallerParams { + reassembly, + ..HaploidCallerParams::default() + }; let calls = call_denovo_region(bam, refp, contig, lo, hi, ¶ms).expect("call_denovo_region"); calls .into_iter() diff --git a/crates/navigator-analysis/examples/site_reads.rs b/crates/navigator-analysis/examples/site_reads.rs index 468c8461..e324257b 100644 --- a/crates/navigator-analysis/examples/site_reads.rs +++ b/crates/navigator-analysis/examples/site_reads.rs @@ -30,7 +30,10 @@ fn main() { let region: Region = format!("{contig}:{lo}-{hi}").parse().expect("region"); println!("site chrY:{pos} ref={ref_base} window ±{win}"); - println!("{:<32} {:>4} {:>4} {:>5} {:>4} {:>3} {:>8}", "qname", "pair", "mapq", "site", "bq", "nm", "flags"); + println!( + "{:<32} {:>4} {:>4} {:>5} {:>4} {:>3} {:>8}", + "qname", "pair", "mapq", "site", "bq", "nm", "flags" + ); let mut base_tally: HashMap = HashMap::new(); for result in reader.query(&header, ®ion).expect("query") { let rec = result.expect("rec"); @@ -38,11 +41,16 @@ fn main() { if f.is_secondary() || f.is_supplementary() || f.is_duplicate() || f.is_unmapped() { continue; } - let Some(start) = rec.alignment_start().map(|p| p.get() as i64) else { continue }; + let Some(start) = rec.alignment_start().map(|p| p.get() as i64) else { + continue; + }; let seq = rec.sequence(); let quals = rec.quality_scores(); let qb = quals.as_ref(); - let name = rec.name().map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default(); + let name = rec + .name() + .map(|n| String::from_utf8_lossy(n).into_owned()) + .unwrap_or_default(); let mapq = rec.mapping_quality().map_or(255, |m| m.get()); // Walk the CIGAR: capture the base at `pos` and count mismatches vs ref (excluding `pos`). @@ -84,9 +92,18 @@ fn main() { continue; // doesn't span the site } *base_tally.entry(site_base).or_default() += 1; - let pair = if f.is_first_segment() { "R1" } else if f.is_last_segment() { "R2" } else { "?" }; + let pair = if f.is_first_segment() { + "R1" + } else if f.is_last_segment() { + "R2" + } else { + "?" + }; let mm: Vec = mm_pos.iter().map(|p| (p - pos).to_string()).collect(); - println!("{name:<38} {pair:>4} {mapq:>4} {site_base:>5} {site_bq:>4} {nm:>3} mm@[{}]", mm.join(",")); + println!( + "{name:<38} {pair:>4} {mapq:>4} {site_base:>5} {site_bq:>4} {nm:>3} mm@[{}]", + mm.join(",") + ); } let mut keys: Vec<_> = base_tally.keys().copied().collect(); keys.sort(); diff --git a/crates/navigator-analysis/examples/validate_coverage.rs b/crates/navigator-analysis/examples/validate_coverage.rs index b8c94366..de7f08d1 100644 --- a/crates/navigator-analysis/examples/validate_coverage.rs +++ b/crates/navigator-analysis/examples/validate_coverage.rs @@ -32,21 +32,20 @@ fn main() { last = done; } }; - let standalone = - match coverage::collect_coverage_callable_with_progress( - bam, - reference, - ¶ms, - None, - &mut progress, - &navigator_analysis::CancelToken::none(), - ) { - Ok(r) => r, - Err(e) => { - eprintln!("standalone coverage error: {e}"); - std::process::exit(1); - } - }; + let standalone = match coverage::collect_coverage_callable_with_progress( + bam, + reference, + ¶ms, + None, + &mut progress, + &navigator_analysis::CancelToken::none(), + ) { + Ok(r) => r, + Err(e) => { + eprintln!("standalone coverage error: {e}"); + std::process::exit(1); + } + }; let standalone_dur = t0.elapsed(); eprintln!("\nstandalone coverage done in {standalone_dur:.1?}"); summarize("standalone", &standalone); @@ -54,21 +53,20 @@ fn main() { // 2. The oracle: trusted per-contig parallel walker on the same file. let t1 = Instant::now(); let progress2 = |_done: usize, _total: usize| {}; - let unified = - match unified::collect_unified_metrics_parallel_with_progress( - bam, - reference, - ¶ms, - None, - &progress2, - &navigator_analysis::CancelToken::none(), - ) { - Ok(r) => r, - Err(e) => { - eprintln!("parallel walker error: {e}"); - std::process::exit(1); - } - }; + let unified = match unified::collect_unified_metrics_parallel_with_progress( + bam, + reference, + ¶ms, + None, + &progress2, + &navigator_analysis::CancelToken::none(), + ) { + Ok(r) => r, + Err(e) => { + eprintln!("parallel walker error: {e}"); + std::process::exit(1); + } + }; let parallel_dur = t1.elapsed(); eprintln!("\nparallel walker done in {parallel_dur:.1?}"); summarize("parallel ", &unified.coverage); diff --git a/crates/navigator-analysis/src/ancestry.rs b/crates/navigator-analysis/src/ancestry.rs index fbd80a94..3f3e33c7 100644 --- a/crates/navigator-analysis/src/ancestry.rs +++ b/crates/navigator-analysis/src/ancestry.rs @@ -183,7 +183,13 @@ impl HaplotypeReference { /// Pack per-haplotype allele rows (`rows[h][s]` = 0/1) into the bit-packed form. `hap_pop[h]` /// is the population index of haplotype `h`. Used by the offline builder and by tests. - pub fn from_rows(build: String, sites: Vec, populations: Vec, hap_pop: Vec, rows: &[Vec]) -> Self { + pub fn from_rows( + build: String, + sites: Vec, + populations: Vec, + hap_pop: Vec, + rows: &[Vec], + ) -> Self { let n_sites = sites.len(); let n_haplotypes = rows.len(); let total_bits = n_sites * n_haplotypes; @@ -658,7 +664,12 @@ pub fn paint_local_ancestry_phased( .sites .iter() .filter(|s| s.freqs.len() == panel.populations.len()) - .map(|s| ((s.contig.as_str(), s.position), per_state_af(&s.freqs, &pop_state, &states))) + .map(|s| { + ( + (s.contig.as_str(), s.position), + per_state_af(&s.freqs, &pop_state, &states), + ) + }) .collect(); let contigs: std::collections::BTreeSet<&str> = phased.sites.iter().map(|s| s.contig.as_str()).collect(); @@ -684,7 +695,14 @@ pub fn paint_local_ancestry_phased( // collapse_copy needs (pos, _, dosage-ish); the AF/allele payload is unused there. let collapse_sites: Vec<(i64, Vec, i32)> = sites.iter().map(|s| (s.0, Vec::new(), s.2 as i32)).collect(); - segments.extend(collapse_copy(contig, &collapse_sites, &path, &states, params.min_segment_sites, side)); + segments.extend(collapse_copy( + contig, + &collapse_sites, + &path, + &states, + params.min_segment_sites, + side, + )); } } segments @@ -1365,7 +1383,13 @@ pub struct F4Estimate { impl F4Estimate { /// Standard error of statistic `i` from the jackknife covariance diagonal. pub fn se(&self, i: usize) -> f64 { - self.cov.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).max(0.0).sqrt() + self.cov + .get(i) + .and_then(|r| r.get(i)) + .copied() + .unwrap_or(0.0) + .max(0.0) + .sqrt() } } @@ -1393,7 +1417,10 @@ pub fn f4_vector( } // Reject out-of-range population indices up front — a mis-built quartet must not panic mid-scan. let ref_ok = |p: Pop| matches!(p, Pop::Target) || matches!(p, Pop::Ref(i) if i < k); - if !quartets.iter().all(|q| ref_ok(q.a) && ref_ok(q.b) && ref_ok(q.c) && ref_ok(q.d)) { + if !quartets + .iter() + .all(|q| ref_ok(q.a) && ref_ok(q.b) && ref_ok(q.c) && ref_ok(q.d)) + { return None; } @@ -1889,14 +1916,10 @@ mod tests { alternate_allele: 'G', }) .collect(); - let rows: Vec> = (0..3).map(|h| (0..9).map(|s| ((s + h) % 2 == 0) as u8).collect()).collect(); - let full = HaplotypeReference::from_rows( - "t".to_string(), - sites, - vec!["GBR".to_string()], - vec![0, 0, 0], - &rows, - ); + let rows: Vec> = (0..3) + .map(|h| (0..9).map(|s| ((s + h) % 2 == 0) as u8).collect()) + .collect(); + let full = HaplotypeReference::from_rows("t".to_string(), sites, vec!["GBR".to_string()], vec![0, 0, 0], &rows); let thin = full.thin_sites(3); assert_eq!(thin.n_sites, 3); assert_eq!(thin.n_haplotypes, 3); @@ -2215,7 +2238,13 @@ mod tests { let panel = two_pop_panel(n); // Hom-alt (→ A) everywhere except a 15-site hom-ref run (→ B) in the middle. let genos: Vec = (0..n) - .map(|i| sg("chr1", 1 + i as i64 * 1_000_000, if (40..55).contains(&i) { 0 } else { 2 })) + .map(|i| { + sg( + "chr1", + 1 + i as i64 * 1_000_000, + if (40..55).contains(&i) { 0 } else { 2 }, + ) + }) .collect(); let prior = vec![("A".to_string(), 0.99), ("B".to_string(), 0.01)]; @@ -2224,7 +2253,10 @@ mod tests { &genos, &panel, &prior, - &PaintParams { min_ancestry: 0.0, ..PaintParams::default() }, + &PaintParams { + min_ancestry: 0.0, + ..PaintParams::default() + }, ); assert!( ungated.iter().any(|s| s.population_code == "B"), @@ -2355,7 +2387,10 @@ mod tests { struct Lcg(u64); impl Lcg { fn next_f64(&mut self) -> f64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); (self.0 >> 11) as f64 / (1u64 << 53) as f64 } /// A diploid dosage drawn under HWE at alt-frequency `f`. @@ -2575,9 +2610,15 @@ mod tests { F4_BLOCK_BP, ) .expect("f4 vector"); - assert!(est.values[1].abs() > 0.02, "denominator f4 must be firmly non-degenerate"); + assert!( + est.values[1].abs() > 0.02, + "denominator f4 must be firmly non-degenerate" + ); let recovered = 1.0 - est.values[0] / est.values[1]; - assert!((recovered - alpha).abs() < 1e-4, "f4-ratio recovered α={recovered:.6}, want {alpha}"); + assert!( + (recovered - alpha).abs() < 1e-4, + "f4-ratio recovered α={recovered:.6}, want {alpha}" + ); } /// f4's exact symmetries (pure f64 arithmetic over one fixed site set): swapping either pair @@ -2623,7 +2664,12 @@ mod tests { let cab = 0.5 + 0.3 * (rng.next_f64() - 0.5); // drift shared by A,B let ccd = 0.5 + 0.3 * (rng.next_f64() - 0.5); // drift shared by C,D let tip = |rng: &mut Lcg, c: f64| (c + 0.15 * (rng.next_f64() - 0.5)) as f32; - vec![tip(&mut rng, cab), tip(&mut rng, cab), tip(&mut rng, ccd), tip(&mut rng, ccd)] + vec![ + tip(&mut rng, cab), + tip(&mut rng, cab), + tip(&mut rng, ccd), + tip(&mut rng, ccd), + ] }) .collect(); let (panel, genos) = f4_panel(&["A", "B", "C", "D"], &freqs); @@ -2637,8 +2683,14 @@ mod tests { .expect("f4 vector"); let z_null = est.values[0] / est.se(0); let z_edge = est.values[1] / est.se(1); - assert!(z_null.abs() < 4.0, "symmetric tree: f4(A,B;C,D) must sit near 0, z={z_null:.2}"); - assert!(z_edge.abs() > 8.0, "real internal edge: f4(A,C;B,D) must be many SE from 0, z={z_edge:.2}"); + assert!( + z_null.abs() < 4.0, + "symmetric tree: f4(A,B;C,D) must sit near 0, z={z_null:.2}" + ); + assert!( + z_edge.abs() > 8.0, + "real internal edge: f4(A,C;B,D) must be many SE from 0, z={z_edge:.2}" + ); assert!( est.values[0].abs() * 5.0 < est.values[1].abs(), "the null statistic must be far smaller than the real edge" @@ -2721,10 +2773,22 @@ mod tests { let fit = qpadm_fit(&genos, &panel, &[0, 1, 2], &outgroups, F4_BLOCK_BP).expect("3-source fit"); assert_eq!(fit.dof, 3, "dof = #outgroups − #sources = 6 − 3"); for (i, &want) in truth.iter().enumerate() { - assert!((fit.weights[i] - want).abs() < 0.08, "w{i} = {:.3}, want {want}", fit.weights[i]); + assert!( + (fit.weights[i] - want).abs() < 0.08, + "w{i} = {:.3}, want {want}", + fit.weights[i] + ); } - assert!(fit.weights_feasible(0.02), "weights must be valid proportions: {:?}", fit.weights); - assert!(fit.p_value > 0.01, "well-specified model must not be rejected, p = {:.4}", fit.p_value); + assert!( + fit.weights_feasible(0.02), + "weights must be valid proportions: {:?}", + fit.weights + ); + assert!( + fit.p_value > 0.01, + "well-specified model must not be rejected, p = {:.4}", + fit.p_value + ); // Drop a needed source (S3): the 2-source model can't express the target's cladeC affinity, // so its f4 residual with the cladeC outgroup is large → rejected. @@ -2751,9 +2815,16 @@ mod tests { assert_eq!(r.panel_type, "ancient"); // Recovered within the underlying qpAdm test's tolerance (~8 pts), and correctly ordered. for (code, want) in [("S1", 50.0), ("S2", 30.0), ("S3", 20.0)] { - assert!((pct(&r, code) - want).abs() < 9.0, "{code}: {:.1} vs {want}", pct(&r, code)); + assert!( + (pct(&r, code) - want).abs() < 9.0, + "{code}: {:.1} vs {want}", + pct(&r, code) + ); } - assert!(pct(&r, "S1") > pct(&r, "S2") && pct(&r, "S2") > pct(&r, "S3"), "order preserved"); + assert!( + pct(&r, "S1") > pct(&r, "S2") && pct(&r, "S2") > pct(&r, "S3"), + "order preserved" + ); let p = r.fit_distance.expect("p-value on fit_distance"); assert!((0.0..=1.0).contains(&p), "p={p}"); diff --git a/crates/navigator-analysis/src/archaic.rs b/crates/navigator-analysis/src/archaic.rs index e7153bd5..bb3b1df6 100644 --- a/crates/navigator-analysis/src/archaic.rs +++ b/crates/navigator-analysis/src/archaic.rs @@ -306,10 +306,8 @@ impl ArchaicMarkerResult { /// *base*. Reading dosage directly as "archaic copies" would invert every site where CHM13 /// orientation left the derived allele on REF — 3 % of the panel. pub fn count_archaic_markers(genotypes: &[SiteGenotype], panel: &ArchaicMarkerPanel) -> ArchaicMarkerResult { - let by_pos: std::collections::HashMap<(&str, i64), &SiteGenotype> = genotypes - .iter() - .map(|g| ((g.contig.as_str(), g.position), g)) - .collect(); + let by_pos: std::collections::HashMap<(&str, i64), &SiteGenotype> = + genotypes.iter().map(|g| ((g.contig.as_str(), g.position), g)).collect(); let (mut total, mut nea, mut den, mut shared) = (0u32, 0u32, 0u32, 0u32); let mut called = 0usize; @@ -652,7 +650,13 @@ mod tests { } } - fn site(position: i64, reference_allele: char, alternate_allele: char, derived: char, class: DiagnosticClass) -> ArchaicSite { + fn site( + position: i64, + reference_allele: char, + alternate_allele: char, + derived: char, + class: DiagnosticClass, + ) -> ArchaicSite { ArchaicSite { contig: "chr1".into(), position, @@ -741,11 +745,11 @@ mod tests { site(400, 'A', 'G', 'G', DiagnosticClass::Neanderthal), ], }; + // Panel site 400 is absent from the genotypes entirely — the fourth way a site goes uncalled. let genotypes = vec![ - gt("chr1", 100, "A", "G", 2), // counted - gt("chr1", 200, "A", "G", -1), // explicit no-call - gt("chr1", 300, "C", "T", 2), // alleles disagree with the panel - // 400 absent entirely + gt("chr1", 100, "A", "G", 2), // counted + gt("chr1", 200, "A", "G", -1), // explicit no-call + gt("chr1", 300, "C", "T", 2), // alleles disagree with the panel ]; let r = count_archaic_markers(&genotypes, &panel); assert_eq!(r.called_sites, 1, "only the usable site counts"); @@ -768,7 +772,10 @@ mod tests { let dense: Vec = (0..10_000).map(|i| i * 40).collect(); let ds = PositionStream::encode("chr21", &dense); assert_eq!(ds.iter().collect::>(), dense); - assert!(ds.deltas.len() < dense.len() * 2, "delta encoding should stay ~1 byte/site here"); + assert!( + ds.deltas.len() < dense.len() * 2, + "delta encoding should stay ~1 byte/site here" + ); } #[test] @@ -779,7 +786,10 @@ mod tests { contigs: vec![PositionStream::encode("chr21", &[100, 200, 300, 400])], }; // 200 and 400 are shared with Africans -> stripped; the rest are private. - assert_eq!(og.retain_private("chr21", &[50, 200, 250, 400, 500]), vec![50, 250, 500]); + assert_eq!( + og.retain_private("chr21", &[50, 200, 250, 400, 500]), + vec![50, 250, 500] + ); // Exact-boundary behaviour: first and last outgroup entries. assert_eq!(og.retain_private("chr21", &[100, 400]), Vec::::new()); // A contig with no outgroup data yields NOTHING rather than everything — stripping nothing @@ -823,7 +833,9 @@ mod tests { let called: Vec = (0..1000).collect(); // Expected copies over 1000 sites at f=0.5 is 1000; landing exactly there is the median. - let p = dist.percentile_for_called("HIGH", &called, 1000, "fp").expect("percentile"); + let p = dist + .percentile_for_called("HIGH", &called, 1000, "fp") + .expect("percentile"); assert!((p - 50.0).abs() < 2.0, "expected ~50th percentile, got {p}"); // Well above expectation ranks high, well below ranks low. diff --git a/crates/navigator-analysis/src/archaic_match.rs b/crates/navigator-analysis/src/archaic_match.rs index e3e636d8..77f9c2f2 100644 --- a/crates/navigator-analysis/src/archaic_match.rs +++ b/crates/navigator-analysis/src/archaic_match.rs @@ -291,9 +291,9 @@ pub fn observations_for_contig( if callable.callable_fraction(contig, pos) < min_callable_fraction { continue; } - let carries = calls_by_pos.get(&pos).is_some_and(|g| { - g.dosage > 0 && g.alternate_allele.as_bytes().first() == Some(&derived) - }); + let carries = calls_by_pos + .get(&pos) + .is_some_and(|g| g.dosage > 0 && g.alternate_allele.as_bytes().first() == Some(&derived)); let class = match c.classes.get(i).copied().unwrap_or(2) { 0 => DiagnosticClass::Neanderthal, 1 => DiagnosticClass::Denisovan, @@ -327,7 +327,14 @@ fn ln_sum_exp(a: f64, b: f64) -> f64 { /// /// Log-space forward/backward with recombination-scaled transitions, as in [`crate::roh`]. Exposed /// so the decoding can be tested against hand-computed posteriors without constructing assets. -pub fn posteriors(obs: &[SiteObs], contig: &str, gmap: &GeneticMap, p_bg: f64, p_arch: f64, switches_per_cm: f64) -> Vec { +pub fn posteriors( + obs: &[SiteObs], + contig: &str, + gmap: &GeneticMap, + p_bg: f64, + p_arch: f64, + switches_per_cm: f64, +) -> Vec { let n = obs.len(); if n == 0 { return Vec::new(); @@ -477,7 +484,11 @@ pub fn call_from_observations( let total_mb: f64 = segments.iter().map(|s| s.length_mb()).sum(); let summary = ArchaicSummary { total_mb, - pct_callable: if callable_mb > 0.0 { total_mb * 100.0 / callable_mb } else { 0.0 }, + pct_callable: if callable_mb > 0.0 { + total_mb * 100.0 / callable_mb + } else { + 0.0 + }, callable_mb, neanderthal_mb: 0.0, denisovan_mb: 0.0, @@ -537,7 +548,9 @@ pub fn carried_panel_sites<'a>( calls.iter().map(|c| ((c.contig.as_str(), c.position), c)).collect(); let mut out = BTreeMap::new(); for s in &panel.sites { - let Some((k, g)) = by_pos.get_key_value(&(s.contig.as_str(), s.position)) else { continue }; + let Some((k, g)) = by_pos.get_key_value(&(s.contig.as_str(), s.position)) else { + continue; + }; let carries = g.dosage > 0 && g.alternate_allele.starts_with(s.archaic_derived_allele); out.insert(*k, carries); } @@ -567,19 +580,23 @@ pub fn filter_by_concordance( let kept: Vec = result .segments .into_iter() - .filter(|seg| { - match segment_concordance(panel, &seg.contig, seg.start, seg.end, &carried, min_sites) { + .filter( + |seg| match segment_concordance(panel, &seg.contig, seg.start, seg.end, &carried, min_sites) { Some(c) => c >= min_concordance, None => true, - } - }) + }, + ) .collect(); let total_mb: f64 = kept.iter().map(|s| s.length_mb()).sum(); let callable_mb = result.summary.callable_mb; ArchaicSegmentResult { summary: ArchaicSummary { total_mb, - pct_callable: if callable_mb > 0.0 { total_mb * 100.0 / callable_mb } else { 0.0 }, + pct_callable: if callable_mb > 0.0 { + total_mb * 100.0 / callable_mb + } else { + 0.0 + }, callable_mb, neanderthal_mb: 0.0, denisovan_mb: 0.0, @@ -670,7 +687,11 @@ mod tests { ..Default::default() }, ); - assert!(r.segments.is_empty(), "background should call nothing, got {:?}", r.segments); + assert!( + r.segments.is_empty(), + "background should call nothing, got {:?}", + r.segments + ); } /// Scattered carried sites at the background rate must not accumulate into a tract — the @@ -691,7 +712,11 @@ mod tests { ..Default::default() }, ); - assert!(r.segments.is_empty(), "background-rate carriers formed {:?}", r.segments); + assert!( + r.segments.is_empty(), + "background-rate carriers formed {:?}", + r.segments + ); } /// A site whose derived allele IS the reference base separates nothing, and a no-call there @@ -765,7 +790,10 @@ mod tests { // Altai is derived at all 4 sites; Denisova at only the first. let panel = ArchaicMarkerPanel { build: "chm13v2.0".into(), - thresholds: ArchaicPanelThresholds { max_afr_freq: 0.01, min_non_afr_freq: 0.0005 }, + thresholds: ArchaicPanelThresholds { + max_afr_freq: 0.01, + min_non_afr_freq: 0.0005, + }, sites: vec![ panel_site(1_000, 'A', [D, A, A, D]), panel_site(2_000, 'A', [D, A, A, A]), @@ -793,7 +821,10 @@ mod tests { fn filter_keeps_segments_it_cannot_judge() { let panel = ArchaicMarkerPanel { build: "chm13v2.0".into(), - thresholds: ArchaicPanelThresholds { max_afr_freq: 0.01, min_non_afr_freq: 0.0005 }, + thresholds: ArchaicPanelThresholds { + max_afr_freq: 0.01, + min_non_afr_freq: 0.0005, + }, sites: vec![panel_site(1_000, 'A', [ArchaicCall::HomDerived; 4])], }; let seg = ArchaicSegment { @@ -836,7 +867,10 @@ mod tests { } let panel = ArchaicMarkerPanel { build: "chm13v2.0".into(), - thresholds: ArchaicPanelThresholds { max_afr_freq: 0.01, min_non_afr_freq: 0.0005 }, + thresholds: ArchaicPanelThresholds { + max_afr_freq: 0.01, + min_non_afr_freq: 0.0005, + }, sites, }; // Carries 9/10 in the first span, 1/10 in the second. @@ -868,7 +902,10 @@ mod tests { let out = filter_by_concordance(r, &panel, &calls, 0.7, 3); assert_eq!(out.segments.len(), 1, "the poorly-matching segment should go"); assert_eq!(out.segments[0].start, 900); - assert_eq!(out.summary.n_segments, 1, "the summary must be recomputed, not carried over"); + assert_eq!( + out.summary.n_segments, 1, + "the summary must be recomputed, not carried over" + ); } /// A no-call is hom-reference, i.e. NOT carrying. Conditioning on "has a call" instead is what diff --git a/crates/navigator-analysis/src/archaic_segments.rs b/crates/navigator-analysis/src/archaic_segments.rs index 3093bebf..c09f933e 100644 --- a/crates/navigator-analysis/src/archaic_segments.rs +++ b/crates/navigator-analysis/src/archaic_segments.rs @@ -268,7 +268,17 @@ pub fn call_archaic_segments( continue; } segments.extend(call_contig( - contig, positions, lo, hi, gmap, cfg, background, archaic_rate, classify, callable, &alleles, + contig, + positions, + lo, + hi, + gmap, + cfg, + background, + archaic_rate, + classify, + callable, + &alleles, )); } @@ -582,7 +592,14 @@ mod tests { build: "chm13v2.0".into(), contigs: Vec::new(), }; - let r = call_archaic_segments(&calls, &og, &classify, &callable_all(), &GeneticMap::from_markers(Vec::new()), &ArchaicConfig::default()); + let r = call_archaic_segments( + &calls, + &og, + &classify, + &callable_all(), + &GeneticMap::from_markers(Vec::new()), + &ArchaicConfig::default(), + ); assert_eq!(r.segments.len(), 1, "exactly the dense block should call"); let s = &r.segments[0]; assert!(s.start >= 950_000 && s.start <= 1_050_000, "start {} off", s.start); @@ -610,7 +627,14 @@ mod tests { build: "chm13v2.0".into(), contigs: Vec::new(), }; - let r = call_archaic_segments(&calls, &og, &classify, &callable_all(), &GeneticMap::from_markers(Vec::new()), &ArchaicConfig::default()); + let r = call_archaic_segments( + &calls, + &og, + &classify, + &callable_all(), + &GeneticMap::from_markers(Vec::new()), + &ArchaicConfig::default(), + ); assert!(r.segments.is_empty(), "outgroup-shared density must not call archaic"); } diff --git a/crates/navigator-analysis/src/caller.rs b/crates/navigator-analysis/src/caller.rs index aacd20b9..52ced0c4 100644 --- a/crates/navigator-analysis/src/caller.rs +++ b/crates/navigator-analysis/src/caller.rs @@ -494,14 +494,16 @@ pub fn call_indels_at( // Parse + left-normalize each target's expected allele. proc_lo = 1 (full-contig reference). struct PTarget { - pos: i64, // VCF POS (anchor), 1-based - n_anchor: i64, // normalized CIGAR anchor (= pos+1 canonically) + pos: i64, // VCF POS (anchor), 1-based + n_anchor: i64, // normalized CIGAR anchor (= pos+1 canonically) n_allele: IndelAllele, - span_end: i64, // last ref base the ref-spanning read must cover + span_end: i64, // last ref base the ref-spanning read must cover } let mut ptargets: Vec = Vec::new(); for (pos, anc, der) in targets { - let Some(al) = expected_indel_allele(anc, der) else { continue }; + let Some(al) = expected_indel_allele(anc, der) else { + continue; + }; let (n_anchor, n_allele) = left_normalize(pos + 1, &al, &refbytes, 1); let del_len = match &al { IndelAllele::Del(l) => *l as i64, @@ -534,10 +536,14 @@ pub fn call_indels_at( if !passes(&record, params) { continue; } - let Some(start) = record.alignment_start().map(|p| p.get() as i64) else { continue }; + let Some(start) = record.alignment_start().map(|p| p.get() as i64) else { + continue; + }; let (raw, ref_end) = read_indel_events(&record, start); - let events: Vec<(i64, IndelAllele)> = - raw.into_iter().map(|(a, al)| left_normalize(a, &al, &refbytes, 1)).collect(); + let events: Vec<(i64, IndelAllele)> = raw + .into_iter() + .map(|(a, al)| left_normalize(a, &al, &refbytes, 1)) + .collect(); // Targets whose anchor this read could inform: pos in [start, ref_end]. let lo = positions.partition_point(|&p| p < start); let hi = positions.partition_point(|&p| p <= ref_end); @@ -545,10 +551,7 @@ pub fn call_indels_at( let t = &ptargets[i]; if events.iter().any(|(a, al)| *a == t.n_anchor && *al == t.n_allele) { matched[i] += 1; - } else if start <= t.pos - && ref_end >= t.span_end - && !events.iter().any(|(a, _)| *a == t.n_anchor) - { + } else if start <= t.pos && ref_end >= t.span_end && !events.iter().any(|(a, _)| *a == t.n_anchor) { refspan[i] += 1; } } @@ -1160,7 +1163,12 @@ fn denovo_chunk( /// A reassembly candidate for a paralog-gated position: the top **non-reference** base, kept only if /// it carries at least `min_paralog_minor_reads` reads (a real alternate, not a lone error). -fn active_candidate(pos: i64, counts: &[u32; 4], ref_base: u8, params: &HaploidCallerParams) -> Option { +fn active_candidate( + pos: i64, + counts: &[u32; 4], + ref_base: u8, + params: &HaploidCallerParams, +) -> Option { let ref_bi = base_index(ref_base)?; let (alt_bi, &alt_count) = counts .iter() @@ -1319,7 +1327,13 @@ fn extract_window_reads( } } if wseq.len() >= 30 { - reads.push(reassembly::WindowRead { name, seq: wseq, quals: wq, mapq, site_obs }); + reads.push(reassembly::WindowRead { + name, + seq: wseq, + quals: wq, + mapq, + site_obs, + }); } } Ok(reads) @@ -2167,11 +2181,14 @@ mod tests { assert!(!para([11, 0, 0, 0])); // One discordant read at low depth — a sequencing error, kept. assert!(!para([3, 1, 0, 0])); // second=1 (< 2 reads) - // Scattered errors across other bases, none reaching 2 reads — kept. + + // Scattered errors across other bases, none reaching 2 reads — kept. assert!(!para([18, 1, 1, 0])); // second=1 - // Genuine bi-allelic pileup (7 derived / 4 ancestral) — paralog, dropped. + + // Genuine bi-allelic pileup (7 derived / 4 ancestral) — paralog, dropped. assert!(para([7, 4, 0, 0])); // second=4, 0.36 > 0.20 - // Boundary: 2/10 = 0.20 is not strictly above the threshold — kept. + + // Boundary: 2/10 = 0.20 is not strictly above the threshold — kept. assert!(!para([8, 2, 0, 0])); // 3/10 = 0.30 > 0.20 with 3 reads — dropped. assert!(para([7, 3, 0, 0])); diff --git a/crates/navigator-analysis/src/callset.rs b/crates/navigator-analysis/src/callset.rs index 04906c80..07146117 100644 --- a/crates/navigator-analysis/src/callset.rs +++ b/crates/navigator-analysis/src/callset.rs @@ -55,10 +55,7 @@ fn autosome_contig(chr: &str) -> Option { /// Select the target individual's column index in the `.ind` file (0-based, matching the `.geno` /// character position). `sample` names it; a single-individual file needs no name. fn select_individual(ind_text: &str, sample: Option<&str>) -> Result { - let ids: Vec<&str> = ind_text - .lines() - .filter_map(|l| l.split_whitespace().next()) - .collect(); + let ids: Vec<&str> = ind_text.lines().filter_map(|l| l.split_whitespace().next()).collect(); if ids.is_empty() { return Err(AnalysisError::Message("EIGENSTRAT .ind has no individuals".into())); } @@ -191,10 +188,7 @@ rs3 22 0.0 4000 T C // SAMPLE_B: rs1=0→(G,G); rs2=9→missing; rsX skipped (chr23); rs3=1→(T,C). assert_eq!( cs.calls, - vec![ - ("1".to_string(), 1000, 'G', 'G'), - ("22".to_string(), 4000, 'T', 'C'), - ] + vec![("1".to_string(), 1000, 'G', 'G'), ("22".to_string(), 4000, 'T', 'C'),] ); assert_eq!(cs.missing, 1); assert_eq!(cs.build, "GRCh37"); diff --git a/crates/navigator-analysis/src/cancel.rs b/crates/navigator-analysis/src/cancel.rs index e1f6f24c..11b986ce 100644 --- a/crates/navigator-analysis/src/cancel.rs +++ b/crates/navigator-analysis/src/cancel.rs @@ -116,7 +116,10 @@ mod tests { Ok(()) }); canceller.cancel(); - assert!(handle.join().unwrap().is_err(), "the loop must stop, not run to completion"); + assert!( + handle.join().unwrap().is_err(), + "the loop must stop, not run to completion" + ); } /// Cancellation must be reported as itself, never as a generic failure — the UI branches on diff --git a/crates/navigator-analysis/src/coverage.rs b/crates/navigator-analysis/src/coverage.rs index 9fed0ae6..57cd58eb 100644 --- a/crates/navigator-analysis/src/coverage.rs +++ b/crates/navigator-analysis/src/coverage.rs @@ -24,8 +24,8 @@ use noodles::fasta; use serde::{Deserialize, Serialize}; -use crate::contig; use crate::cancel::CancelToken; +use crate::contig; use crate::error::AnalysisError; use crate::reader; use crate::readview::AlnRead; @@ -399,7 +399,14 @@ pub fn collect_coverage_callable( params: &CallableLociParams, contig_allowlist: Option<&HashSet>, ) -> Result { - collect_coverage_callable_with_progress(bam_path, reference_path, params, contig_allowlist, &mut |_, _| {}, &CancelToken::none()) + collect_coverage_callable_with_progress( + bam_path, + reference_path, + params, + contig_allowlist, + &mut |_, _| {}, + &CancelToken::none(), + ) } /// Like [`collect_coverage_callable`], reporting `progress(contigs_done, contigs_total)` as each diff --git a/crates/navigator-analysis/src/gvcf.rs b/crates/navigator-analysis/src/gvcf.rs index 29873619..5180972c 100644 --- a/crates/navigator-analysis/src/gvcf.rs +++ b/crates/navigator-analysis/src/gvcf.rs @@ -224,7 +224,9 @@ pub fn read_diploid_calls_from( let mut col = l.split('\t'); let chrom = col.next().unwrap_or(""); - let Some(sorted) = targets_by_contig.get(chrom) else { continue }; + let Some(sorted) = targets_by_contig.get(chrom) else { + continue; + }; let pos: i64 = match col.next().and_then(|s| s.parse().ok()) { Some(p) => p, None => continue, @@ -316,11 +318,7 @@ pub struct GvcfSnv { /// which resolves sites a pileup caller can't (misaligned ref reads → false ~50/50), so reading the /// GVCF recovers private SNVs the de-novo pileup caller drops. Ref blocks, hom-ref, and indel records /// are skipped; records are gated on `params.min_dp` / `params.min_gq`. -pub fn read_derived_snvs( - gvcf: &Path, - contig: &str, - params: &GvcfReadParams, -) -> Result, AnalysisError> { +pub fn read_derived_snvs(gvcf: &Path, contig: &str, params: &GvcfReadParams) -> Result, AnalysisError> { let file = std::fs::File::open(gvcf).map_err(|e| AnalysisError::io(gvcf, e))?; read_derived_snvs_from(bgzf::io::Reader::new(file), contig, params) } @@ -396,7 +394,11 @@ pub fn read_derived_snvs_from( alternate: alt_allele.as_bytes()[0].to_ascii_uppercase() as char, depth, alt_depth, - allele_fraction: if depth > 0 { alt_depth as f64 / depth as f64 } else { 0.0 }, + allele_fraction: if depth > 0 { + alt_depth as f64 / depth as f64 + } else { + 0.0 + }, gq, }); } @@ -504,7 +506,16 @@ pub fn read_site_evidence_from( Some((ad_vec[0], ad_vec.get(alt_idx).copied().unwrap_or(0))) }; // A variant record is more specific than any ref block at the same site → override. - out.insert(pos, GvcfSiteEvidence { allele, dp, ad, gq, refblock: false }); + out.insert( + pos, + GvcfSiteEvidence { + allele, + dp, + ad, + gq, + refblock: false, + }, + ); } } Ok(out) @@ -680,7 +691,8 @@ chrM\t100\t.\tC\tT,\t500\t.\tDP=30\tGT:AD:DP:GQ:PL\t1:0,30,0:30:99:510, called.variant_bases.insert(2459921, 'A'); called.callable.insert(2459921); called.callable.insert(2459000); // hom-ref-only → takes the reference base - // The reference base at a hom-ref site can be the *derived* allele (CHM13 = J1 Y). + + // The reference base at a hom-ref site can be the *derived* allele (CHM13 = J1 Y). let ref_base: HashMap = [(2459921, 'G'), (2459000, 'T'), (700, 'C')].into_iter().collect(); let calls = assemble_calls(&called, &ref_base); assert_eq!(calls.get(&2459921), Some(&'A'), "variant (derived) wins over reference"); diff --git a/crates/navigator-analysis/src/haplo.rs b/crates/navigator-analysis/src/haplo.rs index 8329949b..30692716 100644 --- a/crates/navigator-analysis/src/haplo.rs +++ b/crates/navigator-analysis/src/haplo.rs @@ -169,7 +169,10 @@ impl DuVariant { if let Some(p) = self.link_alleles() { return p; } - (coord.ancestral.clone().unwrap_or_default(), coord.derived.clone().unwrap_or_default()) + ( + coord.ancestral.clone().unwrap_or_default(), + coord.derived.clone().unwrap_or_default(), + ) } } @@ -299,7 +302,9 @@ pub fn normalize_polarity(tree: &mut HaploTree, reference: &HashMap) -> bool { if b == d { return true; } - let ambiguous = locus - .ancestral - .chars() - .next() - .is_some_and(|a| strand_ambiguous(a, d)); + let ambiguous = locus.ancestral.chars().next().is_some_and(|a| strand_ambiguous(a, d)); !ambiguous && complement_base(b) == d } @@ -803,7 +804,10 @@ fn node_counts(node: &HaploNode, calls: &HashMap) -> (usize, usize, u /// Public view of a node's `(derived, ancestral, no-call)` defining-SNP tally against `calls` — for /// diagnostics / tracing a placement path. pub fn node_call_counts(tree: &HaploTree, calls: &HashMap, node_id: i64) -> (usize, usize, usize) { - tree.nodes.get(&node_id).map(|n| node_counts(n, calls)).unwrap_or((0, 0, 0)) + tree.nodes + .get(&node_id) + .map(|n| node_counts(n, calls)) + .unwrap_or((0, 0, 0)) } /// Find a node by name for the branch-report tool: matches a **haplogroup name** (e.g. `R-FGC29071`) @@ -1036,10 +1040,12 @@ mod tests { fn descent_by_node_buckets_path_with_state() { let t = parse_ftdna_json(TREE).unwrap(); // Sample is derived at H (A146G) and H2 (A263G); H2a's SNP (C750T) was never called. - let state: HashMap = - [("A146G".to_string(), CallState::Derived), ("A263G".to_string(), CallState::Derived)] - .into_iter() - .collect(); + let state: HashMap = [ + ("A146G".to_string(), CallState::Derived), + ("A263G".to_string(), CallState::Derived), + ] + .into_iter() + .collect(); let grouped = descent_by_node(&t, 4, &state); // terminal H2a // root → H → H2 → H2a, root carries no defining loci. @@ -1321,11 +1327,26 @@ mod tests { name: "CT".into(), is_root: false, loci: vec![ - Locus { position: 100, ancestral: "T".into(), derived: "C".into(), name: "PF1016".into() }, + Locus { + position: 100, + ancestral: "T".into(), + derived: "C".into(), + name: "PF1016".into(), + }, // Already-aligned SNP — must be left untouched. - Locus { position: 200, ancestral: "A".into(), derived: "G".into(), name: "M168".into() }, + Locus { + position: 200, + ancestral: "A".into(), + derived: "G".into(), + name: "M168".into(), + }, // Strand-different alleles (G>A vs C>T) — not a pure swap, left untouched. - Locus { position: 300, ancestral: "G".into(), derived: "A".into(), name: "S3".into() }, + Locus { + position: 300, + ancestral: "G".into(), + derived: "A".into(), + name: "S3".into(), + }, ], children: vec![], }, @@ -1479,7 +1500,15 @@ mod tests { let t = parse_ftdna_json(CONFIDENT_DIVERGENCE_TREE).unwrap(); // Derived at H(146); ancestral at B(500) → carries none of B's derived (d == 0); but matches // all five of Bdeep's SNPs (a homoplasy block → 5 derived below B, past REDEEM_DERIVED). - let c = calls(&[(146, 'G'), (500, 'C'), (900, 'A'), (901, 'A'), (902, 'A'), (903, 'A'), (904, 'A')]); + let c = calls(&[ + (146, 'G'), + (500, 'C'), + (900, 'A'), + (901, 'A'), + (902, 'A'), + (903, 'A'), + (904, 'A'), + ]); // Kulczynski is lured to Bdeep by the five coincidental matches... assert_eq!(score(&t, &c)[0].name, "Bdeep"); // ...but B is a confident divergence (zero derived), never redeemed despite the derived block diff --git a/crates/navigator-analysis/src/ibd_panel.rs b/crates/navigator-analysis/src/ibd_panel.rs index d55ffa9d..20ba3c42 100644 --- a/crates/navigator-analysis/src/ibd_panel.rs +++ b/crates/navigator-analysis/src/ibd_panel.rs @@ -422,12 +422,7 @@ mod tests { } // A panel site with an explicit build-locus contig (e.g. "chr1" for the b38 column vs bare "1"). - fn site_b( - rsid: &str, - chm13: (i64, char, char), - build: (&str, i64, char, char), - which: &str, - ) -> IbdPanelSite { + fn site_b(rsid: &str, chm13: (i64, char, char), build: (&str, i64, char, char), which: &str) -> IbdPanelSite { let locus = Locus { contig: build.0.into(), position: build.1, @@ -490,7 +485,10 @@ mod tests { assert!(!by_pos.contains_key(&300), "palindrome skipped"); // Emitted at CHM13 loci with CHM13 alleles; depth preserved. let swap = out.iter().find(|s| s.position == 200).unwrap(); - assert_eq!((swap.reference_allele.as_str(), swap.alternate_allele.as_str()), ("G", "T")); + assert_eq!( + (swap.reference_allele.as_str(), swap.alternate_allele.as_str()), + ("G", "T") + ); assert_eq!(swap.depth, 20); assert!(out.iter().all(|s| s.contig == "chr1")); } @@ -502,7 +500,10 @@ mod tests { let (panel, _) = IbdPanel::from_sites("chm13v2.0", sites); let out = panel.resolve_alignment("GRCh37", &[geno("rs1", "1", 500, "A", "G", 2)]); assert_eq!(out.len(), 1); - assert_eq!((out[0].position, out[0].dosage, out[0].contig.as_str()), (100, 2, "chr1")); + assert_eq!( + (out[0].position, out[0].dosage, out[0].contig.as_str()), + (100, 2, "chr1") + ); // A no-call (dosage < 0) is dropped. assert!(panel .resolve_alignment("GRCh37", &[geno("rs1", "1", 500, "A", "G", -1)]) diff --git a/crates/navigator-analysis/src/index.rs b/crates/navigator-analysis/src/index.rs index 3a8df232..1ee42f8f 100644 --- a/crates/navigator-analysis/src/index.rs +++ b/crates/navigator-analysis/src/index.rs @@ -187,7 +187,6 @@ fn multi_reference_panic(text: &str) -> bool { text.contains(MULTI_REFERENCE_PANIC) && !text.contains("slice reference sequence name") } - fn is_coordinate_sorted(header: &sam::Header) -> bool { header .header() @@ -197,9 +196,7 @@ fn is_coordinate_sorted(header: &sam::Header) -> bool { } #[allow(clippy::type_complexity)] -fn alignment_context( - record: &bam::Record, -) -> std::io::Result<(Option, Option, Option)> { +fn alignment_context(record: &bam::Record) -> std::io::Result<(Option, Option, Option)> { Ok(( record.reference_sequence_id().transpose()?, record.alignment_start().transpose()?, @@ -228,7 +225,10 @@ mod tests { let known = index_panic_error(path, &"invalid reference sequence name"); let known = known.to_string(); assert!(known.contains("multi-reference slices"), "names the cause: {known}"); - assert!(known.contains("samtools index /data/sample.cram"), "gives the command: {known}"); + assert!( + known.contains("samtools index /data/sample.cram"), + "gives the command: {known}" + ); // An unclassified panic reports its own text rather than borrowing the known diagnosis. let other = index_panic_error(path, &String::from("not yet implemented")).to_string(); diff --git a/crates/navigator-analysis/src/lai.rs b/crates/navigator-analysis/src/lai.rs index 1054b042..42a581a0 100644 --- a/crates/navigator-analysis/src/lai.rs +++ b/crates/navigator-analysis/src/lai.rs @@ -441,7 +441,9 @@ fn smooth_viterbi( bp[i][b] = arg; } } - let mut last = (0..n_labels).max_by(|&a, &b| v[n - 1][a].total_cmp(&v[n - 1][b])).unwrap_or(0); + let mut last = (0..n_labels) + .max_by(|&a, &b| v[n - 1][a].total_cmp(&v[n - 1][b])) + .unwrap_or(0); let mut path = vec![0usize; n]; path[n - 1] = last; for i in (1..n).rev() { @@ -496,8 +498,13 @@ fn collapse_labels( .map(|(l, lo, hi)| { let code = labels[l].as_str(); let super_pop = population_super(code).unwrap_or(code); - let fine = if super_pop != code { Some(code.to_string()) } else { None }; - let mean_post = (lo..=hi).map(|i| post[i].get(l).copied().unwrap_or(0.0)).sum::() / (hi - lo + 1) as f64; + let fine = if super_pop != code { + Some(code.to_string()) + } else { + None + }; + let mean_post = + (lo..=hi).map(|i| post[i].get(l).copied().unwrap_or(0.0)).sum::() / (hi - lo + 1) as f64; AncestrySegment { contig: contig.to_string(), start: positions[lo], @@ -648,7 +655,10 @@ mod tests { ); let map = GeneticMap::uniform(1.0, &[("chr1", 250_000_000)]); let phased = phased_side(&pat); - let params = CopyingLaiParams { min_segment_cm: 5.0, ..CopyingLaiParams::default() }; + let params = CopyingLaiParams { + min_segment_cm: 5.0, + ..CopyingLaiParams::default() + }; // Empty prior → gate disabled (keep all haplotypes), so the folding path is exercised. let segs = paint_copying_lai(&phased, &reference, &map, &[], ¶ms); // The folded tiny pop must never surface as a fine call. @@ -799,7 +809,10 @@ mod tests { let map = GeneticMap::uniform(1.0, &[("chr1", 700_000_000)]); let prior = vec![("EUR".to_string(), 1.0)]; let paint = |params: &CopyingLaiParams| { - call_shares(&paint_copying_lai(&phased, &reference, &map, &prior, params), &positions) + call_shares( + &paint_copying_lai(&phased, &reference, &map, &prior, params), + &positions, + ) }; let previous = CopyingLaiParams { recomb_per_cm: 0.1, diff --git a/crates/navigator-analysis/src/library_stats.rs b/crates/navigator-analysis/src/library_stats.rs index a0008b6b..7fcc1bb1 100644 --- a/crates/navigator-analysis/src/library_stats.rs +++ b/crates/navigator-analysis/src/library_stats.rs @@ -153,8 +153,16 @@ pub fn scan_library_stats( /// read platforms are `SHORT`. `None` when the platform is unknown (contributes no vote). fn detect_read_type_from_qname(qname: &str, platform: &str) -> Option<&'static str> { match platform { - "PacBio" => Some(if qname.rsplit('/').next() == Some("ccs") { "HIFI" } else { "CLR" }), - "Nanopore" => Some(if qname.contains(';') { "ONT_DUPLEX" } else { "ONT_SIMPLEX" }), + "PacBio" => Some(if qname.rsplit('/').next() == Some("ccs") { + "HIFI" + } else { + "CLR" + }), + "Nanopore" => Some(if qname.contains(';') { + "ONT_DUPLEX" + } else { + "ONT_SIMPLEX" + }), "Illumina" | "MGI" => Some("SHORT"), _ => None, } @@ -387,12 +395,21 @@ mod tests { Some("ONT_SIMPLEX") ); assert_eq!( - detect_read_type_from_qname("abcdef01-2345-6789-abcd-ef0123456789;01234567-89ab-cdef-0123-456789abcdef", "Nanopore"), + detect_read_type_from_qname( + "abcdef01-2345-6789-abcd-ef0123456789;01234567-89ab-cdef-0123-456789abcdef", + "Nanopore" + ), Some("ONT_DUPLEX") ); // Short-read platforms. - assert_eq!(detect_read_type_from_qname("A00123:45:H7TJ2DSXX:1:1101:1000:1996", "Illumina"), Some("SHORT")); - assert_eq!(detect_read_type_from_qname("V300012345L1C001R0010000123", "MGI"), Some("SHORT")); + assert_eq!( + detect_read_type_from_qname("A00123:45:H7TJ2DSXX:1:1101:1000:1996", "Illumina"), + Some("SHORT") + ); + assert_eq!( + detect_read_type_from_qname("V300012345L1C001R0010000123", "MGI"), + Some("SHORT") + ); // Unknown platform ⇒ no vote. assert_eq!(detect_read_type_from_qname("totally random name", "Unknown"), None); } diff --git a/crates/navigator-analysis/src/mask.rs b/crates/navigator-analysis/src/mask.rs index dce0e825..f2eae544 100644 --- a/crates/navigator-analysis/src/mask.rs +++ b/crates/navigator-analysis/src/mask.rs @@ -224,7 +224,8 @@ mod tests { // [10,20) and [15,25) coalesce to [10,25); [40,50) separate. let m = RegionMask::from_intervals(vec![(40, 50), (10, 20), (15, 25)]); assert_eq!(m.covered(), 15 + 10); // [10,25)=15, [40,50)=10 - // 1-based positions: base0 = pos-1. + + // 1-based positions: base0 = pos-1. assert!(!m.contains(10)); // base0 9 < 10 assert!(m.contains(11)); // base0 10 in [10,25) assert!(m.contains(25)); // base0 24 in [10,25) @@ -240,10 +241,8 @@ mod tests { let dir = std::env::temp_dir().join(format!("dun-maskgz-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("m.bed.gz"); - let mut enc = flate2::write::GzEncoder::new( - std::fs::File::create(&path).unwrap(), - flate2::Compression::default(), - ); + let mut enc = + flate2::write::GzEncoder::new(std::fs::File::create(&path).unwrap(), flate2::Compression::default()); // chrX ignored; two chrY intervals, one of them coalescing. enc.write_all(b"chrY\t100\t200\nchrX\t0\t50\nchrY\t150\t260\n").unwrap(); enc.finish().unwrap(); diff --git a/crates/navigator-analysis/src/mastervar.rs b/crates/navigator-analysis/src/mastervar.rs index 64afd864..1f987c0a 100644 --- a/crates/navigator-analysis/src/mastervar.rs +++ b/crates/navigator-analysis/src/mastervar.rs @@ -189,7 +189,10 @@ fn locus_call(rows: &[Row]) -> Option { } // Diploid: reconstruct the two haplotypes. An `all` snp row means both alleles carry it. - if let Some(all) = rows.iter().find(|r| r.allele == Allele::All && r.var_type == VarType::Snp) { + if let Some(all) = rows + .iter() + .find(|r| r.allele == Allele::All && r.var_type == VarType::Snp) + { return snp_call(contig, position, reference, &all.allele_seq, rs_id, Some("1/1".into())); } let hap = |which: Allele| -> Hap { @@ -277,9 +280,10 @@ pub fn parse_reader(reader: impl BufRead) -> Result') { - columns = Some(Columns::from_header(&line).ok_or_else(|| { - MasterVarError::Format("column header is missing required masterVar fields".into()) - })?); + columns = + Some(Columns::from_header(&line).ok_or_else(|| { + MasterVarError::Format("column header is missing required masterVar fields".into()) + })?); continue; } let Some(c) = columns.as_ref() else { @@ -403,7 +407,10 @@ mod tests { ); assert_eq!(out.calls.len(), 1); let c = &out.calls[0]; - assert_eq!((c.position, c.reference.as_str(), c.alternate.as_str()), (9006, "T", "C")); + assert_eq!( + (c.position, c.reference.as_str(), c.alternate.as_str()), + (9006, "T", "C") + ); assert_eq!(c.genotype.as_deref(), Some("0/1")); } @@ -429,7 +436,10 @@ mod tests { #[test] fn first_rs_id_extracts_first_accession() { - assert_eq!(first_rs_id("dbsnp.100:rs2748067;dbsnp.131:rs76046194").as_deref(), Some("rs2748067")); + assert_eq!( + first_rs_id("dbsnp.100:rs2748067;dbsnp.131:rs76046194").as_deref(), + Some("rs2748067") + ); assert_eq!(first_rs_id("").as_deref(), None); assert_eq!(first_rs_id("cosmic:COSM123").as_deref(), None); } diff --git a/crates/navigator-analysis/src/phasing.rs b/crates/navigator-analysis/src/phasing.rs index 2d0c9c68..0df80f1a 100644 --- a/crates/navigator-analysis/src/phasing.rs +++ b/crates/navigator-analysis/src/phasing.rs @@ -219,13 +219,14 @@ impl<'a> ReferencePhaser<'a> { // Collect candidate successor states, keyed by (x,y), keeping the best incoming lp. let mut next: HashMap<(u32, u32), (f64, u32)> = HashMap::new(); - let consider = |x: u32, y: u32, base_lp: f64, trans_ln: f64, bp: u32, next: &mut HashMap<(u32, u32), (f64, u32)>| { - let lp = base_lp + trans_ln + self.emit_ln(g, allele(col, x as usize), allele(col, y as usize)); - let e = next.entry((x, y)).or_insert((f64::NEG_INFINITY, 0)); - if lp > e.0 { - *e = (lp, bp); - } - }; + let consider = + |x: u32, y: u32, base_lp: f64, trans_ln: f64, bp: u32, next: &mut HashMap<(u32, u32), (f64, u32)>| { + let lp = base_lp + trans_ln + self.emit_ln(g, allele(col, x as usize), allele(col, y as usize)); + let e = next.entry((x, y)).or_insert((f64::NEG_INFINITY, 0)); + if lp > e.0 { + *e = (lp, bp); + } + }; for (bi, s) in prev.iter().enumerate() { let bp = bi as u32; @@ -249,10 +250,7 @@ impl<'a> ReferencePhaser<'a> { } } - let mut beam: Vec = next - .into_iter() - .map(|((x, y), (lp, bp))| Bs { x, y, lp, bp }) - .collect(); + let mut beam: Vec = next.into_iter().map(|((x, y), (lp, bp))| Bs { x, y, lp, bp }).collect(); prune_beam(&mut beam, self.params.beam); trellis.push(beam); } diff --git a/crates/navigator-analysis/src/preflight.rs b/crates/navigator-analysis/src/preflight.rs index 6ba59bae..e2f77135 100644 --- a/crates/navigator-analysis/src/preflight.rs +++ b/crates/navigator-analysis/src/preflight.rs @@ -613,6 +613,9 @@ mod tests { // which path component is absent, so key on the message rather than a Unix errno. assert!(first.detail.starts_with("not found"), "{}", first.detail); // The reference check must not run — the report stops at the first real blocker. - assert!(!report.checks.iter().any(|c| c.id == CheckId::ReferenceFasta), "{report}"); + assert!( + !report.checks.iter().any(|c| c.id == CheckId::ReferenceFasta), + "{report}" + ); } } diff --git a/crates/navigator-analysis/src/reader.rs b/crates/navigator-analysis/src/reader.rs index f8a85686..4d9eeefa 100644 --- a/crates/navigator-analysis/src/reader.rs +++ b/crates/navigator-analysis/src/reader.rs @@ -312,13 +312,16 @@ impl IdxReader { let path = path.clone(); let repo = repo.clone(); - let ref_id = header.reference_sequences().get_index_of(region.name()).ok_or_else(|| { - AnalysisError::Message(format!( - "contig {} not in {} header", - String::from_utf8_lossy(region.name()), - path.display() - )) - })?; + let ref_id = header + .reference_sequences() + .get_index_of(region.name()) + .ok_or_else(|| { + AnalysisError::Message(format!( + "contig {} not in {} header", + String::from_utf8_lossy(region.name()), + path.display() + )) + })?; let interval = region.interval(); let mut offsets = cram_container_offsets(inner.index(), ref_id, interval).into_iter(); @@ -364,8 +367,7 @@ impl IdxReader { for rec in &records { // Same per-record overlap test noodles applies post-decode — the // container filter is a coarse prefilter, not a replacement for it. - if let (Some(Ok(start)), Some(Ok(end))) = (rec.alignment_start(), rec.alignment_end()) - { + if let (Some(Ok(start)), Some(Ok(end))) = (rec.alignment_start(), rec.alignment_end()) { if !interval.intersects((start..=end).into()) { continue; } @@ -734,8 +736,14 @@ mod tests { for region in [ Region::new(b"chrM".to_vec(), ..), - Region::new(b"chrM".to_vec(), Position::new(1).unwrap()..=Position::new(200).unwrap()), - Region::new(b"chrM".to_vec(), Position::new(50).unwrap()..=Position::new(60).unwrap()), + Region::new( + b"chrM".to_vec(), + Position::new(1).unwrap()..=Position::new(200).unwrap(), + ), + Region::new( + b"chrM".to_vec(), + Position::new(50).unwrap()..=Position::new(60).unwrap(), + ), ] { let (header, mut ours) = open_indexed(&cram, Some(&reference)).expect("open"); let mine: Vec = ours @@ -757,7 +765,10 @@ mod tests { .map(|r| capture(&r.expect("rec"))) .collect(); - assert_eq!(mine, reference_impl, "region {region:?}: must match noodles' Query exactly"); + assert_eq!( + mine, reference_impl, + "region {region:?}: must match noodles' Query exactly" + ); } } } diff --git a/crates/navigator-analysis/src/reassembly.rs b/crates/navigator-analysis/src/reassembly.rs index 01bc213c..525b559a 100644 --- a/crates/navigator-analysis/src/reassembly.rs +++ b/crates/navigator-analysis/src/reassembly.rs @@ -199,7 +199,11 @@ fn genotype_candidate( } } - let allele_fraction = if depth > 0 { alt_depth as f64 / depth as f64 } else { 0.0 }; + let allele_fraction = if depth > 0 { + alt_depth as f64 / depth as f64 + } else { + 0.0 + }; let genotype = if log_odds > params.min_log_odds && alt_depth >= params.min_alt_fragments { Zygosity::Derived } else if log_odds < -params.min_log_odds { @@ -468,7 +472,9 @@ mod tests { } fn call_with(reads: &[WindowRead], params: &ReassemblyParams) -> ReassemblyCall { - genotype_window(REF, WIN_START, &[candidate()], reads, params).pop().unwrap() + genotype_window(REF, WIN_START, &[candidate()], reads, params) + .pop() + .unwrap() } #[test] @@ -539,11 +545,19 @@ mod tests { // are clean. Against a reference+single-SNV alt haplotype (v1) the linked variants penalise // the true reads; the POA-assembled haplotype (v2) lets them match cleanly, so the call is // both DERIVED and more confident than v1. - let mut reads: Vec<_> = (0..10).map(|i| read_muts(&format!("alt{i}"), b'T', LINKED, 35, 60)).collect(); + let mut reads: Vec<_> = (0..10) + .map(|i| read_muts(&format!("alt{i}"), b'T', LINKED, 35, 60)) + .collect(); reads.extend((0..4).map(|i| read(&format!("ref{i}"), b'A', 35, 60))); let v1 = call_with(&reads, &ReassemblyParams::default()); // assemble_alt: false (ref+SNV) - let v2 = call_with(&reads, &ReassemblyParams { assemble_alt: true, ..Default::default() }); + let v2 = call_with( + &reads, + &ReassemblyParams { + assemble_alt: true, + ..Default::default() + }, + ); assert_eq!(v1.genotype, Zygosity::Derived); assert_eq!(v2.genotype, Zygosity::Derived); assert!( diff --git a/crates/navigator-analysis/src/roh.rs b/crates/navigator-analysis/src/roh.rs index b8babd29..c8e36b2f 100644 --- a/crates/navigator-analysis/src/roh.rs +++ b/crates/navigator-analysis/src/roh.rs @@ -201,7 +201,13 @@ fn span_cm(gmap: &GeneticMap, chr: &str, start_bp: i64, end_bp: i64) -> f64 { /// Log-space 2-state HMM (0 = Normal, 1 = Autozygous) over one chromosome's sorted sites; returns /// the stitched Autozygous runs (unfiltered). -fn call_chromosome(chr: &str, sites: &[(i64, bool)], gmap: &GeneticMap, cfg: &RohConfig, baseline: f64) -> Vec { +fn call_chromosome( + chr: &str, + sites: &[(i64, bool)], + gmap: &GeneticMap, + cfg: &RohConfig, + baseline: f64, +) -> Vec { let n = sites.len(); let ln = |x: f64| x.max(1e-300).ln(); diff --git a/crates/navigator-analysis/src/scan.rs b/crates/navigator-analysis/src/scan.rs index 867ac499..c5f929e2 100644 --- a/crates/navigator-analysis/src/scan.rs +++ b/crates/navigator-analysis/src/scan.rs @@ -446,9 +446,15 @@ mod tests { let sample = scan_sample(&dir); let sc = &sample.sidecars; - assert!(sc.has_haplogroup_gvcf(), "bare chrY.g.vcf.gz must be detected as the Y GVCF"); + assert!( + sc.has_haplogroup_gvcf(), + "bare chrY.g.vcf.gz must be detected as the Y GVCF" + ); assert!(sc.chr_y_gvcf.as_ref().unwrap().ends_with("chrY.g.vcf.gz")); - assert!(sc.callable_bed.as_ref().is_some_and(|p| p.ends_with("callable_status.bed"))); + assert!(sc + .callable_bed + .as_ref() + .is_some_and(|p| p.ends_with("callable_status.bed"))); assert!(sc.coverage.is_some() && sc.stats.is_some()); assert_eq!(sample.alignment_files.len(), 1, "the chrYM.cram"); diff --git a/crates/navigator-analysis/src/sex.rs b/crates/navigator-analysis/src/sex.rs index b911f59a..6a7e7f2c 100644 --- a/crates/navigator-analysis/src/sex.rs +++ b/crates/navigator-analysis/src/sex.rs @@ -304,7 +304,12 @@ mod tests { #[test] fn y_scoped_detects_y_only_extracts() { // chrY in the millions, autosomes only a few dozen mismapped reads, no chrX → Y-scoped. - assert!(is_y_scoped([("chrY", 3_000_000), ("chr1", 30), ("chr2", 24), ("chr7", 12)])); + assert!(is_y_scoped([ + ("chrY", 3_000_000), + ("chr1", 30), + ("chr2", 24), + ("chr7", 12) + ])); // A pure chrY-only alignment (nothing elsewhere) → Y-scoped. assert!(is_y_scoped([("chrY", 2_000_000)])); // chrY + chrM only (the chrYM.cram shape) → Y-scoped (chrM is neither autosome nor chrX). @@ -314,9 +319,17 @@ mod tests { #[test] fn y_scoped_rejects_wgs_and_females() { // Male WGS: autosomes dwarf chrY → not Y-scoped (the ratio walk handles these). - assert!(!is_y_scoped([("chr1", 200_000_000), ("chrX", 5_000_000), ("chrY", 3_000_000)])); + assert!(!is_y_scoped([ + ("chr1", 200_000_000), + ("chrX", 5_000_000), + ("chrY", 3_000_000) + ])); // Female WGS: chrY only a trace of mismapping → not Y-scoped. - assert!(!is_y_scoped([("chr1", 200_000_000), ("chrX", 10_000_000), ("chrY", 300)])); + assert!(!is_y_scoped([ + ("chr1", 200_000_000), + ("chrX", 10_000_000), + ("chrY", 300) + ])); // Near-empty alignment: a handful of chrY reads is not enough to judge. assert!(!is_y_scoped([("chrY", 50)])); } diff --git a/crates/navigator-analysis/src/sv/walker.rs b/crates/navigator-analysis/src/sv/walker.rs index 492f4c1c..34390ed8 100644 --- a/crates/navigator-analysis/src/sv/walker.rs +++ b/crates/navigator-analysis/src/sv/walker.rs @@ -189,12 +189,7 @@ fn is_expected_orientation(record: &impl AlnRead, pos1: i64, mate_pos: i64) -> b /// Parse the first SA-tag alignment into a [`SplitRead`]; clip length is the read's own /// soft/hard-clip total. -fn extract_split_read( - record: &impl AlnRead, - contig: &str, - mapq: u8, - config: &SvCallerConfig, -) -> Option { +fn extract_split_read(record: &impl AlnRead, contig: &str, mapq: u8, config: &SvCallerConfig) -> Option { let sa = record.string_tag(SA_TAG)?; if sa.is_empty() { return None; diff --git a/crates/navigator-analysis/src/unified.rs b/crates/navigator-analysis/src/unified.rs index 65c9ae47..8f9d9c39 100644 --- a/crates/navigator-analysis/src/unified.rs +++ b/crates/navigator-analysis/src/unified.rs @@ -26,12 +26,12 @@ use noodles::core::Region; use rayon::prelude::*; use serde::{Deserialize, Serialize}; +use crate::cancel::CancelToken; use crate::contig; use crate::coverage::{ merge_coverage_partials, CallableLociParams, ContigCoverageAccum, ContigCoveragePartial, CoverageResult, CoverageState, }; -use crate::cancel::CancelToken; use crate::error::AnalysisError; use crate::read_metrics::{ReadMetrics, ReadMetricsState}; use crate::reader::{self, RecordSink}; @@ -141,7 +141,14 @@ pub fn collect_unified_metrics( params: &CallableLociParams, contig_allowlist: Option<&HashSet>, ) -> Result { - collect_unified_metrics_with_progress(bam_path, reference_path, params, contig_allowlist, &mut |_, _| {}, &CancelToken::none()) + collect_unified_metrics_with_progress( + bam_path, + reference_path, + params, + contig_allowlist, + &mut |_, _| {}, + &CancelToken::none(), + ) } /// Like [`collect_unified_metrics`], reporting `progress(contigs_done, contigs_total)` as the @@ -208,7 +215,14 @@ pub fn collect_unified_metrics_parallel( params: &CallableLociParams, contig_allowlist: Option<&HashSet>, ) -> Result { - collect_unified_metrics_parallel_with_progress(bam_path, reference_path, params, contig_allowlist, &|_, _| {}, &CancelToken::none()) + collect_unified_metrics_parallel_with_progress( + bam_path, + reference_path, + params, + contig_allowlist, + &|_, _| {}, + &CancelToken::none(), + ) } /// Worker threads for the per-contig fan-out. Defaults to all available cores capped at 12 — diff --git a/crates/navigator-analysis/tests/cancel_real.rs b/crates/navigator-analysis/tests/cancel_real.rs index 54d9c46a..a8bb1952 100644 --- a/crates/navigator-analysis/tests/cancel_real.rs +++ b/crates/navigator-analysis/tests/cancel_real.rs @@ -44,7 +44,10 @@ fn cancelling_a_whole_genome_walk_returns_promptly() { let elapsed = started.elapsed(); eprintln!("returned after {elapsed:.1?}: {result:?}"); - assert!(result.is_err(), "a cancelled walk must not return a partial result as success"); + assert!( + result.is_err(), + "a cancelled walk must not return a partial result as success" + ); assert!( matches!(result, Err(navigator_analysis::AnalysisError::Cancelled)), "must report cancellation, not a generic failure" diff --git a/crates/navigator-analysis/tests/mastervar_real.rs b/crates/navigator-analysis/tests/mastervar_real.rs index b71868ca..3be191d0 100644 --- a/crates/navigator-analysis/tests/mastervar_real.rs +++ b/crates/navigator-analysis/tests/mastervar_real.rs @@ -43,7 +43,13 @@ fn parse_real_master_var() { // chrY / chrM must be hemizygous (genotype "1") — never diploid. for c in out.calls.iter().filter(|c| c.contig == "chrY" || c.contig == "chrM") { - assert_eq!(c.genotype.as_deref(), Some("1"), "{}:{} should be hemizygous", c.contig, c.position); + assert_eq!( + c.genotype.as_deref(), + Some("1"), + "{}:{} should be hemizygous", + c.contig, + c.position + ); } // Every call is a clean single-base biallelic SNP. for c in &out.calls { diff --git a/crates/navigator-analysis/tests/parity_real.rs b/crates/navigator-analysis/tests/parity_real.rs index f1c73e51..7ee60f79 100644 --- a/crates/navigator-analysis/tests/parity_real.rs +++ b/crates/navigator-analysis/tests/parity_real.rs @@ -78,7 +78,7 @@ fn hg002_chrm_denovo_smoke() { &PathBuf::from(reference), "chrM", &HaploidCallerParams::default(), - &navigator_analysis::CancelToken::none(), + &navigator_analysis::CancelToken::none(), ) .expect("de-novo should succeed on real data"); @@ -115,7 +115,7 @@ fn hg002_chry_denovo_streams() { &PathBuf::from(reference), "chrY", &HaploidCallerParams::default(), - &navigator_analysis::CancelToken::none(), + &navigator_analysis::CancelToken::none(), ) .expect("chrY de-novo should succeed"); eprintln!("chrY de-novo calls: {}", calls.len()); @@ -307,7 +307,7 @@ fn hg002_chrm_gatk_parity() { &PathBuf::from(&reference), "chrM", &HaploidCallerParams::default(), - &navigator_analysis::CancelToken::none(), + &navigator_analysis::CancelToken::none(), ) .expect("de-novo should succeed"); diff --git a/crates/navigator-analysis/tests/sv.rs b/crates/navigator-analysis/tests/sv.rs index 12bd98cb..84e6f8d4 100644 --- a/crates/navigator-analysis/tests/sv.rs +++ b/crates/navigator-analysis/tests/sv.rs @@ -73,7 +73,7 @@ fn walker_reads_cram_with_the_same_result_as_bam() { &config, &navigator_analysis::CancelToken::none(), ) - .expect("BAM walk should succeed"); + .expect("BAM walk should succeed"); let from_cram = walker::collect_evidence( &fixtures().join("sv.cram"), Some(&fixtures().join("svref.fa")), @@ -92,7 +92,10 @@ fn walker_reads_cram_with_the_same_result_as_bam() { // Compare the evidence itself, not just the counts — the split read carries the fields that // come from the accessors CRAM implements differently (name, SA tag, CIGAR clip length). let (b, c) = (&from_bam.split_reads[0], &from_cram.split_reads[0]); - assert_eq!((&c.read_name, c.clip_length, &c.supp_chrom, c.supp_pos), (&b.read_name, b.clip_length, &b.supp_chrom, b.supp_pos)); + assert_eq!( + (&c.read_name, c.clip_length, &c.supp_chrom, c.supp_pos), + (&b.read_name, b.clip_length, &b.supp_chrom, b.supp_pos) + ); let names = |e: &SvEvidenceCollection| { let mut v: Vec<_> = e .discordant_pairs diff --git a/crates/navigator-app/src/analysis.rs b/crates/navigator-app/src/analysis.rs index fc2cfb50..6b4758fc 100644 --- a/crates/navigator-app/src/analysis.rs +++ b/crates/navigator-app/src/analysis.rs @@ -666,7 +666,9 @@ impl App { } let kind = denovo_kind(&contig); let calls = tokio::task::spawn_blocking(move || { - navigator_analysis::guard_walk("de-novo calling", || caller::call_denovo(&bam, &reference, &contig, ¶ms, &cancel)) + navigator_analysis::guard_walk("de-novo calling", || { + caller::call_denovo(&bam, &reference, &contig, ¶ms, &cancel) + }) }) .await??; self.save_analysis(alignment_id, &kind, caller::DENOVO_VERSION, &calls) @@ -858,7 +860,8 @@ impl App { /// alignments (see [`consensus_diploid_calls`]), sample column `consensus`. Heavy; the export /// path runs it off the UI thread. pub async fn consensus_diploid_vcf(&self, biosample_guid: SampleGuid) -> Result { - let calls = self.consensus_diploid_calls(biosample_guid, None, CancelToken::none()) + let calls = self + .consensus_diploid_calls(biosample_guid, None, CancelToken::none()) .await?; Ok(navigator_analysis::vcf::write_diploid_vcf("consensus", &calls)) } @@ -983,10 +986,10 @@ impl App { Some(p) => Some(PathBuf::from(p)), None => self.gateway.cached_reference(&aln.reference_build), }; - Ok(tokio::task::spawn_blocking(move || { - navigator_analysis::preflight::diagnose(&bam, reference.as_deref()) - }) - .await?) + Ok( + tokio::task::spawn_blocking(move || navigator_analysis::preflight::diagnose(&bam, reference.as_deref())) + .await?, + ) } pub async fn run_denovo_for_alignment( diff --git a/crates/navigator-app/src/brief.rs b/crates/navigator-app/src/brief.rs index 192ef7a7..7bee310e 100644 --- a/crates/navigator-app/src/brief.rs +++ b/crates/navigator-app/src/brief.rs @@ -568,10 +568,7 @@ fn fallback_test_text(lang: Lang, target: TargetType) -> (String, Option TargetType::Autosomal | TargetType::Mixed => ("brief.testAutosomal", None), TargetType::XChromosome => ("brief.testX", Some("brief.testXLimits")), }; - ( - tr(lang, what).to_string(), - limits.map(|k| tr(lang, k).to_string()), - ) + (tr(lang, what).to_string(), limits.map(|k| tr(lang, k).to_string())) } /// The one-line "who you are" headline summary. @@ -582,11 +579,7 @@ fn headline_summary( maternal: Option<&LineageBrief>, ) -> String { match (paternal, maternal) { - (Some(p), Some(m)) => tr_fmt( - lang, - "brief.headlineBoth", - &[name, &p.haplogroup, &m.haplogroup], - ), + (Some(p), Some(m)) => tr_fmt(lang, "brief.headlineBoth", &[name, &p.haplogroup, &m.haplogroup]), (Some(p), None) => tr_fmt(lang, "brief.headlinePaternal", &[name, &p.haplogroup]), (None, Some(m)) => tr_fmt(lang, "brief.headlineMaternal", &[name, &m.haplogroup]), (None, None) => tr(lang, "brief.headlineNone").to_string(), diff --git a/crates/navigator-app/src/commands.rs b/crates/navigator-app/src/commands.rs index 154099c9..6e0e7966 100644 --- a/crates/navigator-app/src/commands.rs +++ b/crates/navigator-app/src/commands.rs @@ -490,8 +490,13 @@ impl App { pub async fn record_analysis_error(&self, alignment_id: i64, step: &str, message: &str) { let mut message = message.to_string(); message.truncate(500); // keep the payload small; the head carries the cause - let marker = AnalysisError { step: step.to_string(), message }; - let _ = self.save_analysis(alignment_id, ERROR_KIND, ERROR_VERSION, &marker).await; + let marker = AnalysisError { + step: step.to_string(), + message, + }; + let _ = self + .save_analysis(alignment_id, ERROR_KIND, ERROR_VERSION, &marker) + .await; } /// Clear any persisted [`record_analysis_error`] marker for this alignment (no-op when absent). diff --git a/crates/navigator-app/src/export.rs b/crates/navigator-app/src/export.rs index 95c44921..f0875f95 100644 --- a/crates/navigator-app/src/export.rs +++ b/crates/navigator-app/src/export.rs @@ -290,7 +290,9 @@ pub fn branch_report_tsv(report: &BranchReport) -> String { "# DUNavigator {dna} branch report — node {} ({}); {d} derived / {a} ancestral / {n} no-call\n", report.root, report.contig ); - out.push_str("node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote\n"); + out.push_str( + "node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote\n", + ); for r in &report.rows { out.push_str(&format!( "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n", @@ -849,11 +851,16 @@ mod tests { ], }; let tsv = branch_report_tsv(&report); - assert!(tsv.lines().next().unwrap().starts_with("# DUNavigator Y-DNA branch report — node R-FGC29071")); - assert!(tsv.contains("node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote")); assert!(tsv .lines() - .any(|l| l == "R-FGC29071\tR-FGC29067\tFGC29069\tchrY\t14583465\tG\tT\tT\tderived\t1\t0,11\t11\t99\tgvcf_variant\t")); + .next() + .unwrap() + .starts_with("# DUNavigator Y-DNA branch report — node R-FGC29071")); + assert!(tsv.contains( + "node\tparent\tmarker\tchrom\tpos\tancestral\tderived\tobserved_base\tstatus\tGT\tAD\tDP\tGQ\tsource\tnote" + )); + assert!(tsv.lines().any(|l| l + == "R-FGC29071\tR-FGC29067\tFGC29069\tchrY\t14583465\tG\tT\tT\tderived\t1\t0,11\t11\t99\tgvcf_variant\t")); // Ref-block row: AD/DP omitted (.), GQ kept, ancestral, hom-ref note. assert!(tsv .lines() diff --git a/crates/navigator-app/src/fastpath.rs b/crates/navigator-app/src/fastpath.rs index 8d844e08..010ce25c 100644 --- a/crates/navigator-app/src/fastpath.rs +++ b/crates/navigator-app/src/fastpath.rs @@ -371,7 +371,14 @@ impl App { }; // Don't downgrade a full deep walk on reimport — keep it if it's already equal-or-fuller. let wrote = self - .save_analysis_no_downgrade(alignment_id, "read_metrics", "1", &metrics, "pipeline-sidecar", completeness) + .save_analysis_no_downgrade( + alignment_id, + "read_metrics", + "1", + &metrics, + "pipeline-sidecar", + completeness, + ) .await?; Ok(wrote) } @@ -601,7 +608,11 @@ impl App { canonical_build(&aln.reference_build), Some(ReferenceBuild::Chm13v2 | ReferenceBuild::Chm13v2MaskedRcrs) ); - let regions = if is_chm13 { self.y_structural_regions().await } else { None }; + let regions = if is_chm13 { + self.y_structural_regions().await + } else { + None + }; // L2: the cohort **callable mask** (Poznik-style, CALLABLE in ≥90% of a ~3k-male cohort) — // only ~25% of non-PAR chrY is reliably callable cohort-wide. L3: a **cohort-shared-sites** // blocklist — every position that varies with ≥2 carriers across the cohort (plus homoplasy diff --git a/crates/navigator-app/src/ftdna_import.rs b/crates/navigator-app/src/ftdna_import.rs index d23beb4c..a50e3226 100644 --- a/crates/navigator-app/src/ftdna_import.rs +++ b/crates/navigator-app/src/ftdna_import.rs @@ -250,17 +250,20 @@ impl App { } } out.subjects_examined += 1; - let derived = - navigator_domain::identity::catalog_ids_from_provenance(&b.donor_identifier, b.sample_accession.as_deref()); + let derived = navigator_domain::identity::catalog_ids_from_provenance( + &b.donor_identifier, + b.sample_accession.as_deref(), + ); if derived.is_empty() { continue; } out.subjects_matched += 1; - let existing: std::collections::HashSet<(String, String)> = external_id::list_for(self.store.pool(), b.guid) - .await? - .into_iter() - .map(|e| (e.source, e.external_id)) - .collect(); + let existing: std::collections::HashSet<(String, String)> = + external_id::list_for(self.store.pool(), b.guid) + .await? + .into_iter() + .map(|e| (e.source, e.external_id)) + .collect(); for (ns, val) in derived { if existing.contains(&(ns.clone(), val.clone())) { continue; @@ -333,9 +336,7 @@ impl App { } } // Skip samples whose name isn't a recognizable catalog alias unless `--all`. - if !all - && navigator_domain::identity::catalog_ids_from_provenance(&b.donor_identifier, None).is_empty() - { + if !all && navigator_domain::identity::catalog_ids_from_provenance(&b.donor_identifier, None).is_empty() { continue; } if limit.is_some_and(|n| out.examined >= n) { @@ -367,11 +368,12 @@ impl App { out.examples.push(format!("{} → {acc}", b.donor_identifier)); } } - let existing: std::collections::HashSet<(String, String)> = external_id::list_for(self.store.pool(), b.guid) - .await? - .into_iter() - .map(|e| (e.source, e.external_id)) - .collect(); + let existing: std::collections::HashSet<(String, String)> = + external_id::list_for(self.store.pool(), b.guid) + .await? + .into_iter() + .map(|e| (e.source, e.external_id)) + .collect(); for (ns, val) in &ids { if existing.contains(&(ns.clone(), val.clone())) { continue; @@ -705,9 +707,11 @@ impl App { source: &str, external_id: &str, ) -> Result, AppError> { - Ok(navigator_store::external_id::find(self.store.pool(), source, external_id) - .await? - .map(|e| e.biosample_guid)) + Ok( + navigator_store::external_id::find(self.store.pool(), source, external_id) + .await? + .map(|e| e.biosample_guid), + ) } /// FTDNA-reported member labels for a Subject, if imported. diff --git a/crates/navigator-app/src/haplogroup.rs b/crates/navigator-app/src/haplogroup.rs index d8821df6..34336371 100644 --- a/crates/navigator-app/src/haplogroup.rs +++ b/crates/navigator-app/src/haplogroup.rs @@ -15,7 +15,10 @@ fn parse_painting_json(s: &str) -> Result { Ok(r) => Ok(r), Err(_) => { let segments: Vec = serde_json::from_str(s)?; - Ok(PaintingResult { segments, ..Default::default() }) + Ok(PaintingResult { + segments, + ..Default::default() + }) } } } @@ -169,7 +172,10 @@ impl App { let per_contig = self.callable_intervals_all(*id).await?; Ok(export::callable_bed(&per_contig)) } - ExportRequest::DiploidVcf(id) => self.diploid_vcf_genome(*id, navigator_analysis::CancelToken::none()).await, + ExportRequest::DiploidVcf(id) => { + self.diploid_vcf_genome(*id, navigator_analysis::CancelToken::none()) + .await + } ExportRequest::ConsensusDiploidVcf(guid) => self.consensus_diploid_vcf(*guid).await, ExportRequest::SubjectBriefHtml(guid) => { let brief = self.subject_brief(*guid).await?; @@ -294,8 +300,15 @@ impl App { source_key: &str, call: &RunHaplogroupCall, ) -> Result<(), AppError> { - self.record_haplogroup_call_fp(biosample_guid, dna_type, source_key, call, CallProvenance::NavigatorWalk, None) - .await + self.record_haplogroup_call_fp( + biosample_guid, + dna_type, + source_key, + call, + CallProvenance::NavigatorWalk, + None, + ) + .await } /// Like [`record_haplogroup_call`](Self::record_haplogroup_call) but stamps the input @@ -406,7 +419,10 @@ impl App { let Ok(bio) = self.biosample_of_alignment(alignment_id).await else { return Ok(false); }; - Ok(self.preferred_external_call(bio, dna_type, alignment_id).await?.is_some()) + Ok(self + .preferred_external_call(bio, dna_type, alignment_id) + .await? + .is_some()) } /// "Compare callers": the trusted external caller vs Navigator's internal caller for one @@ -425,9 +441,11 @@ impl App { }; if y_bearing { let external = match bio { - Some(g) => haplogroup_call::get_one(self.store.pool(), g, DnaType::Y, &external_y_source_key(alignment_id)) - .await? - .map(|c| c.haplogroup), + Some(g) => { + haplogroup_call::get_one(self.store.pool(), g, DnaType::Y, &external_y_source_key(alignment_id)) + .await? + .map(|c| c.haplogroup) + } None => None, }; let navigator = self @@ -443,9 +461,11 @@ impl App { } let external_mt = match bio { - Some(g) => haplogroup_call::get_one(self.store.pool(), g, DnaType::Mt, &external_mt_source_key(alignment_id)) - .await? - .map(|c| c.haplogroup), + Some(g) => { + haplogroup_call::get_one(self.store.pool(), g, DnaType::Mt, &external_mt_source_key(alignment_id)) + .await? + .map(|c| c.haplogroup) + } None => None, }; let navigator_mt = self @@ -730,7 +750,12 @@ impl App { /// tree is unavailable (interpret then falls back to each variant's stored ref/alt). async fn current_y_polarity(&self) -> std::collections::BTreeMap { match y_tree_provider() { - YTreeProvider::DecodingUs => self.decodingus_y_polarity().await.unwrap_or_default().into_iter().collect(), + YTreeProvider::DecodingUs => self + .decodingus_y_polarity() + .await + .unwrap_or_default() + .into_iter() + .collect(), YTreeProvider::Ftdna => self .fetch_ftdna_y_tree() .await @@ -1009,8 +1034,14 @@ impl App { profile.terminal = terminal; // Persist observations (keyed dna_type='Mt') with the tree provider actually used. - self.persist_observed_profile(biosample_guid, DnaType::Mt, &observed, &profile.summary, Some(provider.to_string())) - .await?; + self.persist_observed_profile( + biosample_guid, + DnaType::Mt, + &observed, + &profile.summary, + Some(provider.to_string()), + ) + .await?; Ok(profile) } @@ -1054,7 +1085,11 @@ impl App { .gvcf_base_calls(a.id, "chrY", &gvcf, &tree, tree_build_for_contig("chrY")) .await .ok(), - _ => self.assign_haplogroup_detail(a.id, "chrY", &tree_json).await.ok().map(|(_, _, c)| c), + _ => self + .assign_haplogroup_detail(a.id, "chrY", &tree_json) + .await + .ok() + .map(|(_, _, c)| c), }; let Some(calls) = calls else { continue }; if !calls.is_empty() { @@ -1082,7 +1117,10 @@ impl App { /// DecodingUs-provider genome consensus (the default): genotype every WGS alignment against the /// DecodingUs Y tree in each source's *native* build, group by build, pool by position, and place /// on the build carrying the most evidence. - async fn place_y_consensus_decodingus(&self, biosample_guid: SampleGuid) -> Result, AppError> { + async fn place_y_consensus_decodingus( + &self, + biosample_guid: SampleGuid, + ) -> Result, AppError> { // Genotype every WGS alignment against the **DecodingUs** Y tree — the workspace's configured // provider, served from the local cache — in each source's *native* build (`hs1` for CHM13, // `GRCh38`, `GRCh37`). No liftover and no FTDNA dependency: the per-alignment genotype is @@ -1097,12 +1135,18 @@ impl App { // Parse the DecodingUs tree once per distinct build the sources use (cheap — the JSON is // memoized). Built up front so the async genotyping loop holds only shared borrows of `trees`. - let mut builds: std::collections::HashSet<&'static str> = - alignments.iter().filter_map(|a| decodingus_build_key(&a.reference_build)).collect(); + let mut builds: std::collections::HashSet<&'static str> = alignments + .iter() + .filter_map(|a| decodingus_build_key(&a.reference_build)) + .collect(); for set in &vsets { if set.source_type != SourceType::Chip { // Unknown vendor build → GRCh38 (the vendor-Y-VCF import default). - if let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) { + if let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + { builds.insert(bk); } } @@ -1116,11 +1160,15 @@ impl App { let mut by_build: HashMap<&'static str, YSourceCalls> = HashMap::new(); for a in &alignments { - let Some(bk) = decodingus_build_key(&a.reference_build) else { continue }; + let Some(bk) = decodingus_build_key(&a.reference_build) else { + continue; + }; let Some(tree) = trees.get(bk) else { continue }; // Native build → no liftover; the cache-key matches the Y assignment's, so a CRAM walk is // a hit — but a preferred-external alignment is genotyped from its GVCF instead (no decode). - let Ok(calls) = self.consensus_base_calls(a, "chrY", tree, None).await else { continue }; + let Ok(calls) = self.consensus_base_calls(a, "chrY", tree, None).await else { + continue; + }; if !calls.is_empty() { by_build.entry(bk).or_default().push((SourceType::WgsShortRead, calls)); } @@ -1133,11 +1181,20 @@ impl App { if set.source_type == SourceType::Chip { continue; } - let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) else { continue }; + let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + else { + continue; + }; let Some(tree) = trees.get(bk) else { continue }; let calls = Self::vset_chr_y_calls(set); if !calls.is_empty() { - by_build.entry(bk).or_default().push((set.source_type, strand_reconcile_to_tree(tree, calls))); + by_build + .entry(bk) + .or_default() + .push((set.source_type, strand_reconcile_to_tree(tree, calls))); } } @@ -1203,7 +1260,9 @@ impl App { )); } } - out.push_str(&format!("\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n")); + out.push_str(&format!( + "\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n" + )); Ok(out) } @@ -1236,12 +1295,18 @@ impl App { // Localize the BAM/CRAM and resolve its reference exactly as `base_calls` does, then tally the // raw reads at the lineage positions and read the reference base there. let bam = self - .localize(Path::new(&aln.bam_path.clone().ok_or(AppError::MissingPaths(alignment_id))?)) + .localize(Path::new( + &aln.bam_path.clone().ok_or(AppError::MissingPaths(alignment_id))?, + )) .await; let is_cram = bam.extension().is_some_and(|e| e.eq_ignore_ascii_case("cram")); let reference = match aln.reference_path.clone() { Some(p) => Some(PathBuf::from(p)), - None if is_cram => Some(self.gateway.resolve_reference(&aln.reference_build, &mut |_, _| {}).await?), + None if is_cram => Some( + self.gateway + .resolve_reference(&aln.reference_build, &mut |_, _| {}) + .await?, + ), None => self.gateway.cached_reference(&aln.reference_build), }; let targets: HashSet = assignment.lineage.iter().map(|e| e.position).collect(); @@ -1300,7 +1365,9 @@ impl App { e.state, )); } - out.push_str(&format!("\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n")); + out.push_str(&format!( + "\ntotals: derived={derived} ancestral={ancestral} nocall={nocall}\n" + )); Ok(out) } @@ -1315,7 +1382,11 @@ impl App { decodingus_build_key(&a.reference_build) == Some("hs1") && a.aligner.to_ascii_lowercase().contains("pbmm2") }) - .or_else(|| alignments.iter().find(|a| decodingus_build_key(&a.reference_build) == Some("hs1"))) + .or_else(|| { + alignments + .iter() + .find(|a| decodingus_build_key(&a.reference_build) == Some("hs1")) + }) .or_else(|| alignments.first()); Ok(pick.map(|a| a.id)) } @@ -1330,8 +1401,10 @@ impl App { for a in &alignments { let y_only = match sequence_run::get(self.store.pool(), a.sequence_run_id).await? { // `target_of` is tolerant of unknown codes (→ None), which stay eligible. - Some(run) => navigator_domain::testtype::target_of(&run.test_type) - == Some(navigator_domain::testtype::TargetType::YChromosome), + Some(run) => { + navigator_domain::testtype::target_of(&run.test_type) + == Some(navigator_domain::testtype::TargetType::YChromosome) + } None => false, }; if !y_only { @@ -1364,11 +1437,17 @@ impl App { let tree_json = self.fetch_decodingus_y_tree().await?; let alignments = alignment::list_for_biosample(self.store.pool(), biosample_guid).await?; let vsets = variant_set::list_for_biosample(self.store.pool(), biosample_guid).await?; - let mut builds: std::collections::HashSet<&'static str> = - alignments.iter().filter_map(|a| decodingus_build_key(&a.reference_build)).collect(); + let mut builds: std::collections::HashSet<&'static str> = alignments + .iter() + .filter_map(|a| decodingus_build_key(&a.reference_build)) + .collect(); for set in &vsets { if set.source_type != SourceType::Chip { - if let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) { + if let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + { builds.insert(bk); } } @@ -1381,7 +1460,9 @@ impl App { } let mut by_build: HashMap<&'static str, YSourceCalls> = HashMap::new(); for a in &alignments { - let Some(bk) = decodingus_build_key(&a.reference_build) else { continue }; + let Some(bk) = decodingus_build_key(&a.reference_build) else { + continue; + }; let Some(tree) = trees.get(bk) else { continue }; if let Ok(calls) = self.base_calls(a.id, "chrY", tree, None).await { if !calls.is_empty() { @@ -1393,13 +1474,20 @@ impl App { if set.source_type == SourceType::Chip { continue; } - let Some(bk) = set.reference_build.as_deref().map_or(Some("GRCh38"), decodingus_build_key) else { + let Some(bk) = set + .reference_build + .as_deref() + .map_or(Some("GRCh38"), decodingus_build_key) + else { continue; }; let Some(tree) = trees.get(bk) else { continue }; let calls = Self::vset_chr_y_calls(set); if !calls.is_empty() { - by_build.entry(bk).or_default().push((set.source_type, strand_reconcile_to_tree(tree, calls))); + by_build + .entry(bk) + .or_default() + .push((set.source_type, strand_reconcile_to_tree(tree, calls))); } } let Some(bk) = by_build @@ -1589,7 +1677,11 @@ impl App { continue; }; if !calls.is_empty() { - sources.push((format!("aln #{} · {}", a.id, a.aligner), SourceType::WgsShortRead, calls)); + sources.push(( + format!("aln #{} · {}", a.id, a.aligner), + SourceType::WgsShortRead, + calls, + )); } } @@ -1650,7 +1742,11 @@ impl App { .filter_map(|c| c.alternate.chars().next().map(|b| (c.position, b.to_ascii_uppercase()))) .collect(); if !chip_mt.is_empty() { - sources.push(("Chip mtDNA panel".to_string(), SourceType::Chip, strand_reconcile_to_tree(tree, chip_mt))); + sources.push(( + "Chip mtDNA panel".to_string(), + SourceType::Chip, + strand_reconcile_to_tree(tree, chip_mt), + )); } Ok(sources) @@ -1701,7 +1797,9 @@ impl App { DnaType::Mt => self.cached_mt_profile(biosample_guid).await?, }; let Some(profile) = profile else { return Ok(None) }; - let Some(terminal) = profile.terminal.clone() else { return Ok(None) }; + let Some(terminal) = profile.terminal.clone() else { + return Ok(None); + }; // Render on the configured provider's tree so the node names + defining SNPs line up with the // profile's placement (which followed the same provider). Y: DecodingUs in the subject's @@ -1902,7 +2000,9 @@ impl App { let Some(alignment_id) = self.pick_alignment_for(guid, dna).await? else { return Ok(None); }; - Ok(Some(self.branch_report(alignment_id, dna, node_query, max_depth).await?)) + Ok(Some( + self.branch_report(alignment_id, dna, node_query, max_depth).await?, + )) } /// The persisted autosomal consensus-profile snapshot for a subject, if built — cheap (no @@ -1939,11 +2039,14 @@ impl App { &self, biosample_guid: SampleGuid, ) -> Result, AppError> { - self.build_autosomal_profile_inner(biosample_guid, true).await.map(Some).or_else(|e| match e { - // "no source" isn't an error for a refresh — the subject just has nothing cached yet. - AppError::Import(_) => Ok(None), - other => Err(other), - }) + self.build_autosomal_profile_inner(biosample_guid, true) + .await + .map(Some) + .or_else(|e| match e { + // "no source" isn't an error for a refresh — the subject just has nothing cached yet. + AppError::Import(_) => Ok(None), + other => Err(other), + }) } /// **Panel batch-process mode** (progressive-consensus, docs §7.17): genotype one alignment at @@ -2094,7 +2197,8 @@ impl App { // One source per imported **external autosomal call set** (a trusted 1240K EIGENSTRAT set — // GATK4 / pileupCaller). Resolved to CHM13 panel dosages at import and stored, so it pools in // with no CRAM decode (available to both the full build and the progressive refresh). - for row in navigator_store::external_panel_dosage::list_for_biosample(self.store.pool(), biosample_guid).await? { + for row in navigator_store::external_panel_dosage::list_for_biosample(self.store.pool(), biosample_guid).await? + { match serde_json::from_str::>(&row.dosages) { Ok(gts) => { let obs = to_obs(gts); @@ -2407,10 +2511,12 @@ impl App { } let reference = self.gateway.cached_reference("chm13v2.0")?; let pairs = tokio::task::spawn_blocking(move || { - navigator_analysis::reader::read_contig_sequence(&reference, "chrM").ok().map(|chrm| { - let chrm = String::from_utf8_lossy(&chrm).into_owned(); - navigator_analysis::mtvariants::mt_position_map(navigator_analysis::mtvariants::rcrs(), &chrm) - }) + navigator_analysis::reader::read_contig_sequence(&reference, "chrM") + .ok() + .map(|chrm| { + let chrm = String::from_utf8_lossy(&chrm).into_owned(); + navigator_analysis::mtvariants::mt_position_map(navigator_analysis::mtvariants::rcrs(), &chrm) + }) }) .await .ok() @@ -2574,9 +2680,7 @@ impl App { if let Ok(entries) = std::fs::read_dir(&dir) { for e in entries.flatten() { let p = e.path(); - if p.extension().and_then(|x| x.to_str()) == Some("json") - && std::fs::remove_file(&p).is_ok() - { + if p.extension().and_then(|x| x.to_str()) == Some("json") && std::fs::remove_file(&p).is_ok() { removed += 1; } } @@ -2810,7 +2914,9 @@ impl App { // required; PCA + fine frequencies are optional (best-effort — the feature degrades if absent). self.ensure_ancestry_asset(build, &ancestry_panel_path(build)).await?; let _ = self.ensure_ancestry_asset(build, &ancestry_pca_path(build)).await; - let _ = self.ensure_ancestry_asset(build, &ancestry_freq_global_path(build)).await; + let _ = self + .ensure_ancestry_asset(build, &ancestry_freq_global_path(build)) + .await; let panel_path = ancestry_panel_path(build); let panel_bytes = read_verified_asset(build, &panel_path)? .ok_or_else(|| AppError::AncestryPanelMissing(panel_path.clone()))?; @@ -2878,10 +2984,7 @@ impl App { /// no autosomal calls, or the deep model does not apply (non-European / model rejected / infeasible /// weights). `None` persists nothing — keeping an inapplicable breakdown off the UI *and* out of /// the PDS. - pub async fn estimate_deep_ancestry( - &self, - biosample_guid: SampleGuid, - ) -> Result, AppError> { + pub async fn estimate_deep_ancestry(&self, biosample_guid: SampleGuid) -> Result, AppError> { if !crate::ANCIENT_ANCESTRY_ENABLED { return Ok(None); } @@ -2907,7 +3010,9 @@ impl App { // Both the scope gate and the qpAdm fit read the *same* genotypes; every panel here is // CHM13-canonical (§7.16), so no per-site re-keying is needed. let profile = self.cached_autosomal_profile(biosample_guid).await?.ok_or_else(|| { - AppError::Import("build the autosomal consensus first (Autosomal tab) before estimating deep ancestry".into()) + AppError::Import( + "build the autosomal consensus first (Autosomal tab) before estimating deep ancestry".into(), + ) })?; let genotypes = consensus_genotypes(&profile); if genotypes.is_empty() { @@ -2957,10 +3062,7 @@ impl App { /// /// Every row reports its dispersion even when the applicability gate rejects it, so a rejection /// can be read as a magnitude rather than taken on faith. - pub async fn ancient_ancestry_stability( - &self, - biosample_guid: SampleGuid, - ) -> Result, AppError> { + pub async fn ancient_ancestry_stability(&self, biosample_guid: SampleGuid) -> Result, AppError> { // Build the consensus on demand — this is a diagnostic, and requiring the caller to have // clicked through the GUI first would make it useless from the CLI. let profile = match self.cached_autosomal_profile(biosample_guid).await? { @@ -2969,8 +3071,7 @@ impl App { }; let build = ReferenceBuild::Chm13v2; let path = ancestry_freq_ancient_path(build); - let bytes = - read_verified_asset(build, &path)?.ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; + let bytes = read_verified_asset(build, &path)?.ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; let panel = AncestryPanel::from_bytes(&bytes)?; // The super-pop panel too: deep ancestry is scoped by the modern estimate, so each view has // to be scored by both models or the diagnostic wouldn't be reproducing the shipped policy. @@ -3041,11 +3142,16 @@ impl App { // (MAF/strand/polarity/ts-tv) has been ruled out. if let Ok(dump_path) = std::env::var("NAVIGATOR_ANCIENT_DUMP") { let want = std::env::var("NAVIGATOR_ANCIENT_ALN").unwrap_or_else(|_| "#9".into()); - let freq: std::collections::HashMap<(&str, i64), &Vec> = - panel.sites.iter().map(|s| ((s.contig.as_str(), s.position), &s.freqs)).collect(); + let freq: std::collections::HashMap<(&str, i64), &Vec> = panel + .sites + .iter() + .map(|s| ((s.contig.as_str(), s.position), &s.freqs)) + .collect(); let mut out = String::from("chip\taln_dosage\twhg\tanf\tsteppe\n"); for v in &profile.variants { - let Some(f) = freq.get(&(v.contig.as_str(), v.position)) else { continue }; + let Some(f) = freq.get(&(v.contig.as_str(), v.position)) else { + continue; + }; if f.len() != 3 { continue; } @@ -3077,33 +3183,34 @@ impl App { }; // One source's own observed dosages, restricted to the variants the predicate keeps. - let build_single = - |label: &str, keep: &dyn Fn(&navigator_domain::consensus::DiploidVariant) -> bool| -> Vec { - profile - .variants - .iter() - .filter(|v| keep(v)) - .filter_map(|v| { - let obs = v.sources.iter().find(|s| s.label.as_str() == label)?; - (obs.dosage >= 0).then(|| SiteGenotype { - name: v.name.clone(), - contig: v.contig.clone(), - position: v.position, - reference_allele: v.reference.clone(), - alternate_allele: v.alternate.clone(), - ploidy: 2, - dosage: obs.dosage as i32, - gq: 0, - depth: 0, - ref_depth: 0, - alt_depth: 0, - pls: Vec::new(), - gt: None, - allele_depths: None, - }) + let build_single = |label: &str, + keep: &dyn Fn(&navigator_domain::consensus::DiploidVariant) -> bool| + -> Vec { + profile + .variants + .iter() + .filter(|v| keep(v)) + .filter_map(|v| { + let obs = v.sources.iter().find(|s| s.label.as_str() == label)?; + (obs.dosage >= 0).then(|| SiteGenotype { + name: v.name.clone(), + contig: v.contig.clone(), + position: v.position, + reference_allele: v.reference.clone(), + alternate_allele: v.alternate.clone(), + ploidy: 2, + dosage: obs.dosage as i32, + gq: 0, + depth: 0, + ref_depth: 0, + alt_depth: 0, + pls: Vec::new(), + gt: None, + allele_depths: None, }) - .collect() - }; + }) + .collect() + }; // Each source alone: take that source's own observed dosage at each site. For a WGS // source, also refit it three ways to localize the stability bias: @@ -3118,17 +3225,25 @@ impl App { }); fit(format!("source: {label}"), &build_single(label, &|_| true)); if !is_chip { - fit(format!("source: {label} ∩chip"), &build_single(label, &|v| chip_sites.contains(&v.name))); - fit(format!("source: {label} ∁chip"), &build_single(label, &|v| !chip_sites.contains(&v.name))); - fit(format!("source: {label} ¬ambig"), &build_single(label, &|v| !is_ambiguous(v))); + fit( + format!("source: {label} ∩chip"), + &build_single(label, &|v| chip_sites.contains(&v.name)), + ); + fit( + format!("source: {label} ∁chip"), + &build_single(label, &|v| !chip_sites.contains(&v.name)), + ); + fit( + format!("source: {label} ¬ambig"), + &build_single(label, &|v| !is_ambiguous(v)), + ); } } // Density: deterministic thinning of the pooled consensus. A well-conditioned fit barely // moves when half the evidence is removed; an over-fit one lurches. for (keep, label) in [(2usize, "consensus ÷2 sites"), (4, "consensus ÷4 sites")] { - let thinned: Vec = - consensus.iter().step_by(keep).cloned().collect(); + let thinned: Vec = consensus.iter().step_by(keep).cloned().collect(); fit(label.to_string(), &thinned); } rows @@ -3281,7 +3396,9 @@ impl App { let phaser = ReferencePhaser::new(&hap, &gmap, PhaseParams::default()); let phased_g = phaser.phase(&genotypes); let segs = navigator_analysis::lai::paint_copying_lai(&phased_g, &hap, &gmap, &prior, &lai_params); - let anchor = parent_genos.as_ref().and_then(|pg| anchor_side_to_parent(&phased_g, pg)); + let anchor = parent_genos + .as_ref() + .and_then(|pg| anchor_side_to_parent(&phased_g, pg)); (segs, true, anchor) } None => { @@ -3299,7 +3416,11 @@ impl App { .await?; let side_labels = build_side_labels(phased, anchor_side, parent_meta.as_ref()); - let result = PaintingResult { segments, side_labels, phased }; + let result = PaintingResult { + segments, + side_labels, + phased, + }; // Cache keyed to the consensus signature so it's reused until the consensus is rebuilt. consensus_painting::upsert( @@ -3461,7 +3582,9 @@ impl App { panel_fingerprint: &str, ) -> Result, AppError> { let build = ReferenceBuild::Chm13v2; - let _ = self.ensure_ancestry_asset(build, &crate::archaic_marker_dist_path(build)).await; + let _ = self + .ensure_ancestry_asset(build, &crate::archaic_marker_dist_path(build)) + .await; let Some(bytes) = crate::read_verified_asset(build, &crate::archaic_marker_dist_path(build))? else { return Ok(None); }; @@ -3643,7 +3766,10 @@ impl App { let pairs: Vec<(&str, i32)> = lengths.iter().map(|(k, v)| (k.as_str(), *v)).collect(); let gmap = crate::load_genetic_map(rb, &pairs); - eprintln!("archaic segments: {} calls over {contigs_present} autosome(s)", calls.len()); + eprintln!( + "archaic segments: {} calls over {contigs_present} autosome(s)", + calls.len() + ); let result = tokio::task::spawn_blocking(move || -> Result<_, AppError> { use navigator_analysis::archaic_match as am; @@ -3661,7 +3787,11 @@ impl App { contig, &classify, pos_map, - |p| seq.get((p - 1).max(0) as usize).copied().map(|b| b.to_ascii_uppercase()), + |p| { + seq.get((p - 1).max(0) as usize) + .copied() + .map(|b| b.to_ascii_uppercase()) + }, &callable, am::MatchConfig::default().min_callable_fraction, ); @@ -3707,7 +3837,10 @@ impl App { panel: &ArchaicMarkerPanel, ) -> Result, AppError> { let kind = crate::archaic_panel_cache_kind(); - if let Some(g) = self.load_analysis(alignment_id, &kind, caller::GENOTYPE_VERSION).await? { + if let Some(g) = self + .load_analysis(alignment_id, &kind, caller::GENOTYPE_VERSION) + .await? + { return Ok(g); } let build = self.alignment_or_err(alignment_id).await?.reference_build; @@ -3756,7 +3889,9 @@ impl App { for s in &panel.sites { let Some(l) = s.locus(&build) else { continue }; let key = navigator_analysis::contig::bare_upper(&l.contig); - let Some(contig) = index.get(&key).cloned() else { continue }; + let Some(contig) = index.get(&key).cloned() else { + continue; + }; targets.push(( s, Site { @@ -3783,10 +3918,8 @@ impl App { .await??; // Re-key onto CHM13: same position/alleles the counter expects, dosage re-expressed. - let by_pos: HashMap<(&str, i64), &SiteGenotype> = called - .iter() - .map(|g| ((g.contig.as_str(), g.position), g)) - .collect(); + let by_pos: HashMap<(&str, i64), &SiteGenotype> = + called.iter().map(|g| ((g.contig.as_str(), g.position), g)).collect(); let mut out = Vec::with_capacity(targets.len()); for (site, target) in &targets { let Some(g) = by_pos.get(&(target.contig.as_str(), target.position)) else { @@ -3836,16 +3969,17 @@ impl App { alignment_id: i64, ) -> Result { let build = ReferenceBuild::Chm13v2; - self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)).await?; + self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)) + .await?; let path = crate::archaic_markers_path(build); - let bytes = crate::read_verified_asset(build, &path)? - .ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; + let bytes = + crate::read_verified_asset(build, &path)?.ok_or_else(|| AppError::AncestryPanelMissing(path.clone()))?; let panel = ArchaicMarkerPanel::from_bytes(&bytes)?; let genotypes = self.genotype_archaic_for_alignment(alignment_id, &panel).await?; - Ok(tokio::task::spawn_blocking(move || { - navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel) - }) - .await?) + Ok( + tokio::task::spawn_blocking(move || navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel)) + .await?, + ) } /// The cached archaic (Tier A) marker count for a subject, if one was computed from the @@ -3884,16 +4018,15 @@ impl App { let row = consensus_profile::get(self.store.pool(), biosample_guid, "Auto") .await? .ok_or_else(|| { - AppError::Import( - "build the autosomal consensus first (Autosomal tab) before the archaic report".into(), - ) + AppError::Import("build the autosomal consensus first (Autosomal tab) before the archaic report".into()) })?; // Load the panel BEFORE the cache check: the cache signature is salted with the panel's // hash as well as the consensus signature, because rebuilding the panel changes the site // list and the per-class split, and keying on the consensus alone would serve a stale count // computed against a different panel. let build = ReferenceBuild::Chm13v2; - self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)).await?; + self.ensure_ancestry_asset(build, &crate::archaic_markers_path(build)) + .await?; let panel_path = crate::archaic_markers_path(build); let bytes = crate::read_verified_asset(build, &panel_path)? .ok_or_else(|| AppError::AncestryPanelMissing(panel_path.clone()))?; @@ -3932,10 +4065,9 @@ impl App { } } - let mut result = tokio::task::spawn_blocking(move || { - navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel) - }) - .await?; + let mut result = + tokio::task::spawn_blocking(move || navigator_analysis::archaic::count_archaic_markers(&genotypes, &panel)) + .await?; // Percentile — valid at ANY coverage now, because the cohort is scored over exactly the // sites this subject called rather than over the whole panel. A chip reaching ~3% of the @@ -4520,8 +4652,8 @@ impl App { ) -> Result, AppError> { let bam = bam.to_path_buf(); let reference = reference.map(|p| p.to_path_buf()); - let names = tokio::task::spawn_blocking(move || caller::header_contig_names(&bam, reference.as_deref())) - .await??; + let names = + tokio::task::spawn_blocking(move || caller::header_contig_names(&bam, reference.as_deref())).await??; // Candidate spellings for the requested contig, in preference order. let bare = navigator_analysis::contig::bare(contig); let mut candidates: Vec = vec![contig.to_string(), bare.to_string()]; @@ -4571,7 +4703,9 @@ impl App { chr_y_gvcf_for_alignment(aln) }; if let Some(gvcf) = gvcf { - return self.gvcf_base_calls(aln.id, contig, &gvcf, tree, tree_source_build).await; + return self + .gvcf_base_calls(aln.id, contig, &gvcf, tree, tree_source_build) + .await; } } self.base_calls(aln.id, contig, tree, tree_source_build).await @@ -4669,13 +4803,8 @@ impl App { let mut calls = caller::call_bases_at(&bam, &resolved, &targets, ¶ms, reference.as_deref())?; if !indel_targets.is_empty() { - let indels = caller::call_indels_at( - &bam, - &resolved, - &indel_targets, - ¶ms, - reference.as_deref(), - )?; + let indels = + caller::call_indels_at(&bam, &resolved, &indel_targets, ¶ms, reference.as_deref())?; calls.extend(indels); // sentinel overlays the anchor's base call } Ok(calls) @@ -4687,7 +4816,8 @@ impl App { // Cache the genotypes (stamped with the BAM source_sig) so a rebuild skips the walk. let pairs: Vec<(i64, char)> = calls.iter().map(|(&p, &b)| (p, b)).collect(); - self.save_analysis(alignment_id, GENOTYPE_KIND, &cache_key, &pairs).await?; + self.save_analysis(alignment_id, GENOTYPE_KIND, &cache_key, &pairs) + .await?; Ok(calls) } @@ -4747,7 +4877,8 @@ impl App { }) .await?; let Ok(pairs) = map else { return Ok(None) }; // chrM absent/unreadable → direct fallback - // rcrs_idx/chrm_idx are 0-based; tree + query positions are 1-based. + + // rcrs_idx/chrm_idx are 0-based; tree + query positions are 1-based. let by_rcrs: HashMap = pairs.into_iter().map(|(r, c)| (r as i64 + 1, c as i64 + 1)).collect(); let lifted = targets .iter() @@ -5025,13 +5156,13 @@ mod vset_autosomal_calls_tests { #[test] fn genotype_becomes_reference_forward_allele_pair() { let s = set(vec![ - call("chr1", 100, "C", "T", "1/1"), // hom-alt → (T, T) - call("chr1", 200, "A", "G", "0/1"), // het → (A, G) - call("chr1", 300, "G", "A", "1/."), // het w/ no-call partner → (G, A) - call("chr7", 400, "A", "C", ""), // no genotype → assume het → (A, C) - call("chr2", 500, "A", "G", "1/2"), // tri-allelic → dropped - call("chrY", 600, "A", "G", "1"), // not autosomal → dropped - call("chrM", 700, "A", "G", "1"), // not autosomal → dropped + call("chr1", 100, "C", "T", "1/1"), // hom-alt → (T, T) + call("chr1", 200, "A", "G", "0/1"), // het → (A, G) + call("chr1", 300, "G", "A", "1/."), // het w/ no-call partner → (G, A) + call("chr7", 400, "A", "C", ""), // no genotype → assume het → (A, C) + call("chr2", 500, "A", "G", "1/2"), // tri-allelic → dropped + call("chrY", 600, "A", "G", "1"), // not autosomal → dropped + call("chrM", 700, "A", "G", "1"), // not autosomal → dropped ]); let mut got = App::vset_autosomal_calls(&s); got.sort_by_key(|(_, p, _, _)| *p); @@ -5114,7 +5245,10 @@ mod painting_anchor_tests { #[test] fn side_labels_from_sex_and_anchor() { // Unphased → neutral Side A/B regardless of anchor. - assert_eq!(build_side_labels(false, Some(0), None), ["Side A".to_string(), "Side B".to_string()]); + assert_eq!( + build_side_labels(false, Some(0), None), + ["Side A".to_string(), "Side B".to_string()] + ); // Phased, anchored to side 0, parent female → side 0 Mother, side 1 Father. let mother = (Some("female".to_string()), "Mum".to_string()); @@ -5135,7 +5269,10 @@ mod painting_anchor_tests { ["Parent: Kim".to_string(), "Other parent".to_string()] ); // Phased but no anchor → neutral. - assert_eq!(build_side_labels(true, None, None), ["Side A".to_string(), "Side B".to_string()]); + assert_eq!( + build_side_labels(true, None, None), + ["Side A".to_string(), "Side B".to_string()] + ); } #[test] diff --git a/crates/navigator-app/src/ibd_exchange.rs b/crates/navigator-app/src/ibd_exchange.rs index 8e7a6ffa..868f0cc8 100644 --- a/crates/navigator-app/src/ibd_exchange.rs +++ b/crates/navigator-app/src/ibd_exchange.rs @@ -64,13 +64,10 @@ impl App { let dev = self.ensure_device_key().await?; let request_uri = format!("exchange:{}", Uuid::new_v4()); let ts = Utc::now().timestamp(); - let sig = dev.sign_fresh(ts, &exchange::messages::request( - &request_uri, - &did, - partner_did, - purpose, - scope, - )); + let sig = dev.sign_fresh( + ts, + &exchange::messages::request(&request_uri, &did, partner_did, purpose, scope), + ); let body = serde_json::json!({ "request_uri": request_uri, "initiator_did": did, diff --git a/crates/navigator-app/src/import_profiles.rs b/crates/navigator-app/src/import_profiles.rs index b9e8639d..b05056be 100644 --- a/crates/navigator-app/src/import_profiles.rs +++ b/crates/navigator-app/src/import_profiles.rs @@ -17,7 +17,11 @@ fn load_ysnp_dictionary_cached() -> Result, String> { .map(|f| dir.join(f)) .find(|p| p.is_file()) .ok_or_else(|| format!("no Y-SNP dictionary in {}", dir.display()))?; - let key = format!("{}|{}", dict_path.display(), file_signature(&dict_path).unwrap_or_default()); + let key = format!( + "{}|{}", + dict_path.display(), + file_signature(&dict_path).unwrap_or_default() + ); let memo = YSNP_MEMO.get_or_init(|| Mutex::new(None)); if let Some((k, d)) = memo.lock().unwrap().as_ref() { if *k == key { @@ -308,8 +312,7 @@ impl App { /// re-publish, not a client change. Best-effort — the caller then loads, degrading clearly if the /// dictionary is still absent. Publish with `packaging/publish-assets.sh ysnp`. pub async fn ensure_ysnp_dictionary(&self) -> Result<(), AppError> { - const YSNP_ASSET_BASE: &str = - "https://github.com/JamesKane/decodingus-navigator/releases/download/assets-ysnp"; + const YSNP_ASSET_BASE: &str = "https://github.com/JamesKane/decodingus-navigator/releases/download/assets-ysnp"; let dir = ysnp_dict::asset_dir(); if YsnpDictionary::ASSET_FILENAMES.iter().any(|f| dir.join(f).is_file()) { diff --git a/crates/navigator-app/src/import_unified.rs b/crates/navigator-app/src/import_unified.rs index 9c768baa..8182a192 100644 --- a/crates/navigator-app/src/import_unified.rs +++ b/crates/navigator-app/src/import_unified.rs @@ -343,7 +343,11 @@ impl App { .iter() .filter(|f| f.kind != navigator_analysis::scan::DiscoveredFileType::Index) { - let name = f.path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = f + .path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); match self.add_data(biosample_guid, &f.path).await { Ok(d) => summary.imported.push((name, d.description().to_string())), Err(e) => summary.skipped.push((name, e.to_string())), @@ -380,13 +384,19 @@ impl App { let existing = alignment::list_for_run(self.store.pool(), run.id).await?; for aln_path in &sample.alignment_files { let path_str = aln_path.to_string_lossy().into_owned(); - if existing.iter().any(|a| a.bam_path.as_deref() == Some(path_str.as_str())) { + if existing + .iter() + .any(|a| a.bam_path.as_deref() == Some(path_str.as_str())) + { summary.alignments_skipped += 1; continue; } let probe_path = aln_path.clone(); let (build, _source) = tokio::task::spawn_blocking(move || detect_build_for(&probe_path)).await?; - let reference_path = self.gateway.cached_reference(&build).map(|p| p.to_string_lossy().into_owned()); + let reference_path = self + .gateway + .cached_reference(&build) + .map(|p| p.to_string_lossy().into_owned()); self.record_alignment(NewAlignment { sequence_run_id: run.id, reference_build: build, @@ -409,14 +419,19 @@ impl App { // also lists as variant files — the guard keeps them out of this loop too. if !sample.sidecars.has_haplogroup_gvcf() { for vcf in &sample.variant_files { - let name = vcf.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let name = vcf + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); match self .import_variants_from_file(biosample_guid, vcf, variants::SourceType::Imported) .await { Ok(_) => { summary.variants_imported += 1; - summary.imported.push((name, DetectedData::Variants.description().to_string())); + summary + .imported + .push((name, DetectedData::Variants.description().to_string())); } Err(e) => summary.skipped.push((name, e.to_string())), } @@ -490,7 +505,10 @@ impl App { /// the CRAM. The external calls land on their own `:ext` keys (they cannot clobber, and with the /// "prefer external caller" policy they win the consensus). Returns `(y_placed, mt_placed)`. /// This is the operational fix for a workspace imported before external-caller precedence. - pub async fn reingest_external_for_biosample(&self, biosample_guid: SampleGuid) -> Result<(usize, usize), AppError> { + pub async fn reingest_external_for_biosample( + &self, + biosample_guid: SampleGuid, + ) -> Result<(usize, usize), AppError> { let alns = alignment::list_for_biosample(self.store.pool(), biosample_guid).await?; let (mut y_placed, mut mt_placed) = (0usize, 0usize); for a in &alns { @@ -554,7 +572,13 @@ impl App { .flat_map(|s| s.alignment_files.iter().cloned()) .collect(); let detected: HashMap = tokio::task::spawn_blocking(move || { - all_paths.into_iter().map(|p| { let d = detect_build_for(&p); (p, d) }).collect() + all_paths + .into_iter() + .map(|p| { + let d = detect_build_for(&p); + (p, d) + }) + .collect() }) .await?; @@ -580,13 +604,12 @@ impl App { // Effective build: keep the detected one when the gateway recognizes it (or an explicit // FASTA overrides everything); otherwise fall back to the default so unlabeled files // still import instead of killing the batch. - let (effective, defaulted) = if explicit.is_some() - || !matches!(self.gateway.reference_status(detected_build), RefStatus::Unknown) - { - (detected_build.clone(), false) - } else { - (DEFAULT_IMPORT_BUILD.to_string(), true) - }; + let (effective, defaulted) = + if explicit.is_some() || !matches!(self.gateway.reference_status(detected_build), RefStatus::Unknown) { + (detected_build.clone(), false) + } else { + (DEFAULT_IMPORT_BUILD.to_string(), true) + }; effective_of.insert(detected_build.clone(), effective.clone()); // Resolve the effective build to a FASTA once (explicit > already-resolved > cache > @@ -603,7 +626,11 @@ impl App { RefStatus::Cached(p) | RefStatus::LocalOverride(p) => Some(p.to_string_lossy().into_owned()), RefStatus::NeedsDownload { url, est_bytes } => { if !needs.iter().any(|n| n.build == effective) { - needs.push(BuildNeed { build: effective.clone(), url, est_bytes }); + needs.push(BuildNeed { + build: effective.clone(), + url, + est_bytes, + }); } None } @@ -669,7 +696,15 @@ impl App { for (i, sample) in discovered.samples.iter().enumerate() { progress(i, total, &sample.sample_id); if let Err(e) = self - .import_project_sample(sample, &project, fast_path, &detected, &effective_of, &resolved, &mut summary) + .import_project_sample( + sample, + &project, + fast_path, + &detected, + &effective_of, + &resolved, + &mut summary, + ) .await { eprintln!( @@ -716,8 +751,14 @@ impl App { }; // Ensure the subject is a member of this project (idempotent on the (guid, project) PK). // A reused subject whose *home* project is another one still joins this project's roster. - biosample_project::add(self.store.pool(), biosample.guid, project.id, None, &Utc::now().to_rfc3339()) - .await?; + biosample_project::add( + self.store.pool(), + biosample.guid, + project.id, + None, + &Utc::now().to_rfc3339(), + ) + .await?; // SequenceRun: reuse the first existing run, else create one (defaults to WGS). let run = match sequence_run::list_for_biosample(self.store.pool(), biosample.guid) @@ -937,13 +978,19 @@ impl App { let previous = std::fs::read(&manifest_path).ok(); let _ = std::fs::remove_file(&manifest_path); // else the gateway serves the cached copy let url = format!("{base}/{manifest_name}"); - match self.gateway.resolve_ancestry_asset(&manifest_name, &url, &mut |_, _| {}).await { + match self + .gateway + .resolve_ancestry_asset(&manifest_name, &url, &mut |_, _| {}) + .await + { Ok(_) => manifest = load_asset_manifest(build), Err(e) => { if let Some(bytes) = previous { let _ = std::fs::write(&manifest_path, bytes); } - eprintln!("ancestry assets: could not fetch {manifest_name} ({e}) — leaving {name} to on-disk state"); + eprintln!( + "ancestry assets: could not fetch {manifest_name} ({e}) — leaving {name} to on-disk state" + ); return Ok(()); } } @@ -1114,7 +1161,11 @@ impl App { /// **no CRAM decode**, re-keys to canonical CHM13 (`resolve_chip`), stores the dosages as an /// `external` source, and refreshes the autosomal consensus. Build is auto-detected from the VCF /// header (`NAVIGATOR_CALLSET_BUILD` overrides). Returns the number of resolved panel sites. - pub async fn import_gvcf_callset_from_file(&self, biosample_guid: SampleGuid, path: &Path) -> Result { + pub async fn import_gvcf_callset_from_file( + &self, + biosample_guid: SampleGuid, + path: &Path, + ) -> Result { let build = callset_build_for(path); let panel = self.load_ibd_panel().await?; @@ -1166,9 +1217,12 @@ impl App { .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "external VCF".into()); - self.store_external_dosages(biosample_guid, &format!("{label} (1240K call set, {build})"), dosages, || { - format!("the VCF genotyped 0 panel sites on {build} ({called} calls) — check the build/VCF") - }) + self.store_external_dosages( + biosample_guid, + &format!("{label} (1240K call set, {build})"), + dosages, + || format!("the VCF genotyped 0 panel sites on {build} ({called} calls) — check the build/VCF"), + ) .await } @@ -1186,11 +1240,14 @@ impl App { if site_count == 0 { return Err(AppError::Import(on_empty())); } - let json = serde_json::to_string(&dosages).map_err(|e| AppError::Import(format!("serializing dosages: {e}")))?; + let json = + serde_json::to_string(&dosages).map_err(|e| AppError::Import(format!("serializing dosages: {e}")))?; let row = navigator_store::external_panel_dosage::StoredPanelDosage { biosample_guid: biosample_guid.0.to_string(), source_label: source_label.to_string(), - provenance: navigator_domain::reconciliation::CallProvenance::External.as_str().to_string(), + provenance: navigator_domain::reconciliation::CallProvenance::External + .as_str() + .to_string(), panel_sig: Some(ibd_panel_cache_kind()), site_count: site_count as i64, dosages: json, @@ -1419,7 +1476,10 @@ pub(crate) enum AssetAction { Skip, } -pub(crate) fn asset_action(entry: Option<&navigator_analysis::manifest::AssetEntry>, on_disk: Option) -> AssetAction { +pub(crate) fn asset_action( + entry: Option<&navigator_analysis::manifest::AssetEntry>, + on_disk: Option, +) -> AssetAction { match (entry, on_disk) { (None, _) => AssetAction::Skip, (Some(_), None) => AssetAction::Download, @@ -1434,7 +1494,10 @@ mod asset_tests { use navigator_analysis::manifest::AssetEntry; fn entry(bytes: u64) -> AssetEntry { - AssetEntry { sha256: "deadbeef".into(), bytes } + AssetEntry { + sha256: "deadbeef".into(), + bytes, + } } #[test] @@ -1446,7 +1509,10 @@ mod asset_tests { assert_eq!(asset_action(Some(&entry(100)), None), AssetAction::Download); assert_eq!(asset_action(Some(&entry(100)), Some(100)), AssetAction::Ready); // The case a plain existence check misses: a locally-present asset the release has revised. - assert_eq!(asset_action(Some(&entry(139_815_581)), Some(13_774_065)), AssetAction::Replace); + assert_eq!( + asset_action(Some(&entry(139_815_581)), Some(13_774_065)), + AssetAction::Replace + ); // …and a truncated download. assert_eq!(asset_action(Some(&entry(100)), Some(41)), AssetAction::Replace); } diff --git a/crates/navigator-app/src/lib.rs b/crates/navigator-app/src/lib.rs index e06d3039..788cf963 100644 --- a/crates/navigator-app/src/lib.rs +++ b/crates/navigator-app/src/lib.rs @@ -28,29 +28,26 @@ pub use navigator_analysis::haplo::{BranchEvidence, CallState, NodeEvidence, Sco pub use navigator_analysis::heteroplasmy::HeteroplasmySite; pub use navigator_analysis::mask::YRegionClass; pub use navigator_analysis::mtvariants::{MtRegion, MtVariant, MtVariantKind}; -pub use navigator_analysis::CancelToken; pub use navigator_analysis::preflight::{ Check as PreflightCheck, Report as PreflightReport, Status as PreflightStatus, }; +pub use navigator_analysis::CancelToken; /// Diagnose a BAM/CRAM **path** with no workspace record behind it — the case that matters when a /// user is reporting a file the app refuses to read and we need the answer before deciding whether /// importing it is even possible. Blocking; call it off the async runtime. -pub fn diagnose_alignment_file( - alignment: &std::path::Path, - reference: Option<&std::path::Path>, -) -> PreflightReport { +pub fn diagnose_alignment_file(alignment: &std::path::Path, reference: Option<&std::path::Path>) -> PreflightReport { navigator_analysis::preflight::diagnose(alignment, reference) } -pub use navigator_analysis::probe::AlignmentProbe; -pub use navigator_analysis::read_metrics::{PairOrientation, ReadMetrics}; -pub use navigator_analysis::archaic_segments::{ - ArchaicConfig, ArchaicSegment, ArchaicSegmentResult, ArchaicSource, ArchaicSummary, -}; pub use navigator_analysis::archaic::{ ArchaicCallable, ArchaicClassify, ArchaicCountDistribution, ArchaicMarkerPanel, ArchaicMarkerResult, ArchaicOutgroup, DiagnosticClass, }; +pub use navigator_analysis::archaic_segments::{ + ArchaicConfig, ArchaicSegment, ArchaicSegmentResult, ArchaicSource, ArchaicSummary, +}; +pub use navigator_analysis::probe::AlignmentProbe; +pub use navigator_analysis::read_metrics::{PairOrientation, ReadMetrics}; pub use navigator_analysis::roh::{RohConfig, RohPattern, RohResult, RohSegment, RohSummary}; pub use navigator_analysis::sex::{Confidence as SexConfidence, InferredSex, SexInferenceResult}; pub use navigator_analysis::sv::types::{SvAnalysisResult, SvCall, SvType}; @@ -304,6 +301,7 @@ mod publish_gate_tests { #[test] fn publish_gate_admits_only_confident_unique_novels() { let g = PublishGate::default(); // af >= 0.9, alt_depth >= 10 + // The one that should publish: novel, unique, homozygous, deep. assert!(g.admits(&var(PrivateClass::Novel, None, 30, 1.0))); // Off-path-known is informational, never a novel-branch claim. @@ -329,7 +327,7 @@ mod publish_gate_tests { let bucket = PrivateBucket { terminal: "R-FGC29071".into(), variants: vec![ - var(PrivateClass::Novel, None, 30, 1.0), // publishable + var(PrivateClass::Novel, None, 30, 1.0), // publishable var(PrivateClass::Novel, Some(YRegionClass::Amplicon), 30, 1.0), // structural → no var(PrivateClass::OffPathKnown("Z".into()), None, 30, 1.0), // off-path → no var(PrivateClass::Novel, None, 2, 1.0), // shallow → no @@ -370,9 +368,9 @@ use navigator_sync::{ /// assert they are *not* on the real keychain. pub use navigator_sync::{os_keychain_enabled, use_os_keychain}; pub use navigator_sync::{ - AlignmentRecord, BiosampleRecord, ContigMetrics, FeedPostRecord, PdsClient, PopulationBreakdownRecord, PrivateVariantsRecord, - RecordRef, SequenceRunRecord, VariantCallEntry, NS_ALIGNMENT, NS_BIOSAMPLE, NS_FEED_POST, NS_POPULATION_BREAKDOWN, - NS_SEQUENCERUN, PRIVATE_VARIANTS_COLLECTION, + AlignmentRecord, BiosampleRecord, ContigMetrics, FeedPostRecord, PdsClient, PopulationBreakdownRecord, + PrivateVariantsRecord, RecordRef, SequenceRunRecord, VariantCallEntry, NS_ALIGNMENT, NS_BIOSAMPLE, NS_FEED_POST, + NS_POPULATION_BREAKDOWN, NS_SEQUENCERUN, PRIVATE_VARIANTS_COLLECTION, }; use navigator_sync::{ AuditEntryRecord, HaplogroupReconciliationRecord, HeteroplasmyObservationRecord, IdentityVerificationRecord, @@ -803,10 +801,10 @@ pub use navigator_store::ibd_exchange::StoredIbdExchange; pub use navigator_store::ibd_request::StoredIbdRequest; pub use navigator_store::source_file::SourceFile; use navigator_store::{ - alignment, ancestry_result, artifact, biosample, biosample_project, chip_profile, consensus_painting, - consensus_archaic, consensus_archaic_segments, consensus_profile, consensus_roh, haplogroup_call, mtdna as mtdna_store, project, reconciliation as recon_store, - sequence_run, - source_file, str_profile, sync_history, sync_outbox, sync_state, variant_set, Store, StoreError, + alignment, ancestry_result, artifact, biosample, biosample_project, chip_profile, consensus_archaic, + consensus_archaic_segments, consensus_painting, consensus_profile, consensus_roh, haplogroup_call, + mtdna as mtdna_store, project, reconciliation as recon_store, sequence_run, source_file, str_profile, sync_history, + sync_outbox, sync_state, variant_set, Store, StoreError, }; use serde::de::DeserializeOwned; use serde::Serialize; @@ -832,9 +830,7 @@ fn tree_cache_path(file: &str) -> PathBuf { let dir = std::env::var("NAVIGATOR_TREE_DIR") .ok() .map(PathBuf::from) - .unwrap_or_else(|| { - navigator_domain::paths::decodingus_dir().join("trees") - }); + .unwrap_or_else(|| navigator_domain::paths::decodingus_dir().join("trees")); dir.join(file) } @@ -909,7 +905,11 @@ where // Highest weight wins; on a tie break by the allele itself so the pooled call is // deterministic (a `HashMap` iteration order otherwise picked the winner at random, // which flipped the placed terminal between runs over identical genotypes). - .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))) + .max_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then(a.0.cmp(&b.0)) + }) .map(|(v, _)| (k, v)) }) .collect() @@ -1426,10 +1426,10 @@ fn bundled_masks_dir() -> Option { let dir = exe.parent()?; [ dir.join("../Resources/masks"), // macOS .app/Contents/MacOS → ../Resources - dir.join("masks"), // Windows (alongside) / portable - dir.join("../lib/DUNavigator/masks"), // Linux .deb/AppImage usr/bin → usr/lib/ - dir.join("../share/DUNavigator/masks"), // Linux usr/share/ - dir.join("resources/masks"), // generic + dir.join("masks"), // Windows (alongside) / portable + dir.join("../lib/DUNavigator/masks"), // Linux .deb/AppImage usr/bin → usr/lib/ + dir.join("../share/DUNavigator/masks"), // Linux usr/share/ + dir.join("resources/masks"), // generic ] .into_iter() .find(|c| c.is_dir()) @@ -1461,9 +1461,9 @@ fn bundled_str_dir() -> Option { let exe = std::env::current_exe().ok()?; let dir = exe.parent()?; [ - dir.join("../Resources/str"), // macOS .app/Contents/MacOS → ../Resources - dir.join("str"), // Windows (alongside) / portable - dir.join("../lib/DUNavigator/str"), // Linux .deb/AppImage usr/bin → usr/lib/ + dir.join("../Resources/str"), // macOS .app/Contents/MacOS → ../Resources + dir.join("str"), // Windows (alongside) / portable + dir.join("../lib/DUNavigator/str"), // Linux .deb/AppImage usr/bin → usr/lib/ dir.join("../share/DUNavigator/str"), dir.join("resources/str"), ] @@ -2928,7 +2928,6 @@ fn ibd_panel_cache_kind() -> String { /// Cache kind for per-alignment archaic-panel genotypes. const ARCHAIC_PANEL_KIND: &str = "archaic_panel_genotypes"; - /// The archaic-panel genotype cache kind, salted with the panel asset's manifest sha256 exactly as /// [`ibd_panel_cache_kind`] is — the archaic panel's site list changes whenever its thresholds are /// recalibrated, and serving genotypes taken over an older site set would silently corrupt the count. @@ -3782,7 +3781,8 @@ mod placement_tests { let mut called = gvcf::CalledBases::default(); called.variant_bases.extend([(146, 'G'), (263, 'G'), (1000, 'A')]); called.callable.extend([146, 263, 750, 1000]); // 750 hom-ref → its reference base - // The reference carries the *derived* T at 750 (shared backbone the sample also has). + + // The reference carries the *derived* T at 750 (shared backbone the sample also has). let ref_base: HashMap = [(750, 'T')].into_iter().collect(); let calls = gvcf::assemble_calls(&called, &ref_base); assert_eq!( @@ -3870,14 +3870,18 @@ mod external_precedence_tests { .unwrap(); // No clobber: both rows survive under their distinct keys. - assert!(haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, &external_y_source_key(1)) - .await - .unwrap() - .is_some()); - assert!(haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, "aln:1") - .await - .unwrap() - .is_some()); + assert!( + haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, &external_y_source_key(1)) + .await + .unwrap() + .is_some() + ); + assert!( + haplogroup_call::get_one(app.store.pool(), bio.guid, DnaType::Y, "aln:1") + .await + .unwrap() + .is_some() + ); // Default policy prefers external → external terminal wins despite the walk's higher score. let c = app.haplogroup_consensus(bio.guid, DnaType::Y).await.unwrap().unwrap(); @@ -3927,9 +3931,17 @@ mod publish_tests { .await .unwrap(); // Exact yield → the standardized label's Gbases figure. - sequence_run::set_read_stats(app.store.pool(), run.id, Some(300_000_000), Some(150.0), None, None, Some(45_000_000_000)) - .await - .unwrap(); + sequence_run::set_read_stats( + app.store.pool(), + run.id, + Some(300_000_000), + Some(150.0), + None, + None, + Some(45_000_000_000), + ) + .await + .unwrap(); sequence_run::set_facility(app.store.pool(), run.id, "Dante Labs") .await .unwrap(); @@ -3939,7 +3951,10 @@ mod publish_tests { assert_eq!(value.get("instrumentId").and_then(|v| v.as_str()), Some("A00182")); // The known sequencing lab is published so the AppView can display it (its instrument→lab // map doesn't cover every serial, e.g. PacBio). - assert_eq!(value.get("sequencingFacility").and_then(|v| v.as_str()), Some("Dante Labs")); + assert_eq!( + value.get("sequencingFacility").and_then(|v| v.as_str()), + Some("Dante Labs") + ); // Read-profile fields backing the standardized label are published. assert_eq!(value.get("totalBases").and_then(|v| v.as_i64()), Some(45_000_000_000)); assert_eq!(value.get("readType").and_then(|v| v.as_str()), Some("SHORT")); @@ -3962,12 +3977,18 @@ mod publish_tests { app.add_external_id(b.guid, "PGP", "huF98AFD").await.unwrap(); let value = app.biosample_record("did:plc:test", b.guid).await.unwrap(); - let ids = value.get("externalIds").and_then(|v| v.as_array()).expect("externalIds present"); + let ids = value + .get("externalIds") + .and_then(|v| v.as_array()) + .expect("externalIds present"); let mut pairs: Vec<(String, String)> = ids .iter() .map(|e| { ( - e.get("namespace").and_then(|v| v.as_str()).unwrap_or_default().to_string(), + e.get("namespace") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), e.get("value").and_then(|v| v.as_str()).unwrap_or_default().to_string(), ) }) @@ -4191,7 +4212,9 @@ mod ibd_attest_tests { // The ledger adopts a conversation it never saw opened, so the completed exchange still // reads as one entry with its result attached rather than an orphan row. - app.mark_matching_exchanged(b.guid, &session, "exchange:r").await.unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:r") + .await + .unwrap(); let entries = app.matching_entries().await.unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].status, MatchingStatus::Exchanged); @@ -4212,9 +4235,13 @@ mod ibd_attest_tests { partner_did: "did:key:zC".into(), key: [0u8; 32], }; - app.mark_matching_exchanged(b.guid, &session, "exchange:f").await.unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:f") + .await + .unwrap(); - app.record_matching_failure("exchange:f", "relay timeout").await.unwrap(); + app.record_matching_failure("exchange:f", "relay timeout") + .await + .unwrap(); let e = app.matching_entry("exchange:f").await.unwrap(); assert_eq!(e.status, MatchingStatus::Failed); assert_eq!(e.last_error.as_deref(), Some("relay timeout")); @@ -4267,7 +4294,9 @@ mod ibd_attest_tests { app.record_ibd_exchange(b.guid, &session, "exchange:a", &result) .await .unwrap(); - app.mark_matching_exchanged(b.guid, &session, "exchange:a").await.unwrap(); + app.mark_matching_exchanged(b.guid, &session, "exchange:a") + .await + .unwrap(); // No sample handles (a direct request never carries them) → nothing to attest, no network. assert!(!app.attest_exchange_if_possible("exchange:a").await.unwrap()); @@ -4387,7 +4416,11 @@ mod ibd_federated_tests { assert_eq!(at(0.49), MatchStrength::Possible); assert_eq!(at(0.0), MatchStrength::Possible); // A missing score parses as 0.0, which must read as the weakest claim, never the strongest. - assert_eq!(at(f64::NAN), MatchStrength::Possible, "an unusable score must not overstate"); + assert_eq!( + at(f64::NAN), + MatchStrength::Possible, + "an unusable score must not overstate" + ); } } @@ -4538,10 +4571,16 @@ mod settings_tests { let prod = resolve_oauth_config(None); assert_eq!(prod.client_id(redirect), DEFAULT_OAUTH_CLIENT_ID); assert_eq!(prod.scope, OAUTH_SCOPE); - assert!(prod.scope.contains("transition:generic"), "publishing needs write scope"); + assert!( + prod.scope.contains("transition:generic"), + "publishing needs write scope" + ); // Blank is ignored → still the hosted default. - assert_eq!(resolve_oauth_config(Some(" ".into())).client_id(redirect), DEFAULT_OAUTH_CLIENT_ID); + assert_eq!( + resolve_oauth_config(Some(" ".into())).client_id(redirect), + DEFAULT_OAUTH_CLIENT_ID + ); // `loopback` selects the dev client (client_id derived from the loopback redirect). let dev = resolve_oauth_config(Some("loopback".into())); diff --git a/crates/navigator-app/src/publish.rs b/crates/navigator-app/src/publish.rs index e4437342..11085729 100644 --- a/crates/navigator-app/src/publish.rs +++ b/crates/navigator-app/src/publish.rs @@ -30,7 +30,9 @@ impl App { ); if is_wgs && navigator_analysis::sex::is_y_scoped( - cov.contig_coverage_stats.iter().map(|s| (s.contig.as_str(), s.num_reads)), + cov.contig_coverage_stats + .iter() + .map(|s| (s.contig.as_str(), s.num_reads)), ) { return Err(AppError::Conflict(format!( @@ -279,7 +281,9 @@ impl App { biosample_guid: SampleGuid, ) -> Result { let value = self.biosample_record(client.did(), biosample_guid).await?; - Ok(client.create_record(NS_BIOSAMPLE, value, Some(&biosample_rkey(biosample_guid))).await?) + Ok(client + .create_record(NS_BIOSAMPLE, value, Some(&biosample_rkey(biosample_guid))) + .await?) } /// Publish a sequence-run characterization using an explicit `client`. @@ -289,7 +293,9 @@ impl App { run: &SequenceRun, ) -> Result { let value = self.sequence_run_record(client.did(), run).await?; - Ok(client.create_record(NS_SEQUENCERUN, value, Some(&seqrun_rkey(run.id))).await?) + Ok(client + .create_record(NS_SEQUENCERUN, value, Some(&seqrun_rkey(run.id))) + .await?) } /// Publish an alignment's cached de-novo calls for `contig` using an explicit `client` @@ -433,7 +439,9 @@ mod tests { ], ..Default::default() }; - app.save_analysis(aln, "coverage", COVERAGE_VERSION, &wgs).await.unwrap(); + app.save_analysis(aln, "coverage", COVERAGE_VERSION, &wgs) + .await + .unwrap(); app.coverage_record("did:plc:test", aln) .await .expect("normal WGS coverage should publish"); diff --git a/crates/navigator-app/src/queries.rs b/crates/navigator-app/src/queries.rs index 8f8d5b8a..116124bc 100644 --- a/crates/navigator-app/src/queries.rs +++ b/crates/navigator-app/src/queries.rs @@ -82,11 +82,8 @@ impl App { /// [`haplogroup_terminals`](Self::haplogroup_terminals)). A subject is `Complete` once every /// alignment it owns has a full `coverage` artifact at the current version; otherwise `Pending`. /// Subjects with no alignments are omitted (the list shows no status for them). - pub async fn subject_analysis_status( - &self, - ) -> Result, AppError> { - let census = - artifact::analyzed_census(self.store.pool(), "coverage", coverage::COVERAGE_VERSION).await?; + pub async fn subject_analysis_status(&self) -> Result, AppError> { + let census = artifact::analyzed_census(self.store.pool(), "coverage", coverage::COVERAGE_VERSION).await?; Ok(census .into_iter() .map(|(guid, total, analyzed)| { @@ -191,8 +188,11 @@ impl App { // evidence (resolves sidecar-imported UNKNOWN-platform runs), then an optional file // rescan for long reads that need the read names to tell HiFi from CLR. if run.read_type.is_none() { - let inferred = infer_read_type_cheap(&run.platform_name, &run.test_type) - .or_else(|| metrics.as_ref().and_then(|(_, m)| read_type_from_mean_len(m.mean_read_length))); + let inferred = infer_read_type_cheap(&run.platform_name, &run.test_type).or_else(|| { + metrics + .as_ref() + .and_then(|(_, m)| read_type_from_mean_len(m.mean_read_length)) + }); match inferred { Some(rt) => { sequence_run::set_read_type(self.store.pool(), run.id, rt).await?; @@ -295,7 +295,10 @@ impl App { /// (ancient) breakdown here — a separate report — instead of the modern super-population one. pub async fn donor_ancestry(&self, biosample_guid: SampleGuid) -> Result, AppError> { let all = ancestry_result::for_biosample(self.store.pool(), biosample_guid).await?; - if let Some(c) = all.iter().find(|(id, r)| *id == CONSENSUS_SOURCE_ID && r.method == "ADMIXTURE") { + if let Some(c) = all + .iter() + .find(|(id, r)| *id == CONSENSUS_SOURCE_ID && r.method == "ADMIXTURE") + { return Ok(Some(c.clone())); } Ok(all @@ -434,7 +437,9 @@ impl App { metrics = artifacts.fresh(a.id, "read_metrics", "1"); } if sv_count.is_none() { - sv_count = artifacts.fresh::(a.id, "sv", "1").map(|s| s.sv_calls.len()); + sv_count = artifacts + .fresh::(a.id, "sv", "1") + .map(|s| s.sv_calls.len()); } } let sex = sex.map(|s| match s.inferred_sex { diff --git a/crates/navigator-app/src/sync.rs b/crates/navigator-app/src/sync.rs index 5729d69e..a6cbbee8 100644 --- a/crates/navigator-app/src/sync.rs +++ b/crates/navigator-app/src/sync.rs @@ -65,11 +65,12 @@ impl App { let did = self.require_account()?; // Accounted-for rkeys: everything tracked in sync_state for the alignment collection, plus // the deterministic key for every live local alignment (so a not-yet-drained one isn't culled). - let mut keep: std::collections::HashSet = sync_state::list_for_collection(self.store.pool(), &did, NS_ALIGNMENT) - .await? - .into_iter() - .map(|s| s.rkey) - .collect(); + let mut keep: std::collections::HashSet = + sync_state::list_for_collection(self.store.pool(), &did, NS_ALIGNMENT) + .await? + .into_iter() + .map(|s| s.rkey) + .collect(); for a in alignment::list_all(self.store.pool()).await? { keep.insert(alignment_rkey(a.id)); } diff --git a/crates/navigator-app/tests/app.rs b/crates/navigator-app/tests/app.rs index 27a528c3..04379c1d 100644 --- a/crates/navigator-app/tests/app.rs +++ b/crates/navigator-app/tests/app.rs @@ -952,7 +952,14 @@ async fn save_analysis_no_downgrade_keeps_the_fuller_result() { // No artifact yet → the sidecar write goes through. let wrote = app - .save_analysis_no_downgrade(aln, "coverage", "v1", &serde_json::json!({"m": 1}), "pipeline-sidecar", "partial") + .save_analysis_no_downgrade( + aln, + "coverage", + "v1", + &serde_json::json!({"m": 1}), + "pipeline-sidecar", + "partial", + ) .await .unwrap(); assert!(wrote, "first sidecar write with nothing present"); @@ -964,7 +971,14 @@ async fn save_analysis_no_downgrade_keeps_the_fuller_result() { // Reimport: a partial sidecar must NOT clobber the full deep walk. let wrote = app - .save_analysis_no_downgrade(aln, "coverage", "v1", &serde_json::json!({"m": 3}), "pipeline-sidecar", "partial") + .save_analysis_no_downgrade( + aln, + "coverage", + "v1", + &serde_json::json!({"m": 3}), + "pipeline-sidecar", + "partial", + ) .await .unwrap(); assert!(!wrote, "partial must not downgrade a full result"); @@ -1198,7 +1212,11 @@ async fn add_data_imports_completegenomics_master_var() { assert_eq!(sets.len(), 1); let set = &sets[0]; assert_eq!(set.reference_build.as_deref(), Some("GRCh37")); - assert_eq!(set.calls.len(), 3, "two SNP loci on chr1 + one on chrY; the no-ref span is dropped"); + assert_eq!( + set.calls.len(), + 3, + "two SNP loci on chr1 + one on chrY; the no-ref span is dropped" + ); let hom = set.calls.iter().find(|c| c.position == 21580).unwrap(); assert_eq!((hom.reference.as_str(), hom.alternate.as_str()), ("C", "T")); assert_eq!(hom.genotype.as_deref(), Some("1/1")); @@ -1472,7 +1490,8 @@ async fn diploid_alignment(app: &App) -> i64 { async fn publish_coverage_summary_requires_cached_coverage() { let app = app().await; let aln = diploid_alignment(&app).await; // has a BAM but no coverage run - // Bearer client is never reached — the missing-coverage check fails first. + + // Bearer client is never reached — the missing-coverage check fails first. let client = navigator_app::PdsClient::bearer(reqwest::Client::new(), "http://127.0.0.1:1", "did:plc:x", "tok"); let err = app.publish_coverage_summary(&client, aln).await; assert!( @@ -1681,13 +1700,22 @@ async fn reimport_under_different_project_name_reuses_subject() { }; let a = stage("a"); - let s1 = app.import_project_dir(&a, Some(reference.clone()), "t".into(), false).await.unwrap(); + let s1 = app + .import_project_dir(&a, Some(reference.clone()), "t".into(), false) + .await + .unwrap(); assert_eq!(s1.samples_created, 1); // Same sample, different folder name → a distinct project, but the SAME person. let b = stage("b"); - let s2 = app.import_project_dir(&b, Some(reference), "t".into(), false).await.unwrap(); - assert_ne!(s2.project.id, s1.project.id, "a different folder name is a different project"); + let s2 = app + .import_project_dir(&b, Some(reference), "t".into(), false) + .await + .unwrap(); + assert_ne!( + s2.project.id, s1.project.id, + "a different folder name is a different project" + ); assert_eq!(s2.samples_created, 0, "the subject is reused, not duplicated"); // Exactly one subject in the workspace, and it's a roster member of BOTH projects. @@ -1705,7 +1733,11 @@ async fn delete_project_detaches_members_and_keeps_subjects() { // not refuse ("N subjects still belong to it"). The subjects themselves survive. let app = app().await; let p = app - .create_project(NewProject { name: "P".into(), description: None, administrator: "t".into() }) + .create_project(NewProject { + name: "P".into(), + description: None, + administrator: "t".into(), + }) .await .unwrap(); let b = app.add_biosample(Some(p.id), "S1", None, None).await.unwrap(); @@ -1713,9 +1745,20 @@ async fn delete_project_detaches_members_and_keeps_subjects() { app.delete_project(p.id).await.unwrap(); - assert!(app.project_overview().await.unwrap().iter().all(|o| o.project.id != p.id), "project removed"); assert!( - app.list_all_biosamples().await.unwrap().iter().any(|x| x.guid == b.guid), + app.project_overview() + .await + .unwrap() + .iter() + .all(|o| o.project.id != p.id), + "project removed" + ); + assert!( + app.list_all_biosamples() + .await + .unwrap() + .iter() + .any(|x| x.guid == b.guid), "subject survives the project deletion" ); } @@ -1959,7 +2002,10 @@ async fn analyze_project_runs_coverage_and_attempts_y_per_sample() { .await .unwrap(); - let s = app.analyze_project(p.id, navigator_app::CancelToken::none()).await.unwrap(); + let s = app + .analyze_project(p.id, navigator_app::CancelToken::none()) + .await + .unwrap(); assert_eq!(s.samples, 1); assert_eq!(s.coverage_done, 1, "coverage computed on the CRAM"); // Y was attempted: recorded, or (here) errored on the chrM-only fixture lacking chrY. @@ -2826,8 +2872,16 @@ async fn branch_report_genotypes_the_mt_subtree_end_to_end() { // no-call, and the asymmetric SNV test would have mislabeled the empty allele as a clean SNV. let ins = row("41.1A"); assert_eq!(ins.state, CallState::NoCall); - assert!(ins.note.contains("indel/MNV"), "insertion must be flagged as an indel: {:?}", ins.note); - assert!(ins.note.contains("no call"), "and still surface the no-call: {:?}", ins.note); + assert!( + ins.note.contains("indel/MNV"), + "insertion must be flagged as an indel: {:?}", + ins.note + ); + assert!( + ins.note.contains("no call"), + "and still surface the no-call: {:?}", + ins.note + ); // The tallies match the rows (the insertion is a no-call). let (d, a, n) = report.counts(); @@ -2844,7 +2898,10 @@ async fn mt_alignment_pick_skips_a_y_only_run() { use navigator_app::DnaType; let app = app().await; - let b = app.add_biosample(None, "S-pick", None, Some("male".into())).await.unwrap(); + let b = app + .add_biosample(None, "S-pick", None, Some("male".into())) + .await + .unwrap(); // A Big-Y (Y-only) run, recorded first so it's a candidate for both pickers. let y_run = app @@ -2915,7 +2972,10 @@ async fn mt_alignment_pick_skips_a_y_only_run() { "Y still prefers the CHM13/pbmm2 Big-Y alignment" ); // Dispatch helper routes each DNA type to its picker. - assert_eq!(app.pick_alignment_for(b.guid, DnaType::Mt).await.unwrap(), Some(wgs_aln)); + assert_eq!( + app.pick_alignment_for(b.guid, DnaType::Mt).await.unwrap(), + Some(wgs_aln) + ); assert_eq!(app.pick_alignment_for(b.guid, DnaType::Y).await.unwrap(), Some(y_aln)); } @@ -2935,7 +2995,10 @@ async fn add_sample_dir_records_alignment_from_header_no_decode_and_is_idempoten std::fs::copy(fx.join("coverage.cram.crai"), dir.join("s.chm13.chrYM.cram.crai")).unwrap(); std::fs::write(dir.join("coverage.txt"), "#rname\tstartpos\tendpos\tnumreads\n").unwrap(); - let subject = app.add_biosample(None, "S-KIT", None, Some("male".into())).await.unwrap(); + let subject = app + .add_biosample(None, "S-KIT", None, Some("male".into())) + .await + .unwrap(); let s = app.add_sample_dir(subject.guid, &dir, false).await.unwrap(); assert_eq!(s.alignments_created, 1); @@ -3000,11 +3063,17 @@ async fn add_sample_dir_skips_called_vcf_when_gvcf_present() { std::fs::write(dir.join("gatk4/chrY.g.vcf.gz"), b"not-a-real-gvcf").unwrap(); std::fs::write(dir.join("gatk4/chrY.vcf.gz"), b"##fileformat=VCFv4.2\n").unwrap(); - let subject = app.add_biosample(None, "S-GVCF", None, Some("male".into())).await.unwrap(); + let subject = app + .add_biosample(None, "S-GVCF", None, Some("male".into())) + .await + .unwrap(); let s = app.add_sample_dir(subject.guid, &dir, true).await.unwrap(); assert_eq!(s.alignments_created, 1); - assert_eq!(s.variants_imported, 0, "called chrY.vcf.gz must be skipped when a GVCF is present"); + assert_eq!( + s.variants_imported, 0, + "called chrY.vcf.gz must be skipped when a GVCF is present" + ); assert!(s.sidecars_ingested, "the GVCF fast path was attempted"); assert_eq!(app.list_variant_sets(subject.guid).await.unwrap().len(), 0); diff --git a/crates/navigator-app/tests/mastervar_autosomal_real.rs b/crates/navigator-app/tests/mastervar_autosomal_real.rs index 86bf42cc..5578d10f 100644 --- a/crates/navigator-app/tests/mastervar_autosomal_real.rs +++ b/crates/navigator-app/tests/mastervar_autosomal_real.rs @@ -52,9 +52,15 @@ async fn mastervar_feeds_autosomal_and_ancestry() { profile.variants.len() ); for s in &profile.sources { - println!(" source: {} ({:?}) — {} sites", s.label, s.source_type, s.variant_count); + println!( + " source: {} ({:?}) — {} sites", + s.label, s.source_type, s.variant_count + ); } - assert!(!profile.sources.is_empty(), "the masterVar should be an autosomal source"); + assert!( + !profile.sources.is_empty(), + "the masterVar should be an autosomal source" + ); assert!( profile.variants.len() > 10_000, "a genome-wide source should densify to a large panel overlap, got {}", diff --git a/crates/navigator-domain/src/brief.rs b/crates/navigator-domain/src/brief.rs index 9eda4734..496bc6af 100644 --- a/crates/navigator-domain/src/brief.rs +++ b/crates/navigator-domain/src/brief.rs @@ -565,8 +565,14 @@ mod tests { #[test] fn age_rounding_is_friendly() { - assert_eq!(age_phrase(Lang::En, Some(4237)).unwrap(), "formed roughly 4,200 years ago"); - assert_eq!(age_phrase(Lang::En, Some(63500)).unwrap(), "formed roughly 64,000 years ago"); + assert_eq!( + age_phrase(Lang::En, Some(4237)).unwrap(), + "formed roughly 4,200 years ago" + ); + assert_eq!( + age_phrase(Lang::En, Some(63500)).unwrap(), + "formed roughly 64,000 years ago" + ); assert_eq!(age_phrase(Lang::En, Some(842)).unwrap(), "formed roughly 850 years ago"); assert_eq!(age_phrase(Lang::En, None), None); assert_eq!(age_phrase(Lang::En, Some(0)), None); @@ -574,7 +580,10 @@ mod tests { #[test] fn origin_phrasing() { - assert_eq!(origin_phrase(Lang::En, Some("the steppe")).unwrap(), "associated with the steppe"); + assert_eq!( + origin_phrase(Lang::En, Some("the steppe")).unwrap(), + "associated with the steppe" + ); assert_eq!(origin_phrase(Lang::En, None), None); assert_eq!(origin_phrase(Lang::En, Some(" ")), None); } @@ -598,7 +607,10 @@ mod tests { let mixed = roh_brief(Lang::En, RohPattern::Mixed, 0.05, 30, 150.0, 18.0); assert_eq!(mixed.pattern, "Mixed shared ancestry"); // No runs at all always reads as outbred, whatever the classifier says of an empty set. - assert_eq!(roh_brief(Lang::En, RohPattern::Mixed, 0.0, 0, 0.0, 0.0).pattern, "Outbred"); + assert_eq!( + roh_brief(Lang::En, RohPattern::Mixed, 0.0, 0, 0.0, 0.0).pattern, + "Outbred" + ); } #[test] @@ -664,8 +676,14 @@ mod tests { #[test] fn ancestry_summary_framing() { - assert_eq!(ancestry_summary(Lang::En, &[]), "Ancestry composition not yet estimated"); - assert_eq!(ancestry_summary(Lang::En, &[sp("European", 92.0)]), "Predominantly European"); + assert_eq!( + ancestry_summary(Lang::En, &[]), + "Ancestry composition not yet estimated" + ); + assert_eq!( + ancestry_summary(Lang::En, &[sp("European", 92.0)]), + "Predominantly European" + ); // Unsorted input is sorted by share. assert_eq!( ancestry_summary(Lang::En, &[sp("African", 30.0), sp("European", 70.0)]), diff --git a/crates/navigator-domain/src/consensus.rs b/crates/navigator-domain/src/consensus.rs index 69109c95..d34f6d42 100644 --- a/crates/navigator-domain/src/consensus.rs +++ b/crates/navigator-domain/src/consensus.rs @@ -896,9 +896,11 @@ mod tests { // Opposite-strand reads match via the complement (non-ambiguous A>C: comp T/G). assert_eq!(impute_state(Some('G'), "A", "C"), ConsensusState::Derived); // comp(G)=C=derived assert_eq!(impute_state(Some('T'), "A", "C"), ConsensusState::Ancestral); // comp(T)=A=ancestral + // Strand-ambiguous C/G: complement of derived G is ancestral C → keep literal only. assert_eq!(impute_state(Some('C'), "C", "G"), ConsensusState::Ancestral); assert_eq!(impute_state(Some('A'), "C", "G"), ConsensusState::NoCall); // genuine third allele + // No base → no call. assert_eq!(impute_state(None, "A", "G"), ConsensusState::NoCall); } @@ -909,7 +911,8 @@ mod tests { // can't evaluate it — must be no-call, not a false derived. assert_eq!(impute_state(Some('G'), "G", "GAGC"), ConsensusState::NoCall); // insertion assert_eq!(impute_state(Some('G'), "GAGC", "G"), ConsensusState::NoCall); // deletion - assert_eq!(impute_state(Some('A'), "AT", "GC"), ConsensusState::NoCall); // MNP + assert_eq!(impute_state(Some('A'), "AT", "GC"), ConsensusState::NoCall); + // MNP } #[test] @@ -937,8 +940,9 @@ mod tests { assert_eq!(v0[0].consensus_base.as_deref(), Some("T")); // Corrected polarity C>T → the same base is now Derived. - let polarity: BTreeMap = - [("PF1016".to_string(), ("C".to_string(), "T".to_string()))].into_iter().collect(); + let polarity: BTreeMap = [("PF1016".to_string(), ("C".to_string(), "T".to_string()))] + .into_iter() + .collect(); let (v1, _) = interpret(&observed, &polarity); assert_eq!(v1[0].consensus, ConsensusState::Derived); assert_eq!(v1[0].consensus_base.as_deref(), Some("T")); @@ -953,8 +957,16 @@ mod tests { // A/G alleles, and comp(T)=A is ancestral — so T is treated as an opposite-strand ancestral // read here). A cleaner third-allele case: strand-ambiguous A/T with a C read stays C. let observed = to_observed(&[ - ("a".into(), SourceType::WgsShortRead, vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)]), - ("b".into(), SourceType::WgsShortRead, vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)]), + ( + "a".into(), + SourceType::WgsShortRead, + vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)], + ), + ( + "b".into(), + SourceType::WgsShortRead, + vec![ConsensusObs::observed("S1", 1, "A", "T", Some('C'), true)], + ), ]); let (v, _) = interpret(&observed, &BTreeMap::new()); // A/T is strand-ambiguous, so a C read matches no allele and is kept as itself — the @@ -1241,7 +1253,8 @@ mod tests { assert_eq!(s.total, 2); assert_eq!(s.confirmed, 1); // rs1 (both hom-alt) assert_eq!(s.conflict, 1); // rs2 (0 vs 2) - // (1 confirmed − 0.5·1 conflict) / 2 = 0.25 + + // (1 confirmed − 0.5·1 conflict) / 2 = 0.25 assert!((s.overall_confidence - 0.25).abs() < 1e-9); } } diff --git a/crates/navigator-domain/src/filetype.rs b/crates/navigator-domain/src/filetype.rs index b1f91e3c..64dd841f 100644 --- a/crates/navigator-domain/src/filetype.rs +++ b/crates/navigator-domain/src/filetype.rs @@ -423,8 +423,14 @@ chr1\t246193\t.\tG\tA\t225\t.\tDP=29\tGT\t1/1 >locus\tploidy\tallele\tchromosome\tbegin\tend\tvarType\treference\talleleSeq\tvarScoreVAF\tvarScoreEAF\tvarQuality\thapLink\txRef\n\ 1\t2\tall\tchr1\t0\t10000\tno-ref\t=\t?\t\t\t\t\t\n"; // Both the raw name and a `.tsv.bz2` (extension isn't consulted for this format) detect. - assert_eq!(detect("var-GS00253-DNA_A01_200_37-ASM.tsv", head), DetectedData::CompleteGenomicsVar); - assert_eq!(detect("var-GS00253-DNA_A01_200_37-ASM.tsv.bz2", head), DetectedData::CompleteGenomicsVar); + assert_eq!( + detect("var-GS00253-DNA_A01_200_37-ASM.tsv", head), + DetectedData::CompleteGenomicsVar + ); + assert_eq!( + detect("var-GS00253-DNA_A01_200_37-ASM.tsv.bz2", head), + DetectedData::CompleteGenomicsVar + ); } #[test] diff --git a/crates/navigator-domain/src/ftdna_csv.rs b/crates/navigator-domain/src/ftdna_csv.rs index fd9e5346..6c6d0981 100644 --- a/crates/navigator-domain/src/ftdna_csv.rs +++ b/crates/navigator-domain/src/ftdna_csv.rs @@ -38,7 +38,9 @@ const CONTIG: &str = "chrY"; /// Split a CSV line into trimmed, unquoted cells. fn cells(line: &str) -> Vec { - line.split(',').map(|s| s.trim().trim_matches('"').to_string()).collect() + line.split(',') + .map(|s| s.trim().trim_matches('"').to_string()) + .collect() } /// Recognize the report flavor from a header row's columns, or `None` if it isn't an FTDNA Big Y @@ -71,8 +73,8 @@ pub fn parse(text: &str) -> Result<(FtdnaReport, Vec), String> { let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); let header = lines.next().ok_or("empty FTDNA variant CSV")?; let hcols = cells(header); - let report = report_of_header(&hcols) - .ok_or("not an FTDNA Big Y Named/Private Variants CSV (unrecognized header)")?; + let report = + report_of_header(&hcols).ok_or("not an FTDNA Big Y Named/Private Variants CSV (unrecognized header)")?; let col = |name: &str| hcols.iter().position(|c| c.eq_ignore_ascii_case(name)); let i_name = col("SNP_Name"); @@ -84,7 +86,9 @@ pub fn parse(text: &str) -> Result<(FtdnaReport, Vec), String> { for line in lines { let c = cells(line); let get = |i: usize| c.get(i).map(String::as_str).unwrap_or(""); - let Ok(position) = get(i_pos).parse::() else { continue }; + let Ok(position) = get(i_pos).parse::() else { + continue; + }; let name = i_name.map(|i| get(i).to_string()).filter(|s| !s.is_empty()); // Each row is a derived (positive) call: ref = ancestral, alt = derived, gt = "1". if let Some(call) = variants::snp_call(CONTIG, position, get(i_anc), get(i_der), name, Some("1".into())) { diff --git a/crates/navigator-domain/src/i18n.rs b/crates/navigator-domain/src/i18n.rs index 8f48e6b4..09ede66e 100644 --- a/crates/navigator-domain/src/i18n.rs +++ b/crates/navigator-domain/src/i18n.rs @@ -173,7 +173,11 @@ mod tests { fn brief_prose_is_translated_in_every_language() { let en = catalog(Lang::En); let brief_keys: Vec<&&str> = en.keys().filter(|k| k.starts_with("brief.")).collect(); - assert!(brief_keys.len() > 20, "expected the brief catalog, found {}", brief_keys.len()); + assert!( + brief_keys.len() > 20, + "expected the brief catalog, found {}", + brief_keys.len() + ); for lang in Lang::all() { for key in &brief_keys { assert!( diff --git a/crates/navigator-domain/src/identity.rs b/crates/navigator-domain/src/identity.rs index 8a25f295..9287a692 100644 --- a/crates/navigator-domain/src/identity.rs +++ b/crates/navigator-domain/src/identity.rs @@ -65,7 +65,14 @@ impl IdSource { pub fn is_public(source: &str) -> bool { matches!( source, - Self::PGP | Self::IGSR | Self::THOUSAND_GENOMES | Self::ENA | Self::SRA | Self::BIOSAMPLE | Self::HGDP | Self::SGDP + Self::PGP + | Self::IGSR + | Self::THOUSAND_GENOMES + | Self::ENA + | Self::SRA + | Self::BIOSAMPLE + | Self::HGDP + | Self::SGDP ) } } @@ -116,7 +123,10 @@ fn is_hgdp_name(s: &str) -> bool { /// Used both by [`catalog_ids_from_provenance`] and by the API-driven accession backfill. pub fn insdc_sample_namespace(acc: &str) -> Option<&'static str> { let u = acc.to_ascii_uppercase(); - let digits_after = |p: &str| u.strip_prefix(p).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())); + let digits_after = |p: &str| { + u.strip_prefix(p) + .is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) + }; if u.starts_with("SAMN") || u.starts_with("SAMEA") || u.starts_with("SAMD") { Some(IdSource::BIOSAMPLE) } else if digits_after("ERS") { diff --git a/crates/navigator-domain/src/lib.rs b/crates/navigator-domain/src/lib.rs index 678eff76..686bf717 100644 --- a/crates/navigator-domain/src/lib.rs +++ b/crates/navigator-domain/src/lib.rs @@ -16,8 +16,8 @@ pub mod chipprofile; pub mod consensus; pub mod contig; pub mod filetype; -pub mod ftdna_csv; pub mod ftdna; +pub mod ftdna_csv; pub mod i18n; pub mod identity; pub mod labs; diff --git a/crates/navigator-domain/src/llm_prompt.rs b/crates/navigator-domain/src/llm_prompt.rs index 818c1aea..a6d4984e 100644 --- a/crates/navigator-domain/src/llm_prompt.rs +++ b/crates/navigator-domain/src/llm_prompt.rs @@ -115,7 +115,10 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { s.push_str("\nAncestry:\n"); s.push_str(&format!("- summary: {}\n", a.summary_phrase)); for sp in a.super_populations.iter().filter(|p| p.percentage >= 0.5) { - s.push_str(&format!("- continental: {}: {:.1}%\n", sp.super_population, sp.percentage)); + s.push_str(&format!( + "- continental: {}: {:.1}%\n", + sp.super_population, sp.percentage + )); } // Fine/modern populations (present-day reference groups the person most resembles). Without // these the story leans entirely on the ancient components — this is the recent-ancestry layer. @@ -138,7 +141,10 @@ pub fn narrate_fact_sheet(b: &SubjectBrief) -> String { // Shared ancestry between the parents' lines (genealogical relatedness) — NOT a health signal. s.push_str("\nShared ancestry (runs of homozygosity):\n"); s.push_str(&format!("- pattern: {}\n", r.pattern)); - s.push_str(&format!("- F_ROH: {:.4} (share of DNA in long identical runs)\n", r.f_roh)); + s.push_str(&format!( + "- F_ROH: {:.4} (share of DNA in long identical runs)\n", + r.f_roh + )); s.push_str(&format!( "- {} run(s), about {:.0} Mb in total, longest {:.0} Mb\n", r.n_segments, r.total_mb, r.longest_mb @@ -269,7 +275,10 @@ mod tests { assert!(s.contains("Predominantly European")); assert!(s.contains("Western Hunter-Gatherer")); // Modern/fine populations must reach the model too — not only the ancient sources. - assert!(s.contains("closest modern population: British (55.0%)"), "fine pops missing: {s}"); + assert!( + s.contains("closest modern population: British (55.0%)"), + "fine pops missing: {s}" + ); assert!(s.contains("Iberian (12.0%)")); assert!(s.contains("high-quality (30× average depth)")); } diff --git a/crates/navigator-domain/src/paths.rs b/crates/navigator-domain/src/paths.rs index f1681928..e3c3b0f5 100644 --- a/crates/navigator-domain/src/paths.rs +++ b/crates/navigator-domain/src/paths.rs @@ -49,7 +49,11 @@ pub fn decodingus_dir() -> PathBuf { /// than the fallback. // Compiled on every platform so its precedence stays under test anywhere; only *called* on Windows. #[cfg_attr(not(windows), allow(dead_code))] -fn windows_home(userprofile: Option, homedrive: Option, homepath: Option) -> Option { +fn windows_home( + userprofile: Option, + homedrive: Option, + homepath: Option, +) -> Option { if let Some(p) = userprofile.filter(|p| !p.is_empty()) { return Some(PathBuf::from(p)); } diff --git a/crates/navigator-domain/src/reconciliation.rs b/crates/navigator-domain/src/reconciliation.rs index a52cabcd..0d8b038e 100644 --- a/crates/navigator-domain/src/reconciliation.rs +++ b/crates/navigator-domain/src/reconciliation.rs @@ -332,9 +332,10 @@ pub fn reconcile_with_provenance( lower.sort_unstable(); lower.dedup(); if !lower.is_empty() { - consensus - .warnings - .push(format!("lower-precedence sources place elsewhere: {} (external caller preferred)", lower.join(", "))); + consensus.warnings.push(format!( + "lower-precedence sources place elsewhere: {} (external caller preferred)", + lower.join(", ") + )); } Some(consensus) } @@ -436,7 +437,10 @@ mod tests { let external = call("gatk4 gvcf", 0.60, &["root", "R", "R-M269", "R-L21"]); let walk = call("cram walk", 0.95, &["root", "R", "R-M269", "R-L2"]); let c = reconcile_with_provenance( - &[(CallProvenance::External, external), (CallProvenance::NavigatorWalk, walk)], + &[ + (CallProvenance::External, external), + (CallProvenance::NavigatorWalk, walk), + ], true, ) .unwrap(); @@ -450,7 +454,10 @@ mod tests { let external = call("gatk4 gvcf", 0.60, &["root", "R", "R-M269"]); let walk = call("cram walk", 0.95, &["root", "R", "R-M269", "R-L21", "R-DF13"]); let c = reconcile_with_provenance( - &[(CallProvenance::External, external), (CallProvenance::NavigatorWalk, walk)], + &[ + (CallProvenance::External, external), + (CallProvenance::NavigatorWalk, walk), + ], false, ) .unwrap(); diff --git a/crates/navigator-domain/src/results_context.rs b/crates/navigator-domain/src/results_context.rs index 630e5e00..4807491b 100644 --- a/crates/navigator-domain/src/results_context.rs +++ b/crates/navigator-domain/src/results_context.rs @@ -137,7 +137,10 @@ impl SignalKind { fn sex_section(sex: &Option) -> Option { let sex = sex.as_ref()?; - Some(format!("\nGenetic sex:\n- {} ({} confidence)\n", sex.label, sex.confidence)) + Some(format!( + "\nGenetic sex:\n- {} ({} confidence)\n", + sex.label, sex.confidence + )) } fn ystr_section(ystr: &[YStrPanelFact]) -> Option { @@ -219,7 +222,10 @@ fn roh_section(brief: &SubjectBrief) -> Option { let r = brief.roh.as_ref()?; let mut s = String::from("\nShared ancestry (runs of homozygosity):\n"); s.push_str(&format!("- pattern: {}\n", r.pattern)); - s.push_str(&format!("- F_ROH: {:.4} (share of the genome in long identical runs)\n", r.f_roh)); + s.push_str(&format!( + "- F_ROH: {:.4} (share of the genome in long identical runs)\n", + r.f_roh + )); s.push_str(&format!( "- {} run(s), about {:.0} Mb in total, longest {:.0} Mb\n", r.n_segments, r.total_mb, r.longest_mb @@ -388,10 +394,20 @@ mod tests { confidence: "high".into(), }), ystr: vec![ - YStrPanelFact { panel: "Y-111".into(), markers: 111 }, - YStrPanelFact { panel: "Y-37".into(), markers: 37 }, + YStrPanelFact { + panel: "Y-111".into(), + markers: 111, + }, + YStrPanelFact { + panel: "Y-37".into(), + markers: 37, + }, ], - private_y: Some(PrivateYFact { novel_unique: 12, off_path: 3, structural: 2 }), + private_y: Some(PrivateYFact { + novel_unique: 12, + off_path: 3, + structural: 2, + }), mt_mutations: Some(MtMutationsFact { total: 41, hvr1: 5, @@ -460,7 +476,10 @@ mod tests { // The grounding must actively steer the model off the two framings the design forbids: // restating a count as a percent-Neanderthal, and reporting a Denisovan finding. - assert!(section.contains("not a percentage"), "must warn against percent framing"); + assert!( + section.contains("not a percentage"), + "must warn against percent framing" + ); assert!(section.contains("no Denisovan result is reported")); assert!(!mentions_health(§ion), "archaic must not read as a health result"); @@ -517,7 +536,11 @@ mod tests { #[test] fn structural_line_only_when_nonzero() { let mut ctx = full_context(); - ctx.private_y = Some(PrivateYFact { novel_unique: 4, off_path: 1, structural: 0 }); + ctx.private_y = Some(PrivateYFact { + novel_unique: 4, + off_path: 1, + structural: 0, + }); let s = results_fact_sheet(&ctx); assert!(!s.contains("structural/paralog-prone")); } diff --git a/crates/navigator-domain/src/testtype.rs b/crates/navigator-domain/src/testtype.rs index 9780a0e5..5f7d5e93 100644 --- a/crates/navigator-domain/src/testtype.rs +++ b/crates/navigator-domain/src/testtype.rs @@ -190,8 +190,25 @@ pub fn target_of(test_type: &str) -> Option { return Some(t.target); } let s = test_type.trim().to_ascii_lowercase(); - const Y: &[&str] = &["big y", "big-y", "bigy", "y elite", "y-elite", "y prime", "y-prime", "targeted y"]; - const MT: &[&str] = &["mt full", "mtfull", "mt-full", "full mtdna", "full mitochondrial", "mtdna", "targeted mt"]; + const Y: &[&str] = &[ + "big y", + "big-y", + "bigy", + "y elite", + "y-elite", + "y prime", + "y-prime", + "targeted y", + ]; + const MT: &[&str] = &[ + "mt full", + "mtfull", + "mt-full", + "full mtdna", + "full mitochondrial", + "mtdna", + "targeted mt", + ]; if Y.iter().any(|p| s.contains(p)) { Some(TargetType::YChromosome) } else if MT.iter().any(|p| s.contains(p)) { diff --git a/crates/navigator-domain/src/ysnp_dict.rs b/crates/navigator-domain/src/ysnp_dict.rs index 2a08aa2b..155a98fb 100644 --- a/crates/navigator-domain/src/ysnp_dict.rs +++ b/crates/navigator-domain/src/ysnp_dict.rs @@ -264,16 +264,30 @@ M269\tCTS10003 let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let hdr = "name\tbuild\tchrom\tposition\tstrand\tancestral\tderived\n"; - std::fs::write(dir.join("dictionary.tsv"), format!("{hdr}FullOnlySnp\ths1\tchrY\t123\t+\tA\tG\n")).unwrap(); - std::fs::write(dir.join("chromo2-panel.tsv"), format!("{hdr}PanelOnlySnp\ths1\tchrY\t456\t+\tA\tG\n")).unwrap(); + std::fs::write( + dir.join("dictionary.tsv"), + format!("{hdr}FullOnlySnp\ths1\tchrY\t123\t+\tA\tG\n"), + ) + .unwrap(); + std::fs::write( + dir.join("chromo2-panel.tsv"), + format!("{hdr}PanelOnlySnp\ths1\tchrY\t456\t+\tA\tG\n"), + ) + .unwrap(); let d = YsnpDictionary::load(&dir).unwrap(); assert!(d.resolve("FullOnlySnp", "hs1").is_some(), "loaded the full catalog"); - assert!(d.resolve("PanelOnlySnp", "hs1").is_none(), "did not load the chromo2 panel"); + assert!( + d.resolve("PanelOnlySnp", "hs1").is_none(), + "did not load the chromo2 panel" + ); std::fs::remove_file(dir.join("dictionary.tsv")).unwrap(); let d2 = YsnpDictionary::load(&dir).unwrap(); - assert!(d2.resolve("PanelOnlySnp", "hs1").is_some(), "fell back to the chromo2 panel"); + assert!( + d2.resolve("PanelOnlySnp", "hs1").is_some(), + "fell back to the chromo2 panel" + ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/navigator-panelbuild/examples/ascertain_chip.rs b/crates/navigator-panelbuild/examples/ascertain_chip.rs index 0768860b..6816720e 100644 --- a/crates/navigator-panelbuild/examples/ascertain_chip.rs +++ b/crates/navigator-panelbuild/examples/ascertain_chip.rs @@ -10,7 +10,9 @@ use std::io::{BufRead, BufReader}; fn main() -> anyhow::Result<()> { let mut args = std::env::args().skip(1); - let ancient_path = args.next().expect("usage: ascertain_chip "); + let ancient_path = args + .next() + .expect("usage: ascertain_chip "); let ibd_path = args.next().expect("ibd.bin"); let out_path = args.next().expect("out.bin"); let chip_files: Vec = args.collect(); @@ -44,7 +46,9 @@ fn main() -> anyhow::Result<()> { let mut ancient = AncestryPanel::from_bytes(&std::fs::read(&ancient_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; let before = ancient.sites.len(); - ancient.sites.retain(|s| chip_loci.contains(&(s.contig.clone(), s.position))); + ancient + .sites + .retain(|s| chip_loci.contains(&(s.contig.clone(), s.position))); let after = ancient.sites.len(); std::fs::write(&out_path, ancient.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; diff --git a/crates/navigator-panelbuild/examples/ascertainment.rs b/crates/navigator-panelbuild/examples/ascertainment.rs index e469110c..a367b1b3 100644 --- a/crates/navigator-panelbuild/examples/ascertainment.rs +++ b/crates/navigator-panelbuild/examples/ascertainment.rs @@ -7,16 +7,28 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let ancient_path = std::env::args().nth(1).expect("usage: ascertainment "); - let super_path = std::env::args().nth(2).expect("usage: ascertainment "); + let ancient_path = std::env::args() + .nth(1) + .expect("usage: ascertainment "); + let super_path = std::env::args() + .nth(2) + .expect("usage: ascertainment "); let ancient = AncestryPanel::from_bytes(&std::fs::read(&ancient_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; let sup = AncestryPanel::from_bytes(&std::fs::read(&super_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; - let eur = sup.populations.iter().position(|p| p == "EUR").expect("super panel has no EUR"); + let eur = sup + .populations + .iter() + .position(|p| p == "EUR") + .expect("super panel has no EUR"); let eur_maf: HashMap<(String, i64), f64> = sup .sites .iter() - .filter_map(|s| s.freqs.get(eur).map(|&f| ((s.contig.clone(), s.position), (f as f64).min(1.0 - f as f64)))) + .filter_map(|s| { + s.freqs + .get(eur) + .map(|&f| ((s.contig.clone(), s.position), (f as f64).min(1.0 - f as f64))) + }) .collect(); // Bin edges on EUR MAF. Last bin captures the "common" (chip-like) sites. @@ -30,7 +42,9 @@ fn main() -> anyhow::Result<()> { let mut joined = 0usize; for s in &ancient.sites { - let Some(&m) = eur_maf.get(&(s.contig.clone(), s.position)) else { continue }; + let Some(&m) = eur_maf.get(&(s.contig.clone(), s.position)) else { + continue; + }; joined += 1; let b = bin_of(m); n[b] += 1; diff --git a/crates/navigator-panelbuild/examples/check_liftover.rs b/crates/navigator-panelbuild/examples/check_liftover.rs index 6453fc7e..49ce6e3c 100644 --- a/crates/navigator-panelbuild/examples/check_liftover.rs +++ b/crates/navigator-panelbuild/examples/check_liftover.rs @@ -14,11 +14,16 @@ fn base_at( ) -> Option { let region: Region = format!("{contig}:{pos}-{pos}").parse().ok()?; let rec = reader.query(®ion).ok()?; - rec.sequence().as_ref().first().map(|&b| (b as char).to_ascii_uppercase()) + rec.sequence() + .as_ref() + .first() + .map(|&b| (b as char).to_ascii_uppercase()) } fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: check_liftover "); + let panel_path = std::env::args() + .nth(1) + .expect("usage: check_liftover "); let chm13_fa = std::env::args().nth(2).expect("chm13.fa"); let grch38_fa = std::env::args().nth(3).expect("grch38.fa"); let panel = IbdPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -60,8 +65,16 @@ fn main() -> anyhow::Result<()> { if examples.len() < 12 { examples.push(format!( "{} chm13 {}:{} {}/{} -> grch38 {}:{} REF={} ALT={} genome={}", - s.rsid, s.chm13.contig, s.chm13.position, s.chm13.reference, s.chm13.alternate, - l.contig, l.position, l.reference, l.alternate, b + s.rsid, + s.chm13.contig, + s.chm13.position, + s.chm13.reference, + s.chm13.alternate, + l.contig, + l.position, + l.reference, + l.alternate, + b )); } } @@ -72,11 +85,19 @@ fn main() -> anyhow::Result<()> { println!("sampled every {step}th of {} sites\n", panel.sites.len()); println!( "CHM13 control : genome base == panel REF|ALT at {}/{} ({:.1}%)", - chm_ok, chm_n, 100.0 * chm_ok as f64 / chm_n.max(1) as f64 + chm_ok, + chm_n, + 100.0 * chm_ok as f64 / chm_n.max(1) as f64 ); println!("GRCh38 locus : n={g38_n}"); - println!(" genome == build REF : {g38_ref} ({:.1}%)", 100.0 * g38_ref as f64 / g38_n.max(1) as f64); - println!(" genome == build ALT : {g38_alt} ({:.1}%)", 100.0 * g38_alt as f64 / g38_n.max(1) as f64); + println!( + " genome == build REF : {g38_ref} ({:.1}%)", + 100.0 * g38_ref as f64 / g38_n.max(1) as f64 + ); + println!( + " genome == build ALT : {g38_alt} ({:.1}%)", + 100.0 * g38_alt as f64 / g38_n.max(1) as f64 + ); println!( " genome == NEITHER : {g38_other} ({:.1}%) <- wrong coordinate", 100.0 * g38_other as f64 / g38_n.max(1) as f64 diff --git a/crates/navigator-panelbuild/examples/filter_maf.rs b/crates/navigator-panelbuild/examples/filter_maf.rs index b2a64621..868363bc 100644 --- a/crates/navigator-panelbuild/examples/filter_maf.rs +++ b/crates/navigator-panelbuild/examples/filter_maf.rs @@ -6,7 +6,9 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let a = std::env::args().nth(1).expect("usage: filter_maf "); + let a = std::env::args() + .nth(1) + .expect("usage: filter_maf "); let s = std::env::args().nth(2).expect("super.bin"); let t: f64 = std::env::args().nth(3).expect("min_maf").parse()?; let out = std::env::args().nth(4).expect("out.bin"); @@ -17,11 +19,17 @@ fn main() -> anyhow::Result<()> { let maf: HashMap<(String, i64), f64> = sup .sites .iter() - .filter_map(|x| x.freqs.get(eur).map(|&f| ((x.contig.clone(), x.position), (f as f64).min(1.0 - f as f64)))) + .filter_map(|x| { + x.freqs + .get(eur) + .map(|&f| ((x.contig.clone(), x.position), (f as f64).min(1.0 - f as f64))) + }) .collect(); let before = ancient.sites.len(); - ancient.sites.retain(|x| maf.get(&(x.contig.clone(), x.position)).is_some_and(|&m| m >= t)); + ancient + .sites + .retain(|x| maf.get(&(x.contig.clone(), x.position)).is_some_and(|&m| m >= t)); let after = ancient.sites.len(); std::fs::write(&out, ancient.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; println!("min_maf={t}: kept {after}/{before} sites -> {out}"); diff --git a/crates/navigator-panelbuild/examples/filter_sites.rs b/crates/navigator-panelbuild/examples/filter_sites.rs index 308800bd..cce985d6 100644 --- a/crates/navigator-panelbuild/examples/filter_sites.rs +++ b/crates/navigator-panelbuild/examples/filter_sites.rs @@ -7,7 +7,9 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashSet; fn main() -> anyhow::Result<()> { - let ancient = std::env::args().nth(1).expect("usage: filter_sites "); + let ancient = std::env::args() + .nth(1) + .expect("usage: filter_sites "); let sites_tsv = std::env::args().nth(2).expect("sites.tsv"); let out = std::env::args().nth(3).expect("out.bin"); let keep: HashSet<(String, i64)> = std::fs::read_to_string(&sites_tsv)? @@ -25,6 +27,9 @@ fn main() -> anyhow::Result<()> { panel.sites.retain(|s| keep.contains(&(s.contig.clone(), s.position))); let after = panel.sites.len(); std::fs::write(&out, panel.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; - println!("kept {after}/{before} sites (ascertainment set {}) -> {out}", keep.len()); + println!( + "kept {after}/{before} sites (ascertainment set {}) -> {out}", + keep.len() + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/filter_tv.rs b/crates/navigator-panelbuild/examples/filter_tv.rs index f16bba59..edf5d4ae 100644 --- a/crates/navigator-panelbuild/examples/filter_tv.rs +++ b/crates/navigator-panelbuild/examples/filter_tv.rs @@ -8,13 +8,20 @@ fn is_transition(r: char, a: char) -> bool { } fn main() -> anyhow::Result<()> { - let path = std::env::args().nth(1).expect("usage: filter_tv "); + let path = std::env::args() + .nth(1) + .expect("usage: filter_tv "); let out = std::env::args().nth(2).expect("out.bin"); let mut panel = AncestryPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| anyhow::anyhow!("{e}"))?; let before = panel.sites.len(); - panel.sites.retain(|s| !is_transition(s.reference_allele, s.alternate_allele)); + panel + .sites + .retain(|s| !is_transition(s.reference_allele, s.alternate_allele)); let after = panel.sites.len(); std::fs::write(&out, panel.to_bytes().map_err(|e| anyhow::anyhow!("{e}"))?)?; - println!("transversions only: kept {after}/{before} sites ({:.1}%) -> {out}", 100.0 * after as f64 / before as f64); + println!( + "transversions only: kept {after}/{before} sites ({:.1}%) -> {out}", + 100.0 * after as f64 / before as f64 + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/genotype_bed.rs b/crates/navigator-panelbuild/examples/genotype_bed.rs index d44ae998..eae97854 100644 --- a/crates/navigator-panelbuild/examples/genotype_bed.rs +++ b/crates/navigator-panelbuild/examples/genotype_bed.rs @@ -7,7 +7,9 @@ use std::io::{BufWriter, Write}; use std::path::PathBuf; fn main() -> anyhow::Result<()> { - let bed = std::env::args().nth(1).expect("usage: genotype_bed [ref.fa]"); + let bed = std::env::args() + .nth(1) + .expect("usage: genotype_bed [ref.fa]"); let bam = PathBuf::from(std::env::args().nth(2).expect("bam")); let out = std::env::args().nth(3).expect("out.tsv"); let reference = std::env::args().nth(4).map(PathBuf::from); @@ -25,7 +27,11 @@ fn main() -> anyhow::Result<()> { let contig = f[0].to_string(); let pos: i64 = f[2].parse()?; // BED end = 1-based position let name: Vec<&str> = f[3].split('|').collect(); - let (rsid, r, a) = (name[0], name.get(1).copied().unwrap_or("N"), name.get(2).copied().unwrap_or("N")); + let (rsid, r, a) = ( + name[0], + name.get(1).copied().unwrap_or("N"), + name.get(2).copied().unwrap_or("N"), + ); rsids.push(rsid.to_string()); sites.push(Site { name: rsid.to_string(), @@ -37,7 +43,15 @@ fn main() -> anyhow::Result<()> { } eprintln!("genotyping {} sites from {} ...", sites.len(), bam.display()); let params = HaploidCallerParams::default(); - let gts = genotype_sites_all_contigs(&bam, &sites, 2, ¶ms, reference.as_deref(), &navigator_analysis::CancelToken::none()).map_err(|e| anyhow::anyhow!("{e}"))?; + let gts = genotype_sites_all_contigs( + &bam, + &sites, + 2, + ¶ms, + reference.as_deref(), + &navigator_analysis::CancelToken::none(), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; // genotype_sites_all_contigs returns genotypes REORDERED (per-contig), so we must key each // returned genotype to its rsID by (contig,position) — NOT by input order. Zipping with `rsids` diff --git a/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs b/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs index 2dca7cfb..50cf14b1 100644 --- a/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs +++ b/crates/navigator-panelbuild/examples/ibd_qpadm_orient.rs @@ -3,12 +3,33 @@ use navigator_analysis::ancestry::AncestryPanel; use navigator_analysis::ibd_panel::IbdPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let ibd = IbdPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; - let qp = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; - let m: HashMap<(String,i64),(char,char)> = ibd.sites.iter().map(|s| ((s.chm13.contig.clone(), s.chm13.position),(s.chm13.reference,s.chm13.alternate))).collect(); - let (mut ov, mut same, mut swap, mut other)=(0,0,0,0); - for s in &qp.sites { if let Some(&(r,a))=m.get(&(s.contig.clone(),s.position)) { ov+=1; - if (s.reference_allele,s.alternate_allele)==(r,a){same+=1} else if (s.reference_allele,s.alternate_allele)==(a,r){swap+=1} else {other+=1} } } + let ibd = + IbdPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; + let qp = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let m: HashMap<(String, i64), (char, char)> = ibd + .sites + .iter() + .map(|s| { + ( + (s.chm13.contig.clone(), s.chm13.position), + (s.chm13.reference, s.chm13.alternate), + ) + }) + .collect(); + let (mut ov, mut same, mut swap, mut other) = (0, 0, 0, 0); + for s in &qp.sites { + if let Some(&(r, a)) = m.get(&(s.contig.clone(), s.position)) { + ov += 1; + if (s.reference_allele, s.alternate_allele) == (r, a) { + same += 1 + } else if (s.reference_allele, s.alternate_allele) == (a, r) { + swap += 1 + } else { + other += 1 + } + } + } println!("overlap {ov}: same {same}, swapped {swap}, other {other}"); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/inspect_panel.rs b/crates/navigator-panelbuild/examples/inspect_panel.rs index 9daa317c..354a4598 100644 --- a/crates/navigator-panelbuild/examples/inspect_panel.rs +++ b/crates/navigator-panelbuild/examples/inspect_panel.rs @@ -5,7 +5,12 @@ fn main() -> anyhow::Result<()> { let path = std::env::args().nth(1).expect("usage: inspect_panel "); let bytes = std::fs::read(&path)?; let panel = AncestryPanel::from_bytes(&bytes).map_err(|e| anyhow::anyhow!("{e}"))?; - println!("build={} sites={} pops={}", panel.build, panel.sites.len(), panel.populations.len()); + println!( + "build={} sites={} pops={}", + panel.build, + panel.sites.len(), + panel.populations.len() + ); println!("populations: {:?}", panel.populations); let k = panel.populations.len(); @@ -38,7 +43,9 @@ fn main() -> anyhow::Result<()> { } // Pairwise Nei Fst between a few populations of interest. - let want = ["WHG", "ANF", "Steppe", "EHG", "CHG", "Iran_N", "GBR", "CEU", "TSI", "YRI", "Han"]; + let want = [ + "WHG", "ANF", "Steppe", "EHG", "CHG", "Iran_N", "GBR", "CEU", "TSI", "YRI", "Han", + ]; let idx: Vec<(usize, &str)> = want .iter() .filter_map(|w| panel.populations.iter().position(|p| p == w).map(|i| (i, *w))) diff --git a/crates/navigator-panelbuild/examples/overlap.rs b/crates/navigator-panelbuild/examples/overlap.rs index 8bb6bfe8..7c82786a 100644 --- a/crates/navigator-panelbuild/examples/overlap.rs +++ b/crates/navigator-panelbuild/examples/overlap.rs @@ -9,7 +9,13 @@ fn main() -> anyhow::Result<()> { println!("{} sites={} pops={:?}", a[0], p1.sites.len(), p1.populations); println!("{} sites={} pops={:?}", a[1], p2.sites.len(), p2.populations); println!("overlap = {}", s1.intersection(&s2).count()); - println!("p1 sample contig: {:?}", p1.sites.first().map(|s| (&s.contig, s.position))); - println!("p2 sample contig: {:?}", p2.sites.first().map(|s| (&s.contig, s.position))); + println!( + "p1 sample contig: {:?}", + p1.sites.first().map(|s| (&s.contig, s.position)) + ); + println!( + "p2 sample contig: {:?}", + p2.sites.first().map(|s| (&s.contig, s.position)) + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/panel_overlap.rs b/crates/navigator-panelbuild/examples/panel_overlap.rs index 02cbc51a..625c8187 100644 --- a/crates/navigator-panelbuild/examples/panel_overlap.rs +++ b/crates/navigator-panelbuild/examples/panel_overlap.rs @@ -4,8 +4,10 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let a = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; - let b = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?).map_err(|e| anyhow::anyhow!("{e}"))?; + let a = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(1).unwrap())?) + .map_err(|e| anyhow::anyhow!("{e}"))?; + let b = AncestryPanel::from_bytes(&std::fs::read(std::env::args().nth(2).unwrap())?) + .map_err(|e| anyhow::anyhow!("{e}"))?; let bm: HashMap<(String, i64), (char, char)> = b .sites .iter() diff --git a/crates/navigator-panelbuild/examples/phaploid_fit.rs b/crates/navigator-panelbuild/examples/phaploid_fit.rs index b8d603e4..776da617 100644 --- a/crates/navigator-panelbuild/examples/phaploid_fit.rs +++ b/crates/navigator-panelbuild/examples/phaploid_fit.rs @@ -30,7 +30,12 @@ fn draw_alt(contig: &str, pos: i64, ref_d: u32, alt_d: u32) -> Option { fn fit(label: &str, gts: &[SiteGenotype], panel: &AncestryPanel) { match ancient_admixture_fit(gts, panel, "chm13v2.0") { Some(r) => { - let get = |c: &str| r.components.iter().find(|x| x.population_code == c).map_or(0.0, |x| x.percentage); + let get = |c: &str| { + r.components + .iter() + .find(|x| x.population_code == c) + .map_or(0.0, |x| x.percentage) + }; println!( "{:<26} {:>6} WHG {:>5.1} ANF {:>5.1} Steppe {:>5.1} disp {:>5.2}", label, @@ -46,7 +51,9 @@ fn fit(label: &str, gts: &[SiteGenotype], panel: &AncestryPanel) { } fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: phaploid_fit [ref.fa]"); + let panel_path = std::env::args() + .nth(1) + .expect("usage: phaploid_fit [ref.fa]"); let bam = PathBuf::from(std::env::args().nth(2).expect("bam")); let reference = std::env::args().nth(3).map(PathBuf::from); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -65,7 +72,15 @@ fn main() -> anyhow::Result<()> { eprintln!("genotyping {} ancient sites from {} ...", sites.len(), bam.display()); let params = HaploidCallerParams::default(); - let gts = genotype_sites_all_contigs(&bam, &sites, 2, ¶ms, reference.as_deref(), &navigator_analysis::CancelToken::none()).map_err(|e| anyhow::anyhow!("{e}"))?; + let gts = genotype_sites_all_contigs( + &bam, + &sites, + 2, + ¶ms, + reference.as_deref(), + &navigator_analysis::CancelToken::none(), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; let called = gts.iter().filter(|g| g.dosage >= 0).count(); eprintln!("genotyped: {} sites called (of {})", called, gts.len()); diff --git a/crates/navigator-panelbuild/examples/polarity.rs b/crates/navigator-panelbuild/examples/polarity.rs index 70e2d294..58a5fa31 100644 --- a/crates/navigator-panelbuild/examples/polarity.rs +++ b/crates/navigator-panelbuild/examples/polarity.rs @@ -6,7 +6,9 @@ use navigator_analysis::ancestry::AncestryPanel; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let a = std::env::args().nth(1).expect("usage: polarity "); + let a = std::env::args() + .nth(1) + .expect("usage: polarity "); let s = std::env::args().nth(2).expect("super.bin"); let ancient = AncestryPanel::from_bytes(&std::fs::read(&a)?).map_err(|e| anyhow::anyhow!("{e}"))?; let sup = AncestryPanel::from_bytes(&std::fs::read(&s)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -17,9 +19,12 @@ fn main() -> anyhow::Result<()> { .sites .iter() .filter_map(|x| { - x.freqs - .get(eur) - .map(|&f| ((x.contig.clone(), x.position), (x.reference_allele, x.alternate_allele, (f as f64).min(1.0 - f as f64)))) + x.freqs.get(eur).map(|&f| { + ( + (x.contig.clone(), x.position), + (x.reference_allele, x.alternate_allele, (f as f64).min(1.0 - f as f64)), + ) + }) }) .collect(); @@ -29,7 +34,9 @@ fn main() -> anyhow::Result<()> { let (mut aligned, mut swapped, mut other) = (vec![0usize; nb], vec![0usize; nb], vec![0usize; nb]); for x in &ancient.sites { - let Some(&(sr, sa, m)) = sup_idx.get(&(x.contig.clone(), x.position)) else { continue }; + let Some(&(sr, sa, m)) = sup_idx.get(&(x.contig.clone(), x.position)) else { + continue; + }; let b = bin(m); let (ar, aa) = (x.reference_allele, x.alternate_allele); if ar == sr && aa == sa { @@ -41,7 +48,10 @@ fn main() -> anyhow::Result<()> { } } - println!("{:<14}{:>8}{:>9}{:>9}{:>9}", "EUR MAF bin", "aligned", "SWAPPED", "other", "%swap"); + println!( + "{:<14}{:>8}{:>9}{:>9}{:>9}", + "EUR MAF bin", "aligned", "SWAPPED", "other", "%swap" + ); for b in 0..nb { let tot = (aligned[b] + swapped[b] + other[b]).max(1); println!( @@ -57,6 +67,9 @@ fn main() -> anyhow::Result<()> { let ta: usize = aligned.iter().sum(); let ts: usize = swapped.iter().sum(); let to: usize = other.iter().sum(); - println!("\ntotal: aligned={ta} swapped={ts} other={to} ({:.1}% swapped)", 100.0 * ts as f64 / (ta + ts + to).max(1) as f64); + println!( + "\ntotal: aligned={ta} swapped={ts} other={to} ({:.1}% swapped)", + 100.0 * ts as f64 / (ta + ts + to).max(1) as f64 + ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/qpadm_check.rs b/crates/navigator-panelbuild/examples/qpadm_check.rs index d7739b35..c72be071 100644 --- a/crates/navigator-panelbuild/examples/qpadm_check.rs +++ b/crates/navigator-panelbuild/examples/qpadm_check.rs @@ -11,7 +11,9 @@ use navigator_analysis::caller::{genotype_sites_all_contigs, HaploidCallerParams use std::path::PathBuf; fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: qpadm_check [ref.fa]"); + let panel_path = std::env::args() + .nth(1) + .expect("usage: qpadm_check [ref.fa]"); let bam = PathBuf::from(std::env::args().nth(2).expect("bam")); let reference = std::env::args().nth(3).map(PathBuf::from); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -20,14 +22,23 @@ fn main() -> anyhow::Result<()> { let src_codes = ["WHG", "EEF", "Steppe"]; let sources: Vec = src_codes .iter() - .map(|c| panel.populations.iter().position(|p| p == c).unwrap_or_else(|| panic!("panel missing source {c}"))) + .map(|c| { + panel + .populations + .iter() + .position(|p| p == c) + .unwrap_or_else(|| panic!("panel missing source {c}")) + }) .collect(); let outgroups: Vec = (0..panel.populations.len()).filter(|i| !sources.contains(i)).collect(); eprintln!( "panel: {} sites, sources {:?}, outgroups {:?}", panel.sites.len(), src_codes, - outgroups.iter().map(|&i| panel.populations[i].as_str()).collect::>() + outgroups + .iter() + .map(|&i| panel.populations[i].as_str()) + .collect::>() ); let sites: Vec = panel @@ -44,7 +55,15 @@ fn main() -> anyhow::Result<()> { eprintln!("genotyping {} sites from {} ...", sites.len(), bam.display()); let params = HaploidCallerParams::default(); - let gts = genotype_sites_all_contigs(&bam, &sites, 2, ¶ms, reference.as_deref(), &navigator_analysis::CancelToken::none()).map_err(|e| anyhow::anyhow!("{e}"))?; + let gts = genotype_sites_all_contigs( + &bam, + &sites, + 2, + ¶ms, + reference.as_deref(), + &navigator_analysis::CancelToken::none(), + ) + .map_err(|e| anyhow::anyhow!("{e}"))?; let called = gts.iter().filter(|g| g.dosage >= 0).count(); eprintln!("genotyped: {called} of {} sites called", gts.len()); @@ -52,14 +71,25 @@ fn main() -> anyhow::Result<()> { .ok_or_else(|| anyhow::anyhow!("qpadm_fit returned None (too few sites/blocks or singular system)"))?; println!("\n(reference: chip ~58% Steppe · old frequency-EM on WGS ~80% Steppe · NW-Eur band 40–55)\n"); - println!("sites {} blocks {} dof {} chi2 {:.2} p {:.4}", fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value); + println!( + "sites {} blocks {} dof {} chi2 {:.2} p {:.4}", + fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value + ); for (code, i) in src_codes.iter().zip(0..) { - println!(" {code:<8} {:>6.1} % (SE {:.1})", fit.weights[i] * 100.0, fit.std_errors[i] * 100.0); + println!( + " {code:<8} {:>6.1} % (SE {:.1})", + fit.weights[i] * 100.0, + fit.std_errors[i] * 100.0 + ); } println!( "\nmodel {} at p=0.05; weights {}", if fit.p_value >= 0.05 { "ACCEPTED" } else { "REJECTED" }, - if fit.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE (outside [0,1])" } + if fit.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE (outside [0,1])" + } ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs b/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs index a67d6fa3..6f77f409 100644 --- a/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs +++ b/crates/navigator-panelbuild/examples/qpadm_from_tsv.rs @@ -10,7 +10,9 @@ use navigator_analysis::caller::SiteGenotype; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: qpadm_from_tsv "); + let panel_path = std::env::args() + .nth(1) + .expect("usage: qpadm_from_tsv "); let tsv = std::env::args().nth(2).expect("dosage.tsv"); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -18,8 +20,12 @@ fn main() -> anyhow::Result<()> { let mut dosage: HashMap<(String, i64), i32> = HashMap::new(); for line in std::fs::read_to_string(&tsv)?.lines() { let mut it = line.split('\t'); - let (Some(c), Some(p), Some(d)) = (it.next(), it.next(), it.next()) else { continue }; - let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { continue }; + let (Some(c), Some(p), Some(d)) = (it.next(), it.next(), it.next()) else { + continue; + }; + let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { + continue; + }; dosage.insert((c.to_string(), p), d); } @@ -46,26 +52,44 @@ fn main() -> anyhow::Result<()> { }) .collect(); let called = gts.iter().filter(|g| g.dosage >= 0).count(); - eprintln!("{} panel sites, {} matched in TSV, {called} called", panel.sites.len(), gts.len()); + eprintln!( + "{} panel sites, {} matched in TSV, {called} called", + panel.sites.len(), + gts.len() + ); // Source codes: 3rd arg (comma-separated), else the default frequency-EM sources. let src_codes: Vec = std::env::args() .nth(3) .map(|s| s.split(',').map(|x| x.trim().to_string()).collect()) .unwrap_or_else(|| vec!["WHG".into(), "ANF".into(), "Steppe".into()]); - let sources: Vec = src_codes.iter().map(|c| panel.populations.iter().position(|p| p == c).unwrap()).collect(); + let sources: Vec = src_codes + .iter() + .map(|c| panel.populations.iter().position(|p| p == c).unwrap()) + .collect(); let outgroups: Vec = (0..panel.populations.len()).filter(|i| !sources.contains(i)).collect(); let fit = qpadm_fit(>s, &panel, &sources, &outgroups, F4_BLOCK_BP) .ok_or_else(|| anyhow::anyhow!("qpadm_fit returned None"))?; - println!("\nsites {} blocks {} dof {} chi2 {:.2} p {:.4}", fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value); + println!( + "\nsites {} blocks {} dof {} chi2 {:.2} p {:.4}", + fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value + ); for (code, i) in src_codes.iter().zip(0..) { - println!(" {code:<8} {:>6.1} % (SE {:.1})", fit.weights[i] * 100.0, fit.std_errors[i] * 100.0); + println!( + " {code:<8} {:>6.1} % (SE {:.1})", + fit.weights[i] * 100.0, + fit.std_errors[i] * 100.0 + ); } println!( "\nmodel {} at p=0.05; weights {}", if fit.p_value >= 0.05 { "ACCEPTED" } else { "REJECTED" }, - if fit.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE" } + if fit.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE" + } ); Ok(()) } diff --git a/crates/navigator-panelbuild/examples/qpadm_selftest.rs b/crates/navigator-panelbuild/examples/qpadm_selftest.rs index aaac8469..472bf2a0 100644 --- a/crates/navigator-panelbuild/examples/qpadm_selftest.rs +++ b/crates/navigator-panelbuild/examples/qpadm_selftest.rs @@ -12,7 +12,10 @@ use navigator_analysis::caller::SiteGenotype; struct Lcg(u64); impl Lcg { fn f(&mut self) -> f64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); (self.0 >> 11) as f64 / (1u64 << 53) as f64 } fn dosage(&mut self, p: f64) -> i32 { @@ -64,7 +67,11 @@ fn run(panel: &AncestryPanel, label: &str, truth: [f64; 3], seed: u64) { f.std_errors[1] * 100.0, f.std_errors[2] * 100.0, f.p_value, - if f.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE" }, + if f.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE" + }, ); } None => println!("{label:<28} qpadm_fit -> None"), @@ -72,7 +79,9 @@ fn run(panel: &AncestryPanel, label: &str, truth: [f64; 3], seed: u64) { } fn main() -> anyhow::Result<()> { - let path = std::env::args().nth(1).expect("usage: qpadm_selftest "); + let path = std::env::args() + .nth(1) + .expect("usage: qpadm_selftest "); let panel = AncestryPanel::from_bytes(&std::fs::read(&path)?).map_err(|e| anyhow::anyhow!("{e}"))?; println!( "panel {} sites, outgroups {:?}\ntruth order [WHG, ANF, Steppe]:\n", diff --git a/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs b/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs index 56d7c031..2812dcb0 100644 --- a/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs +++ b/crates/navigator-panelbuild/examples/resolve_chip_dosage.rs @@ -10,7 +10,9 @@ use navigator_domain::chipprofile; use std::io::{BufWriter, Write}; fn main() -> anyhow::Result<()> { - let ibd_path = std::env::args().nth(1).expect("usage: resolve_chip_dosage "); + let ibd_path = std::env::args() + .nth(1) + .expect("usage: resolve_chip_dosage "); let chip_path = std::env::args().nth(2).expect("chip.txt"); let out = std::env::args().nth(3).expect("out.tsv"); @@ -20,7 +22,8 @@ fn main() -> anyhow::Result<()> { eprintln!("chip build {build}: {} autosomal calls", calls.len()); let ibd = IbdPanel::from_bytes(&std::fs::read(&ibd_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; - let tuples: Vec<(String, i64, char, char)> = calls.into_iter().map(|c| (c.contig, c.position, c.a1, c.a2)).collect(); + let tuples: Vec<(String, i64, char, char)> = + calls.into_iter().map(|c| (c.contig, c.position, c.a1, c.a2)).collect(); let gts = ibd.resolve_chip(&build, &tuples); let mut w = BufWriter::new(std::fs::File::create(&out)?); diff --git a/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs b/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs index 1dfa7f4f..7f2d7ebc 100644 --- a/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs +++ b/crates/navigator-panelbuild/examples/score_modern_from_tsv.rs @@ -13,7 +13,9 @@ use navigator_analysis::caller::SiteGenotype; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let fine_path = std::env::args().nth(1).expect("usage: score_modern_from_tsv [pca.bin]"); + let fine_path = std::env::args() + .nth(1) + .expect("usage: score_modern_from_tsv [pca.bin]"); let tsv = std::env::args().nth(2).expect("dosage.tsv"); let pca_path = std::env::args().nth(3).filter(|s| !s.is_empty()); @@ -32,7 +34,9 @@ fn main() -> anyhow::Result<()> { 3 => (f[0], f[1], f[2]), _ => continue, }; - let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { continue }; + let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { + continue; + }; dosage.insert((c.to_string(), p), d); } @@ -81,7 +85,10 @@ fn main() -> anyhow::Result<()> { let mut comps = result.components.clone(); comps.sort_by(|a, b| b.percentage.total_cmp(&a.percentage)); for c in comps.iter().filter(|c| c.percentage >= 0.5) { - println!(" {:<5} {:<22} {:>6.1} %", c.population_code, c.population_name, c.percentage); + println!( + " {:<5} {:<22} {:>6.1} %", + c.population_code, c.population_name, c.percentage + ); } if let Some(pca_path) = pca_path { diff --git a/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs b/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs index 0e80c222..97bdfa5e 100644 --- a/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs +++ b/crates/navigator-panelbuild/examples/score_superpop_from_tsv.rs @@ -6,7 +6,9 @@ use navigator_analysis::caller::SiteGenotype; use std::collections::HashMap; fn main() -> anyhow::Result<()> { - let panel_path = std::env::args().nth(1).expect("usage: score_superpop_from_tsv "); + let panel_path = std::env::args() + .nth(1) + .expect("usage: score_superpop_from_tsv "); let tsv = std::env::args().nth(2).expect("dosage.tsv"); let panel = AncestryPanel::from_bytes(&std::fs::read(&panel_path)?).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -21,7 +23,9 @@ fn main() -> anyhow::Result<()> { 3 => (f[0], f[1], f[2]), _ => continue, }; - let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { continue }; + let (Ok(p), Ok(d)) = (p.trim().parse::(), d.trim().parse::()) else { + continue; + }; dosage.insert((c.to_string(), p), d); } let gts: Vec = panel @@ -47,11 +51,19 @@ fn main() -> anyhow::Result<()> { }) .collect(); let r = estimate_admixture(>s, &panel, "chm13v2.0"); - println!("super-pop panel: {} pops, {} sites used", panel.populations.len(), r.snps_with_genotype); + println!( + "super-pop panel: {} pops, {} sites used", + panel.populations.len(), + r.snps_with_genotype + ); let mut comps = r.components.clone(); comps.sort_by(|a, b| b.percentage.total_cmp(&a.percentage)); for c in &comps { - let gated = if c.percentage < 2.0 { " <- dropped by 2% gate" } else { "" }; + let gated = if c.percentage < 2.0 { + " <- dropped by 2% gate" + } else { + "" + }; println!(" {:<5} {:>6.2} %{}", c.population_code, c.percentage, gated); } Ok(()) diff --git a/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs b/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs index cde620cf..e96fe3c2 100644 --- a/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs +++ b/crates/navigator-panelbuild/examples/verify_qpadm_fit.rs @@ -24,7 +24,15 @@ fn main() -> anyhow::Result<()> { let (traw, ind, rschm, rsba, james) = (&a[1], &a[2], &a[3], &a[4], &a[5]); // Population order: sources first, then outgroups. - let pops = ["WHG", "EEF", "Steppe", "AnatoliaOG", "Afanasievo", "IronGates", "African"]; + let pops = [ + "WHG", + "EEF", + "Steppe", + "AnatoliaOG", + "Afanasievo", + "IronGates", + "African", + ]; let pop_idx: HashMap<&str, usize> = pops.iter().enumerate().map(|(i, &p)| (p, i)).collect(); // .ind → label per traw sample column (skip the appended Target row). @@ -68,7 +76,9 @@ fn main() -> anyhow::Result<()> { } let f: Vec<&str> = line.split('\t').collect(); let rsid = f[1]; - let Some(&(ref contig, pos)) = chm.get(rsid) else { continue }; + let Some(&(ref contig, pos)) = chm.get(rsid) else { + continue; + }; let Some(&ba) = bedalt.get(rsid) else { continue }; let counted = f[4].as_bytes()[0]; let alt = f[5].as_bytes()[0]; @@ -142,14 +152,25 @@ fn main() -> anyhow::Result<()> { let fit = qpadm_fit(&genos, &panel, &sources, &outgroups, F4_BLOCK_BP) .ok_or_else(|| anyhow::anyhow!("qpadm_fit returned None"))?; println!("\n== our qpadm_fit — James (Patterson config) =="); - println!("sites {} blocks {} dof {} chi2 {:.2} p {:.4}", fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value); + println!( + "sites {} blocks {} dof {} chi2 {:.2} p {:.4}", + fit.n_sites, fit.n_blocks, fit.dof, fit.chi2, fit.p_value + ); for (c, i) in ["WHG", "EEF", "Steppe"].iter().zip(0..) { - println!(" {c:<8} {:>6.1} % (SE {:.1})", fit.weights[i] * 100.0, fit.std_errors[i] * 100.0); + println!( + " {c:<8} {:>6.1} % (SE {:.1})", + fit.weights[i] * 100.0, + fit.std_errors[i] * 100.0 + ); } println!( "model {} at p=0.05; weights {}", if fit.p_value >= 0.05 { "ACCEPTED" } else { "REJECTED" }, - if fit.weights_feasible(0.02) { "feasible" } else { "INFEASIBLE" } + if fit.weights_feasible(0.02) { + "feasible" + } else { + "INFEASIBLE" + } ); Ok(()) } diff --git a/crates/navigator-panelbuild/src/archaic.rs b/crates/navigator-panelbuild/src/archaic.rs index ad2983d2..380c1f4e 100644 --- a/crates/navigator-panelbuild/src/archaic.rs +++ b/crates/navigator-panelbuild/src/archaic.rs @@ -257,9 +257,7 @@ pub fn build_archaic_candidates(args: ArchaicCandidatesArgs) -> Result<()> { // reference-confident record states only the REF base and cannot define the pair. At least // one genome must vary, otherwise the site is invariant across all four and carries no // information regardless of polarity. - let Some((reference_allele, alternate_allele)) = present - .iter() - .find_map(|(r, a, _)| a.map(|alt| (*r, alt))) + let Some((reference_allele, alternate_allele)) = present.iter().find_map(|(r, a, _)| a.map(|alt| (*r, alt))) else { continue; }; @@ -520,7 +518,11 @@ fn derived_freq(derived: char, og_ref: char, og_alt: char, af_alt: f32) -> Optio /// /// Same discipline as the CHM13 pass: `CrossMap bed` is not allele-aware, so each site is oriented /// against the hg38 reference base (swap ref/alt where reversed, drop where neither matches). -fn build_hg38_loci(bed: &Path, reference: &Path, candidates: &HashMap) -> Result> { +fn build_hg38_loci( + bed: &Path, + reference: &Path, + candidates: &HashMap, +) -> Result> { let lifted = load_lifted(bed)?; let mut rows: Vec<(usize, String, i64)> = lifted.into_iter().map(|(i, (c, p))| (i, c, p)).collect(); rows.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2))); diff --git a/crates/navigator-panelbuild/src/archaic_dist.rs b/crates/navigator-panelbuild/src/archaic_dist.rs index bdc735df..63ee48bf 100644 --- a/crates/navigator-panelbuild/src/archaic_dist.rs +++ b/crates/navigator-panelbuild/src/archaic_dist.rs @@ -341,7 +341,11 @@ pub fn build_archaic_dist(args: ArchaicDistArgs) -> Result<()> { let mean = vals.iter().sum::() / vals.len() as f64; let observed = vals.iter().map(|v| (v - mean).powi(2)).sum::() / (vals.len() - 1) as f64; // Actual density realised by the hash, not the nominal target. - let realised = site_freqs[pi].iter().enumerate().filter(|(si, _)| in_rung(*si, r)).count() as f32 + let realised = site_freqs[pi] + .iter() + .enumerate() + .filter(|(si, _)| in_rung(*si, r)) + .count() as f32 / panel.len().max(1) as f32; ladder.push((realised, (observed / predicted).max(1.0) as f32)); } @@ -386,7 +390,10 @@ pub fn build_archaic_dist(args: ArchaicDistArgs) -> Result<()> { ladder = kept; } ladder.sort_by(|a, b| a.0.total_cmp(&b.0)); - let shown: Vec = ladder.iter().map(|(d, i)| format!("{:.1}%:{:.1}x", d * 100.0, i)).collect(); + let shown: Vec = ladder + .iter() + .map(|(d, i)| format!("{:.1}%:{:.1}x", d * 100.0, i)) + .collect(); eprintln!(" {sup:<5} variance inflation by density {}", shown.join(" ")); variance_inflation.push(ladder); } diff --git a/crates/navigator-panelbuild/src/archaic_tierb.rs b/crates/navigator-panelbuild/src/archaic_tierb.rs index ae873fb8..b725262a 100644 --- a/crates/navigator-panelbuild/src/archaic_tierb.rs +++ b/crates/navigator-panelbuild/src/archaic_tierb.rs @@ -16,8 +16,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use clap::Parser; use navigator_analysis::archaic::{ - ArchaicCallable, ArchaicClassify, ArchaicOutgroup, CallableContig, ClassifyContig, DiagnosticClass, - PositionStream, + ArchaicCallable, ArchaicClassify, ArchaicOutgroup, CallableContig, ClassifyContig, DiagnosticClass, PositionStream, }; use crate::pca::{open_maybe_gz, write_bin}; @@ -49,7 +48,9 @@ fn load_positions(path: &Path) -> Result>> { continue; } let mut it = line.split_whitespace(); - let (Some(c), Some(p)) = (it.next(), it.next()) else { continue }; + let (Some(c), Some(p)) = (it.next(), it.next()) else { + continue; + }; let Ok(pos) = p.parse::() else { continue }; by_contig.entry(c.to_string()).or_default().push(pos); } @@ -62,7 +63,11 @@ fn load_positions(path: &Path) -> Result>> { pub fn build_archaic_outgroup(args: ArchaicOutgroupArgs) -> Result<()> { let by_contig = load_positions(&args.sites)?; - anyhow::ensure!(!by_contig.is_empty(), "no outgroup sites read from {}", args.sites.display()); + anyhow::ensure!( + !by_contig.is_empty(), + "no outgroup sites read from {}", + args.sites.display() + ); let mut contigs = Vec::with_capacity(by_contig.len()); let mut total = 0usize; @@ -167,7 +172,10 @@ pub fn build_archaic_classify(args: ArchaicClassifyArgs) -> Result<()> { } } if let Some(&(derived, class)) = payload.get(&idx) { - by_contig.entry(f[0].to_string()).or_default().push((end, derived, class)); + by_contig + .entry(f[0].to_string()) + .or_default() + .push((end, derived, class)); } } anyhow::ensure!(!by_contig.is_empty(), "no classification sites survived the join"); @@ -249,7 +257,11 @@ pub fn build_archaic_callable(args: ArchaicCallableArgs) -> Result<()> { spans.entry(f[0].to_string()).or_default().push((s, e)); } } - anyhow::ensure!(!spans.is_empty(), "no callable intervals read from {}", args.bed.display()); + anyhow::ensure!( + !spans.is_empty(), + "no callable intervals read from {}", + args.bed.display() + ); let mut contigs = Vec::with_capacity(spans.len()); let mut total_bp = 0f64; @@ -267,7 +279,9 @@ pub fn build_archaic_callable(args: ArchaicCallableArgs) -> Result<()> { let win_end = start + (idx as i64 + 1) * args.window_bp; let take = end.min(win_end) - cur; if let Some(slot) = callable_bp.get_mut(idx) { - *slot = slot.saturating_add(take.clamp(0, u16::MAX as i64) as u16).min(args.window_bp as u16); + *slot = slot + .saturating_add(take.clamp(0, u16::MAX as i64) as u16) + .min(args.window_bp as u16); } cur = win_end; } diff --git a/crates/navigator-panelbuild/src/hap_panel.rs b/crates/navigator-panelbuild/src/hap_panel.rs index 009754ca..bbd1e244 100644 --- a/crates/navigator-panelbuild/src/hap_panel.rs +++ b/crates/navigator-panelbuild/src/hap_panel.rs @@ -133,7 +133,11 @@ fn allele(s: Option<&str>) -> (u8, bool) { /// A biallelic SNV has single-base ref and alt over `{A,C,G,T}` (skips indels / multiallelic). fn is_biallelic_snv(ref_s: &str, alt_s: &str) -> bool { - let single = |s: &str| s.len() == 1 && s.chars().all(|c| matches!(c.to_ascii_uppercase(), 'A' | 'C' | 'G' | 'T')); + let single = |s: &str| { + s.len() == 1 + && s.chars() + .all(|c| matches!(c.to_ascii_uppercase(), 'A' | 'C' | 'G' | 'T')) + }; single(ref_s) && single(alt_s) } @@ -171,7 +175,10 @@ pub fn build_hap_panel(args: HapPanelArgs) -> Result<()> { .map(|((c, p), (rf, alt, _))| (c.clone(), *p, *rf, *alt)) .collect(); site_keys.sort_by(|a, b| (a.0.as_str(), a.1).cmp(&(b.0.as_str(), b.1))); - anyhow::ensure!(!site_keys.is_empty(), "no shared biallelic SNV sites across the sources"); + anyhow::ensure!( + !site_keys.is_empty(), + "no shared biallelic SNV sites across the sources" + ); let sites: Vec = site_keys .iter() .map(|(c, p, r, a)| HapSite { @@ -191,7 +198,10 @@ pub fn build_hap_panel(args: HapPanelArgs) -> Result<()> { let mut per_source_labelled = vec![0usize; sources.len()]; for (src_i, src) in sources.iter().enumerate() { // Per-site aligned genotype vectors for this source (one map lookup per site, not per sample). - let aligned: Vec<&Vec<(u8, u8)>> = site_keys.iter().map(|(c, p, _, _)| &src.sites[&(c.clone(), *p)].2).collect(); + let aligned: Vec<&Vec<(u8, u8)>> = site_keys + .iter() + .map(|(c, p, _, _)| &src.sites[&(c.clone(), *p)].2) + .collect(); for (si, sample) in src.samples.iter().enumerate() { let Some(pop) = fine.get(sample) else { continue; @@ -215,7 +225,11 @@ pub fn build_hap_panel(args: HapPanelArgs) -> Result<()> { per_source_labelled[src_i] += 1; } } - anyhow::ensure!(!rows.is_empty(), "no labelled samples across the sources (check {})", args.pops.display()); + anyhow::ensure!( + !rows.is_empty(), + "no labelled samples across the sources (check {})", + args.pops.display() + ); let reference = HaplotypeReference::from_rows(BUILD.to_string(), sites, populations, hap_pop, &rows); eprintln!( diff --git a/crates/navigator-panelbuild/src/pca.rs b/crates/navigator-panelbuild/src/pca.rs index 4492bd9b..731a9785 100644 --- a/crates/navigator-panelbuild/src/pca.rs +++ b/crates/navigator-panelbuild/src/pca.rs @@ -567,7 +567,10 @@ pub fn build_fine_panel(args: FinePanelArgs) -> Result<()> { /// floor — not the diploid coding — is what matters. pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { let parse_list = |s: &str| -> Vec { - s.split(',').map(|c| c.trim().to_string()).filter(|c| !c.is_empty()).collect() + s.split(',') + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .collect() }; let sources: Vec = parse_list(&args.components); let outgroup_comps: Vec = parse_list(&args.outgroups); @@ -582,7 +585,13 @@ pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { ); // Per-population call floor: sources use --min-called, outgroups the lower --outgroup-min-called. let floor: Vec = (0..comps.len()) - .map(|i| if i < n_src { args.min_called } else { args.outgroup_min_called }) + .map(|i| { + if i < n_src { + args.min_called + } else { + args.outgroup_min_called + } + }) .collect(); let pop_of = load_fine_map(&args.pops)?; @@ -618,7 +627,11 @@ pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { (!contig.eq_ignore_ascii_case("contig")).then(|| (contig.to_string(), pos)) }) .collect(); - anyhow::ensure!(!set.is_empty(), "ascertainment file {} had no usable contigpos rows", p.display()); + anyhow::ensure!( + !set.is_empty(), + "ascertainment file {} had no usable contigpos rows", + p.display() + ); eprintln!("ascertainment floor: {} sites from {}", set.len(), p.display()); Some(set) } @@ -734,7 +747,10 @@ pub fn build_ancient_panel(args: AncientPanelArgs) -> Result<()> { oriented.len() ); sites = oriented; - anyhow::ensure!(!sites.is_empty(), "no site survived CHM13 orientation — wrong reference?"); + anyhow::ensure!( + !sites.is_empty(), + "no site survived CHM13 orientation — wrong reference?" + ); } let panel = AncestryPanel { diff --git a/crates/navigator-refgenome/src/cache.rs b/crates/navigator-refgenome/src/cache.rs index 39f8f0f1..0c3e235a 100644 --- a/crates/navigator-refgenome/src/cache.rs +++ b/crates/navigator-refgenome/src/cache.rs @@ -143,7 +143,10 @@ mod tests { atomic_write(&path, b"{\"x\":1}").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"x\":1}"); // The rename consumes the temp, so only the target remains — no stray `*.tmp.*` files. - let entries: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect(); + let entries: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); assert_eq!(entries, vec![std::ffi::OsString::from("cfg.json")]); let _ = std::fs::remove_dir_all(&dir); } @@ -157,8 +160,7 @@ mod tests { let dir = std::env::temp_dir().join(format!("atomicw_{}_conc", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let path = Arc::new(dir.join("cfg.json")); - let payloads: Arc> = - Arc::new((0..8).map(|i| format!("[{i}{}]", ",0".repeat(i * 400))).collect()); + let payloads: Arc> = Arc::new((0..8).map(|i| format!("[{i}{}]", ",0".repeat(i * 400))).collect()); atomic_write(&path, payloads[0].as_bytes()).unwrap(); let mut handles = Vec::new(); for _ in 0..24 { @@ -191,7 +193,10 @@ mod tests { let started = std::time::Instant::now(); let err = read_atomic(&path).expect_err("missing file must error"); assert_eq!(err.kind(), std::io::ErrorKind::NotFound); - assert!(started.elapsed() < std::time::Duration::from_millis(100), "missing file was retried"); + assert!( + started.elapsed() < std::time::Duration::from_millis(100), + "missing file was retried" + ); atomic_write(&path, b"{\"x\":1}").unwrap(); assert_eq!(read_atomic(&path).unwrap(), b"{\"x\":1}"); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/navigator-refgenome/src/gateway.rs b/crates/navigator-refgenome/src/gateway.rs index a7b1d500..d2407fde 100644 --- a/crates/navigator-refgenome/src/gateway.rs +++ b/crates/navigator-refgenome/src/gateway.rs @@ -195,6 +195,7 @@ impl ReferenceGateway { })?; let sha = download::download(&self.http, &src.url, &path, progress).await?; verify_pinned(&path, src.sha256.as_deref(), &sha)?; // verify the artifact exactly as served + // The cache stores chains as plain text (`load_liftover` reads them with `read_to_string`). // Every chain flows through the same path: if the downloaded artifact is gzipped (UCSC // serves `.over.chain.gz`; the curated bucket serves plain `.chain`), decompress it in place @@ -770,7 +771,8 @@ mod tests { // Disk hit (any alias / the masked variant share CHM13's regions). let r = g.cached_genome_regions("hs1").expect("disk-cached regions"); assert!(r.chromosome("chrY").unwrap().par.len() == 2); // PAR overlaid by the parser - // Second call is an in-memory hit (same Arc). + + // Second call is an in-memory hit (same Arc). let r2 = g.cached_genome_regions("chm13v2.0_maskedY_rCRS").unwrap(); assert!(Arc::ptr_eq(&r, &r2)); diff --git a/crates/navigator-refgenome/src/registry.rs b/crates/navigator-refgenome/src/registry.rs index 714f46b7..5ee5e8c3 100644 --- a/crates/navigator-refgenome/src/registry.rs +++ b/crates/navigator-refgenome/src/registry.rs @@ -217,9 +217,9 @@ impl UserConfig { // `read_atomic`, not `fs::read_to_string`: a save racing this read leaves the path briefly // delete-pending on Windows, and an unreadable config here means the user's overrides // silently vanish — the same disappearing-override symptom as issue #26, by another route. - let Ok(text) = crate::cache::read_atomic(path).and_then(|b| { - String::from_utf8(b).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) - }) else { + let Ok(text) = crate::cache::read_atomic(path) + .and_then(|b| String::from_utf8(b).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))) + else { return Self::default(); // absent / unreadable → empty (the normal no-config case) }; match serde_json::from_str(&text) { @@ -312,7 +312,12 @@ impl Registry { } _ => return None, }; - Some(ChainSource { from, to, url, sha256: None }) + Some(ChainSource { + from, + to, + url, + sha256: None, + }) } /// The UCSC `cytoBand` table URL for a build (gzipped) — the source for genome-region diff --git a/crates/navigator-store/src/artifact.rs b/crates/navigator-store/src/artifact.rs index 242acfa1..b042157d 100644 --- a/crates/navigator-store/src/artifact.rs +++ b/crates/navigator-store/src/artifact.rs @@ -265,9 +265,19 @@ mod tests { } async fn full_coverage(pool: &SqlitePool, aln: i64) { - upsert(pool, aln, "coverage", "coverage-1", Utc::now(), "{}", "navigator-walk", "full", None) - .await - .unwrap(); + upsert( + pool, + aln, + "coverage", + "coverage-1", + Utc::now(), + "{}", + "navigator-walk", + "full", + None, + ) + .await + .unwrap(); } #[tokio::test] @@ -289,9 +299,19 @@ mod tests { // C: one alignment with only a *partial* (sidecar) coverage → does not count → Pending. let c = subject(pool, "C").await; let c_aln = alignment(pool, c).await; - upsert(pool, c_aln, "coverage", "coverage-1", Utc::now(), "{}", "pipeline-sidecar", "partial", None) - .await - .unwrap(); + upsert( + pool, + c_aln, + "coverage", + "coverage-1", + Utc::now(), + "{}", + "pipeline-sidecar", + "partial", + None, + ) + .await + .unwrap(); // D: a subject with no alignments → absent from the census. let _d = subject(pool, "D").await; diff --git a/crates/navigator-store/src/consensus_archaic_segments.rs b/crates/navigator-store/src/consensus_archaic_segments.rs index 87d62367..5c5401f8 100644 --- a/crates/navigator-store/src/consensus_archaic_segments.rs +++ b/crates/navigator-store/src/consensus_archaic_segments.rs @@ -44,10 +44,11 @@ pub async fn upsert( /// The cached segments marker count result for a biosample, if one exists (caller checks the signature for staleness). pub async fn get(pool: &SqlitePool, guid: SampleGuid) -> Result, StoreError> { - let row: Option = sqlx::query_as("SELECT * FROM consensus_archaic_segments WHERE biosample_guid = ?") - .bind(guid.0.to_string()) - .fetch_optional(pool) - .await?; + let row: Option = + sqlx::query_as("SELECT * FROM consensus_archaic_segments WHERE biosample_guid = ?") + .bind(guid.0.to_string()) + .fetch_optional(pool) + .await?; Ok(row) } diff --git a/crates/navigator-store/src/external_panel_dosage.rs b/crates/navigator-store/src/external_panel_dosage.rs index 49602ad0..aa3e93b6 100644 --- a/crates/navigator-store/src/external_panel_dosage.rs +++ b/crates/navigator-store/src/external_panel_dosage.rs @@ -43,10 +43,7 @@ pub async fn upsert(pool: &SqlitePool, row: &StoredPanelDosage) -> Result<(), St } /// All external panel-dosage rows for a biosample (each a distinct source). -pub async fn list_for_biosample( - pool: &SqlitePool, - guid: SampleGuid, -) -> Result, StoreError> { +pub async fn list_for_biosample(pool: &SqlitePool, guid: SampleGuid) -> Result, StoreError> { let rows: Vec = sqlx::query_as( "SELECT biosample_guid, source_label, provenance, panel_sig, site_count, dosages, created_at \ FROM external_panel_dosage WHERE biosample_guid = ? ORDER BY id", diff --git a/crates/navigator-store/tests/store.rs b/crates/navigator-store/tests/store.rs index 14203918..68f73f9d 100644 --- a/crates/navigator-store/tests/store.rs +++ b/crates/navigator-store/tests/store.rs @@ -114,7 +114,8 @@ async fn run_alignment_chain_persists() { run ); assert_eq!(run.mean_insert_size, Some(580.7)); // flat metric column round-trips - // The lab/instrument identity block is None at create, then filled by set_library_stats. + + // The lab/instrument identity block is None at create, then filled by set_library_stats. assert_eq!(run.instrument_id, None); sequence_run::set_library_stats( s.pool(), @@ -161,6 +162,7 @@ async fn run_alignment_chain_persists() { assert_eq!(reloaded.total_reads, Some(9_100_000)); assert_eq!(reloaded.library_layout.as_deref(), Some("PAIRED")); assert_eq!(reloaded.total_bases, Some(1_365_000_000)); // preserved by COALESCE + // The descriptive + identity columns are untouched by the read-stats write. assert_eq!(reloaded.instrument_id.as_deref(), Some("A00182")); @@ -709,18 +711,24 @@ async fn bulk_member_counts_match_the_per_project_count() { // M:N member of p1 only. let a = sample(None); biosample::create(s.pool(), &a).await.unwrap(); - biosample_project::add(s.pool(), a.guid, p1.id, None, "2026-07-25").await.unwrap(); + biosample_project::add(s.pool(), a.guid, p1.id, None, "2026-07-25") + .await + .unwrap(); // Legacy home column of p1 only. let b = sample(Some(p1.id)); biosample::create(s.pool(), &b).await.unwrap(); // Both M:N and home for p1 — the UNION must count this once, not twice. let c = sample(Some(p1.id)); biosample::create(s.pool(), &c).await.unwrap(); - biosample_project::add(s.pool(), c.guid, p1.id, None, "2026-07-25").await.unwrap(); + biosample_project::add(s.pool(), c.guid, p1.id, None, "2026-07-25") + .await + .unwrap(); // Member of p2 only, so the GROUP BY has to key correctly. let d = sample(None); biosample::create(s.pool(), &d).await.unwrap(); - biosample_project::add(s.pool(), d.guid, p2.id, None, "2026-07-25").await.unwrap(); + biosample_project::add(s.pool(), d.guid, p2.id, None, "2026-07-25") + .await + .unwrap(); let counts: std::collections::HashMap = biosample::member_counts(s.pool()).await.unwrap().into_iter().collect(); @@ -729,7 +737,11 @@ async fn bulk_member_counts_match_the_per_project_count() { assert_eq!(counts.get(&p).copied().unwrap_or(0), one, "project {p}"); } assert_eq!(counts.get(&p1.id).copied().unwrap_or(0), 3, "a, b, c — c counted once"); - assert_eq!(counts.get(&empty.id), None, "a project with no members is absent, not zero"); + assert_eq!( + counts.get(&empty.id), + None, + "a project with no members is absent, not zero" + ); // Removing a subject drops its membership first (a foreign key forbids a dangling // `biosample_project` row, which is why both count forms can join to `biosample` safely). diff --git a/crates/navigator-sync/src/oauth.rs b/crates/navigator-sync/src/oauth.rs index c7220e5e..c30d0698 100644 --- a/crates/navigator-sync/src/oauth.rs +++ b/crates/navigator-sync/src/oauth.rs @@ -204,7 +204,10 @@ async fn post_with_dpop( let Some(nonce) = nonce else { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - return Err(SyncError::Oauth(format!("{post_url}: {status} {}", truncate_body(&body)))); + return Err(SyncError::Oauth(format!( + "{post_url}: {status} {}", + truncate_body(&body) + ))); }; let proof = dpop_proof(key, "POST", htu, now(), Some(&nonce), None); let retry = http.post(post_url).header("DPoP", proof).form(form).send().await?; @@ -213,7 +216,10 @@ async fn post_with_dpop( } else { let status = retry.status(); let body = retry.text().await.unwrap_or_default(); - Err(SyncError::Oauth(format!("{post_url}: {status} {}", truncate_body(&body)))) + Err(SyncError::Oauth(format!( + "{post_url}: {status} {}", + truncate_body(&body) + ))) } } diff --git a/crates/navigator-ui/src/charts.rs b/crates/navigator-ui/src/charts.rs index dfd9d19e..b941d76e 100644 --- a/crates/navigator-ui/src/charts.rs +++ b/crates/navigator-ui/src/charts.rs @@ -157,7 +157,9 @@ pub(crate) fn draw_roh(ui: &mut egui::Ui, result: &RohResult, regions: Option<&G .unwrap_or_else(|| segs.iter().map(|s| s.end_bp).max().unwrap_or(1)) .max(1) as f32; ui.horizontal(|ui| { - ui.allocate_ui(egui::vec2(label_w, bar_h), |ui| ui.label(egui::RichText::new(chr).small())); + ui.allocate_ui(egui::vec2(label_w, bar_h), |ui| { + ui.label(egui::RichText::new(chr).small()) + }); let (rect, resp) = ui.allocate_exact_size(egui::vec2(bar_w, bar_h), egui::Sense::hover()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 2.0, egui::Color32::from_gray(30)); @@ -173,8 +175,14 @@ pub(crate) fn draw_roh(ui: &mut egui::Ui, result: &RohResult, regions: Option<&G if hx >= block.left() && hx <= block.right() { hover = Some(format!( "{}:{}–{} · {:.1} Mb ({:.2} cM) · {} sites ({} het) · conf {:.2}", - seg.chromosome, seg.start_bp, seg.end_bp, seg.length_mb, seg.length_cm, seg.n_sites, - seg.n_het, seg.mean_posterior + seg.chromosome, + seg.start_bp, + seg.end_bp, + seg.length_mb, + seg.length_cm, + seg.n_sites, + seg.n_het, + seg.mean_posterior )); } } @@ -213,7 +221,10 @@ pub(crate) fn top_populations_for_side(segments: &[AncestrySegment], side: u8, k use std::collections::HashMap; let mut bp: HashMap = HashMap::new(); for s in segments.iter().filter(|s| s.copy == side) { - let code = s.fine_population_code.clone().unwrap_or_else(|| s.population_code.clone()); + let code = s + .fine_population_code + .clone() + .unwrap_or_else(|| s.population_code.clone()); *bp.entry(code).or_insert(0) += (s.end - s.start + 1).max(0); } let mut v: Vec<(String, i64)> = bp.into_iter().collect(); @@ -250,7 +261,9 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe let hovered: Option = ui.data(|d| d.get_temp(hover_id)); let mut next_hovered: Option = None; let seg_code = |s: &AncestrySegment| -> String { - s.fine_population_code.clone().unwrap_or_else(|| s.population_code.clone()) + s.fine_population_code + .clone() + .unwrap_or_else(|| s.population_code.clone()) }; // Header: which stacked track is which side (▲ top, ▼ bottom). @@ -278,8 +291,7 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe ui.allocate_ui(egui::vec2(label_w, copy_h * 2.0 + gap), |ui| { ui.label(format!("chr{n}")) }); - let (rect, response) = - ui.allocate_exact_size(egui::vec2(bar_w, copy_h * 2.0 + gap), egui::Sense::hover()); + let (rect, response) = ui.allocate_exact_size(egui::vec2(bar_w, copy_h * 2.0 + gap), egui::Sense::hover()); let painter = ui.painter_at(rect); for (c, segs) in copies.iter().enumerate() { let top = rect.top() + c as f32 * (copy_h + gap); @@ -302,7 +314,11 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe } // Per-segment hover: highlight that population + show side / population / Mb-range tooltip. if let Some(pos) = response.hover_pos() { - let c = if pos.y < rect.top() + copy_h + gap * 0.5 { 0usize } else { 1usize }; + let c = if pos.y < rect.top() + copy_h + gap * 0.5 { + 0usize + } else { + 1usize + }; let bp = lo + (((pos.x - rect.left()) / rect.width().max(1.0)) * span) as i64; if let Some(s) = copies[c].iter().find(|s| bp >= s.start && bp <= s.end) { next_hovered = Some(seg_code(s)); @@ -336,7 +352,11 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe let active = hovered.as_deref() == Some(code.as_str()); let inner = ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); - let swatch = if active || hovered.is_none() { *color } else { color.gamma_multiply(0.5) }; + let swatch = if active || hovered.is_none() { + *color + } else { + color.gamma_multiply(0.5) + }; ui.painter().circle_filled(r.center(), 4.0, swatch); let mut txt = egui::RichText::new(population_name(code)).small(); if active { @@ -346,7 +366,9 @@ pub(crate) fn draw_chromosome_painting(ui: &mut egui::Ui, segments: &[AncestrySe } ui.label(txt); }); - let over = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| inner.response.rect.contains(p)); + let over = ui + .input(|i| i.pointer.hover_pos()) + .is_some_and(|p| inner.response.rect.contains(p)); if over { next_hovered = Some(code.clone()); } @@ -467,7 +489,8 @@ pub(crate) fn draw_population_components(ui: &mut egui::Ui, result: &AncestryRes for (name, code, pct) in shown { ui.horizontal(|ui| { let (sw, _) = ui.allocate_exact_size(egui::vec2(10.0, 10.0), egui::Sense::hover()); - ui.painter().rect_filled(sw, 2.0, parse_hex_color(&population_color(code))); + ui.painter() + .rect_filled(sw, 2.0, parse_hex_color(&population_color(code))); ui.add_space(2.0); ui.label(egui::RichText::new(format!("{pct:.1}%")).strong()); ui.label(*name); @@ -749,11 +772,7 @@ pub(crate) fn draw_archaic_segments(ui: &mut egui::Ui, result: &navigator_app::A // chr1, chr10, chr11 … chr19, chr2, chr20 — which reads as a bug to anyone scanning the track. let mut by_chr: BTreeMap<(u32, &str), Vec<&navigator_app::ArchaicSegment>> = BTreeMap::new(); for s in &result.segments { - let n = s - .contig - .trim_start_matches("chr") - .parse::() - .unwrap_or(u32::MAX); // non-numeric contigs sort last, keeping their own order + let n = s.contig.trim_start_matches("chr").parse::().unwrap_or(u32::MAX); // non-numeric contigs sort last, keeping their own order by_chr.entry((n, s.contig.as_str())).or_default().push(s); } if by_chr.is_empty() { diff --git a/crates/navigator-ui/src/cli.rs b/crates/navigator-ui/src/cli.rs index caeb47c6..d10064ce 100644 --- a/crates/navigator-ui/src/cli.rs +++ b/crates/navigator-ui/src/cli.rs @@ -468,7 +468,10 @@ async fn backfill_accessions(args: AccessionArgs) -> i32 { Ok(v) => v, Err(c) => return c, }; - let r = match app.backfill_accessions(project_id, args.apply, args.all, args.limit).await { + let r = match app + .backfill_accessions(project_id, args.apply, args.all, args.limit) + .await + { Ok(r) => r, Err(e) => return report(e), }; @@ -483,10 +486,16 @@ async fn backfill_accessions(args: AccessionArgs) -> i32 { println!(" ids attached (name + accession): {}", r.ids_added); println!(" local accession fixed: {}", r.accession_updated); if r.conflicts > 0 { - println!(" conflicts: {} (id already owned by another subject)", r.conflicts); + println!( + " conflicts: {} (id already owned by another subject)", + r.conflicts + ); } } else { - println!(" ids to attach (name + accession): {} (dry run — pass --apply)", r.ids_to_add); + println!( + " ids to attach (name + accession): {} (dry run — pass --apply)", + r.ids_to_add + ); } for ex in &r.examples { println!(" e.g. {ex}"); @@ -519,7 +528,10 @@ async fn backfill_catalog_ids(args: CatalogArgs) -> i32 { if r.applied { println!(" ids added: {}", r.ids_added); if r.conflicts > 0 { - println!(" conflicts: {} (id already owned by another subject — skipped)", r.conflicts); + println!( + " conflicts: {} (id already owned by another subject — skipped)", + r.conflicts + ); } } else { println!(" ids to add: {} (dry run — pass --apply)", r.ids_to_add); @@ -562,7 +574,10 @@ async fn prune_orphans(args: PruneArgs) -> i32 { if args.json { println!("{}", serde_json::to_string_pretty(&report).unwrap_or_default()); } else if report.applied { - println!("Examined {} alignment record(s); deleted {} orphan(s).", report.examined, report.deleted); + println!( + "Examined {} alignment record(s); deleted {} orphan(s).", + report.examined, report.deleted + ); for rk in &report.orphans { println!(" deleted {rk}"); } @@ -876,16 +891,15 @@ async fn analyze(args: AnalyzeArgs) -> i32 { .build_autosomal_profile(*biosample_guid) .await .map(|p| format!("{} site(s)", p.variants.len())), - AnalysisStep::Ancestry { biosample_guid } => app - .estimate_ancestry_from_consensus(*biosample_guid) - .await - .map(|r| { + AnalysisStep::Ancestry { biosample_guid } => { + app.estimate_ancestry_from_consensus(*biosample_guid).await.map(|r| { // The top super-population is the headline the brief shows. r.super_population_summary .first() .map(|p| format!("{} {:.0}%", p.super_population, p.percentage)) .unwrap_or_else(|| "(none)".into()) - }), + }) + } }; match outcome { Ok(summary) => eprintln!(" [{:>8.1?}] {n}/{total} {} — {summary}", t.elapsed(), step.label()), @@ -903,7 +917,6 @@ async fn analyze(args: AnalyzeArgs) -> i32 { 0 } - fn db_path(over: Option) -> PathBuf { over.unwrap_or_else(crate::default_db_path) } @@ -1348,7 +1361,10 @@ async fn private_y(args: DebugCallsArgs) -> i32 { Ok(calls) => { eprintln!("raw de-novo chrY calls: {}", calls.len()); for c in &calls { - println!("DENOVO\t{}\t{}\t{}\t{}\t{:.2}", c.position, c.depth, c.alt_depth, c.alternate_allele, c.allele_fraction); + println!( + "DENOVO\t{}\t{}\t{}\t{}\t{:.2}", + c.position, c.depth, c.alt_depth, c.alternate_allele, c.allele_fraction + ); } return 0; } @@ -1365,10 +1381,7 @@ async fn private_y(args: DebugCallsArgs) -> i32 { return 1; } }; - let gate = app - .publish_gate_for_alignment(alignment_id) - .await - .unwrap_or_default(); + let gate = app.publish_gate_for_alignment(alignment_id).await.unwrap_or_default(); println!("alignment {alignment_id} — terminal {}", bucket.terminal); println!(" DISPLAY (filtered) total: {}", bucket.variants.len()); println!(" off-path known: {}", bucket.off_path()); @@ -1525,10 +1538,13 @@ async fn branch_report(args: BranchReportArgs) -> i32 { r.position, r.ancestral, r.derived, - r.observed_base.map(|c| c.to_string()).unwrap_or_else(|| ".".to_string()), + r.observed_base + .map(|c| c.to_string()) + .unwrap_or_else(|| ".".to_string()), status(r.state), gt(r.state), - r.ad.map(|(rf, al)| format!("{rf},{al}")).unwrap_or_else(|| ".".to_string()), + r.ad.map(|(rf, al)| format!("{rf},{al}")) + .unwrap_or_else(|| ".".to_string()), opt(r.dp), opt(r.gq), r.source, @@ -1611,7 +1627,10 @@ async fn archaic_segments(args: ShowArgs) -> i32 { return 0; } let s = &r.summary; - println!("Archaic segments (Tier B): {:.1} Mb in {} tracts", s.total_mb, s.n_segments); + println!( + "Archaic segments (Tier B): {:.1} Mb in {} tracts", + s.total_mb, s.n_segments + ); println!(" {:.2}% of the {:.0} Mb callable", s.pct_callable, s.callable_mb); println!(" lineage split withheld — attribution is not yet reliable enough to report"); 0 @@ -1646,7 +1665,10 @@ async fn archaic(args: ArchaicArgs) -> i32 { println!("{}", serde_json::to_string_pretty(&r).unwrap_or_default()); return 0; } - println!("Archaic markers (Tier A): {} of {} copies", r.total_copies, r.possible_copies); + println!( + "Archaic markers (Tier A): {} of {} copies", + r.total_copies, r.possible_copies + ); println!( " {} of {} panel sites called ({:.1}%)", r.called_sites, @@ -1656,7 +1678,10 @@ async fn archaic(args: ArchaicArgs) -> i32 { println!(" rate {:.4} copies/site", r.rate()); println!(" Neanderthal {}", r.neanderthal_copies); println!(" shared archaic {}", r.shared_copies); - println!(" Denisovan {} (near the noise floor outside Oceania — not a finding)", r.denisovan_copies); + println!( + " Denisovan {} (near the noise floor outside Oceania — not a finding)", + r.denisovan_copies + ); match (r.percentile, &r.cohort) { (Some(p), Some(c)) => println!(" percentile more than {p:.0}% of {c}"), _ => println!(" percentile not reported (coverage not comparable to the reference cohort)"), @@ -1868,10 +1893,8 @@ pub struct DoctorArgs { async fn doctor(args: DoctorArgs) -> i32 { let diagnosis = if let Some(file) = args.file { let reference = args.reference; - match tokio::task::spawn_blocking(move || { - navigator_app::diagnose_alignment_file(&file, reference.as_deref()) - }) - .await + match tokio::task::spawn_blocking(move || navigator_app::diagnose_alignment_file(&file, reference.as_deref())) + .await { Ok(r) => r, Err(e) => { @@ -1952,10 +1975,14 @@ async fn call(args: CallArgs) -> i32 { let scope = args.contig.clone().unwrap_or_else(|| "whole genome".into()); eprintln!("calling de-novo diploid variants on alignment #{alignment_id} ({scope})…"); let vcf = match args.contig { - Some(contig) => app.diploid_vcf(alignment_id, contig, navigator_app::CancelToken::none()) - .await, - None => app.diploid_vcf_genome(alignment_id, navigator_app::CancelToken::none()) - .await, + Some(contig) => { + app.diploid_vcf(alignment_id, contig, navigator_app::CancelToken::none()) + .await + } + None => { + app.diploid_vcf_genome(alignment_id, navigator_app::CancelToken::none()) + .await + } }; let vcf = match vcf { Ok(v) => v, diff --git a/crates/navigator-ui/src/ui/branch.rs b/crates/navigator-ui/src/ui/branch.rs index 9e6ffb37..026797b2 100644 --- a/crates/navigator-ui/src/ui/branch.rs +++ b/crates/navigator-ui/src/ui/branch.rs @@ -51,7 +51,12 @@ impl NavigatorApp { }; self.branch_reports.retain(|(g, d, _)| !(*g == guid && *d == dna)); self.branch_loading.push((guid, dna)); - let _ = self.tx.send(Command::LoadBranchReport { guid, dna, node, depth: None }); + let _ = self.tx.send(Command::LoadBranchReport { + guid, + dna, + node, + depth: None, + }); } if self.branch_loading.iter().any(|(g, d)| *g == guid && *d == dna) { @@ -72,7 +77,11 @@ impl NavigatorApp { .iter() .any(|(g, d, r)| *g == guid && *d == dna && r.is_none()); ui.add_space(4.0); - let key = if no_alignment { "branch.noAlignment" } else { "branch.hint" }; + let key = if no_alignment { + "branch.noAlignment" + } else { + "branch.hint" + }; ui.label(egui::RichText::new(self.tr(key)).weak()); return; } @@ -173,7 +182,11 @@ impl NavigatorApp { cell(ui, W_NODE, egui::RichText::new(&r.node)); cell(ui, W_MARKER, egui::RichText::new(&r.marker)); cell(ui, W_POS, egui::RichText::new(r.position.to_string())); - cell(ui, W_ALLELES, egui::RichText::new(format!("{}>{}", r.ancestral, r.derived))); + cell( + ui, + W_ALLELES, + egui::RichText::new(format!("{}>{}", r.ancestral, r.derived)), + ); cell( ui, W_OBS, diff --git a/crates/navigator-ui/src/ui/central.rs b/crates/navigator-ui/src/ui/central.rs index 5ed48650..ce3d6a4b 100644 --- a/crates/navigator-ui/src/ui/central.rs +++ b/crates/navigator-ui/src/ui/central.rs @@ -706,7 +706,9 @@ impl NavigatorApp { /// The subject-detail header: big name, ID + sex, and Add Data / Edit / Delete actions. fn subject_detail_header(&mut self, ui: &mut egui::Ui, guid: SampleGuid) { - let Some(bio) = self.find_subject(guid).cloned() else { return }; + let Some(bio) = self.find_subject(guid).cloned() else { + return; + }; ui.add_space(6.0); // When the subject was opened from a project's report, offer a way back to that project. if let Some(pid) = self.return_to_project { @@ -873,8 +875,24 @@ impl NavigatorApp { /// left, so the caller can fall back to a localized default. The user can rename later. fn first_run_subject_name(paths: &[std::path::PathBuf]) -> Option { const EXTS: [&str; 18] = [ - ".g.vcf.gz", ".vcf.gz", ".vcf.bgz", ".fasta.gz", ".fa.gz", ".fna.gz", ".bam", ".cram", ".vcf", ".fasta", - ".fa", ".fna", ".fas", ".csv", ".tsv", ".txt", ".gz", ".bgz", + ".g.vcf.gz", + ".vcf.gz", + ".vcf.bgz", + ".fasta.gz", + ".fa.gz", + ".fna.gz", + ".bam", + ".cram", + ".vcf", + ".fasta", + ".fa", + ".fna", + ".fas", + ".csv", + ".tsv", + ".txt", + ".gz", + ".bgz", ]; let name = paths.first()?.file_name()?.to_str()?; let lower = name.to_ascii_lowercase(); @@ -898,7 +916,10 @@ mod tests { #[test] fn derives_subject_name_from_file_stem() { assert_eq!(name("/data/HG002.bam").as_deref(), Some("HG002")); - assert_eq!(name("HG00096.chm13.chrY.g.vcf.gz").as_deref(), Some("HG00096.chm13.chrY")); + assert_eq!( + name("HG00096.chm13.chrY.g.vcf.gz").as_deref(), + Some("HG00096.chm13.chrY") + ); assert_eq!(name("MyKit.vcf.gz").as_deref(), Some("MyKit")); assert_eq!(name("genome_Full.CRAM").as_deref(), Some("genome_Full")); // extension match is case-insensitive assert_eq!(name("relative.fasta").as_deref(), Some("relative")); diff --git a/crates/navigator-ui/src/ui/descent.rs b/crates/navigator-ui/src/ui/descent.rs index 9551d7b2..766b8aec 100644 --- a/crates/navigator-ui/src/ui/descent.rs +++ b/crates/navigator-ui/src/ui/descent.rs @@ -196,7 +196,11 @@ impl NavigatorApp { .color(egui::Color32::WHITE) .strong(), ); - ui.label(egui::RichText::new(format!("{d}/{t} {}", self.tr("descent.derivedShort"))).weak().small()); + ui.label( + egui::RichText::new(format!("{d}/{t} {}", self.tr("descent.derivedShort"))) + .weak() + .small(), + ); }); ui.horizontal_wrapped(|ui| { for s in &node.snps { @@ -224,7 +228,11 @@ impl NavigatorApp { /// (derived, total) defining-SNP counts for one node. fn node_counts(node: &navigator_app::NodeEvidence) -> (usize, usize) { - let derived = node.snps.iter().filter(|s| matches!(s.state, CallState::Derived)).count(); + let derived = node + .snps + .iter() + .filter(|s| matches!(s.state, CallState::Derived)) + .count(); (derived, node.snps.len()) } diff --git a/crates/navigator-ui/src/ui/detail.rs b/crates/navigator-ui/src/ui/detail.rs index 64247a1f..9c43e485 100644 --- a/crates/navigator-ui/src/ui/detail.rs +++ b/crates/navigator-ui/src/ui/detail.rs @@ -395,12 +395,12 @@ impl NavigatorApp { /// (ancient) breakdown is a frequency model and has no position in PC space. fn sample_pca(&self) -> Option<(f64, f64)> { [self.donor_ancestry.as_ref().map(|(_, r)| r)] - .into_iter() - .flatten() - .find_map(|r| { - let c = r.pca_coordinates.as_ref()?; - (c.len() >= 2).then(|| (c[0], c[1])) - }) + .into_iter() + .flatten() + .find_map(|r| { + let c = r.pca_coordinates.as_ref()?; + (c.len() >= 2).then(|| (c[0], c[1])) + }) } /// PCA scatter: the donor's PC1×PC2 against the reference population centroids. The donor's @@ -625,7 +625,14 @@ impl NavigatorApp { }; let mut filter = self.auto_profile_filter; let mut query = std::mem::take(&mut self.auto_profile_query); - draw_diploid_profile(ui, profile, &mut filter, &mut query, self.data_epoch, &mut self.auto_profile_rows); + draw_diploid_profile( + ui, + profile, + &mut filter, + &mut query, + self.data_epoch, + &mut self.auto_profile_rows, + ); self.auto_profile_filter = filter; self.auto_profile_query = query; } @@ -887,7 +894,11 @@ impl NavigatorApp { .weak() .small(), ); - self.publish_row(ui, "Publish subject to PDS", Command::PublishBiosample { biosample_guid: guid }); + self.publish_row( + ui, + "Publish subject to PDS", + Command::PublishBiosample { biosample_guid: guid }, + ); }); self.genealogy_card(ui, guid); } @@ -1012,7 +1023,11 @@ impl NavigatorApp { if ui.small_button("✎").on_hover_text(self.tr("geneal.editMdka")).clicked() { want_edit_mdka = Some(edit_from(mk)); } - if ui.small_button("✕").on_hover_text(self.tr("geneal.removeMdka")).clicked() { + if ui + .small_button("✕") + .on_hover_text(self.tr("geneal.removeMdka")) + .clicked() + { want_del_mdka = Some(lineage.to_string()); } }); @@ -1136,9 +1151,10 @@ impl NavigatorApp { let q = self.private_y_query.to_ascii_lowercase(); let bucket = self.donor_private_y.as_ref().unwrap(); let names = &self.y_snp_names; // catalogued Y-SNP name at a novel call's site, if any - // Filter to matching variants (position, off-path name, "novel", or the catalogued name); the - // table is bounded to a fixed-height scroll pane (a WGS bucket runs to thousands of rows). A - // hard cap keeps a pathological bucket from flooding even the pane. + + // Filter to matching variants (position, off-path name, "novel", or the catalogued name); the + // table is bounded to a fixed-height scroll pane (a WGS bucket runs to thousands of rows). A + // hard cap keeps a pathological bucket from flooding even the pane. const CAP: usize = 1000; let matched: Vec<_> = bucket .variants diff --git a/crates/navigator-ui/src/ui/events.rs b/crates/navigator-ui/src/ui/events.rs index 0e2585ca..c8e02cd1 100644 --- a/crates/navigator-ui/src/ui/events.rs +++ b/crates/navigator-ui/src/ui/events.rs @@ -354,8 +354,11 @@ impl NavigatorApp { // `.take()` so it applies once; a stale GUID (deleted subject) simply no-ops. if self.selected_sample.is_none() { if let Some(guid_str) = self.pending_restore_subject.take() { - if let Some(guid) = - self.all_biosamples.iter().find(|b| b.guid.0.to_string() == guid_str).map(|b| b.guid) + if let Some(guid) = self + .all_biosamples + .iter() + .find(|b| b.guid.0.to_string() == guid_str) + .map(|b| b.guid) { self.select_sample(guid); } @@ -544,10 +547,8 @@ impl NavigatorApp { if self.selected_sample == Some(biosample_guid) { self.roh_running = false; if let Some(r) = &result { - self.status = format!( - "ROH: {} segments, F_ROH {:.3}", - r.summary.n_segments, r.summary.f_roh - ); + self.status = + format!("ROH: {} segments, F_ROH {:.3}", r.summary.n_segments, r.summary.f_roh); } self.roh = result.map(|b| *b); } @@ -797,11 +798,13 @@ impl NavigatorApp { } self.y_profile = profile; self.y_snp_names_requested = false; // re-resolve names incl. the new positions - // A rebuild re-places the genome consensus (consensus_label); refresh the - // Overview's cached Y/mt consensus so it doesn't lag until the next reload. + + // A rebuild re-places the genome consensus (consensus_label); refresh the + // Overview's cached Y/mt consensus so it doesn't lag until the next reload. let _ = self.tx.send(Command::LoadConsensus(biosample_guid)); // The descent report is drawn from this profile — drop its cache so it rebuilds. - self.descent_reports.retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Y)); + self.descent_reports + .retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Y)); } } Event::YSnpNames { names } => { @@ -820,7 +823,8 @@ impl NavigatorApp { // A rebuild re-places the mt genome consensus; refresh the Overview's cache. let _ = self.tx.send(Command::LoadConsensus(biosample_guid)); // The descent report is drawn from this profile — drop its cache so it rebuilds. - self.descent_reports.retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Mt)); + self.descent_reports + .retain(|(g, d, _)| !(*g == biosample_guid && *d == DnaType::Mt)); } } Event::AutosomalProfile { @@ -1010,13 +1014,15 @@ impl NavigatorApp { // A candidate that became a request is no longer a candidate. let requested: std::collections::HashSet<&str> = entries.iter().filter_map(|e| e.partner_sample_ref.as_deref()).collect(); - self.ibd_suggestions.retain(|s| !requested.contains(s.suggested_sample_guid.as_str())); + self.ibd_suggestions + .retain(|s| !requested.contains(s.suggested_sample_guid.as_str())); self.matching = entries; } Event::CandidateDismissed { suggested_sample_guid } => { self.exchange_busy = false; self.status = self.tr("matching.dismissed").to_string(); - self.ibd_suggestions.retain(|s| s.suggested_sample_guid != suggested_sample_guid); + self.ibd_suggestions + .retain(|s| s.suggested_sample_guid != suggested_sample_guid); self.dismissed_candidates.insert(suggested_sample_guid); } Event::IbdExchangeDone { diff --git a/crates/navigator-ui/src/ui/mod.rs b/crates/navigator-ui/src/ui/mod.rs index 1f88c41c..ba3f3818 100644 --- a/crates/navigator-ui/src/ui/mod.rs +++ b/crates/navigator-ui/src/ui/mod.rs @@ -16,14 +16,13 @@ use crate::widgets::{ }; use eframe::egui; use navigator_app::{ - AncestryResult, AppSettings, AuditEntry, BatchImportSummary, BuildNeed, CallState, ChatTurn, - CompatibilityLevel, Consensus, Coverage, DenovoCall, DescentReport, DnaType, FtdnaGenealogy, FtdnaImportPlan, - FtdnaResolution, + AncestryResult, AppSettings, AuditEntry, BatchImportSummary, BuildNeed, CallState, ChatTurn, CompatibilityLevel, + Consensus, Coverage, DenovoCall, DescentReport, DnaType, FtdnaGenealogy, FtdnaImportPlan, FtdnaResolution, HaploAssignment, HeteroplasmySite, IbdComparison, IbdSuggestion, IdentityVerification, LineageBrief, LineageKind, - MatchKind, MatchStrength, MtRegion, MtVariant, NarratedBrief, PackStatus, PaintingResult, PrivateBucket, PrivateClass, - ProjectOverview, ProjectSampleReport, ProjectStrChart, ReadMetrics, RefBuildStatus, SexInferenceResult, - SignalKind, SnpEvidence, SourceType, StrConcordanceRow, SubjectAnalysisStatus, SubjectBrief, SvAnalysisResult, - UiMode, VerificationStatus, YMatch, YProfile, YSignal, YState, YVariantStatus, YstrClustering, + MatchKind, MatchStrength, MtRegion, MtVariant, NarratedBrief, PackStatus, PaintingResult, PrivateBucket, + PrivateClass, ProjectOverview, ProjectSampleReport, ProjectStrChart, ReadMetrics, RefBuildStatus, + SexInferenceResult, SignalKind, SnpEvidence, SourceType, StrConcordanceRow, SubjectAnalysisStatus, SubjectBrief, + SvAnalysisResult, UiMode, VerificationStatus, YMatch, YProfile, YSignal, YState, YVariantStatus, YstrClustering, }; use navigator_domain::chipprofile::{self, ChipProfile}; use navigator_domain::du_domain::ids::SampleGuid; @@ -1190,6 +1189,7 @@ impl NavigatorApp { let _ = tx.send(Command::BackfillLabs); // resolve labs for runs imported before D8 landed let _ = tx.send(Command::VerifySourceFiles); // flag any imported file that moved/disappeared let _ = tx.send(Command::LoadAssetStatus); // ancestry/IBD "data sources" line + // Check for a newer installer at startup (unless the user opted out). Non-fatal — a failed // check just logs to the status line; the app never auto-updates. // One read of settings.json for the whole constructor — it was loaded six separate times. @@ -1197,7 +1197,7 @@ impl NavigatorApp { if settings.check_for_updates != Some(false) { let _ = tx.send(Command::CheckForUpdate); } - // Persisted theme wins; default dark. (Must match `dark_mode` below.) + // Persisted theme wins; default dark. (Must match `dark_mode` below.) let dark = !matches!(settings.theme.as_deref(), Some("light")); apply_theme(&cc.egui_ctx, dark); // Persisted UI scale (egui zoom) — fixes tiny text on a native-4K display the OS reports at @@ -1208,7 +1208,11 @@ impl NavigatorApp { // (nav is then reconciled to the interface mode by `normalize_for_mode`). Seed `saved_ui_sig` // with the restored intent so a matching restore doesn't trigger a redundant re-save. let restore = &settings; - let restored_nav = restore.last_nav.as_deref().and_then(Nav::from_key).unwrap_or(Nav::Subjects); + let restored_nav = restore + .last_nav + .as_deref() + .and_then(Nav::from_key) + .unwrap_or(Nav::Subjects); let restored_tab = restore .last_detail_tab .as_deref() @@ -2212,7 +2216,10 @@ mod window_geometry_tests { let mon = [1440.0, 900.0]; let got = fit_window_to_monitor([3000.0, 2000.0], mon, MIN_WINDOW); assert!(got[0] <= mon[0] && got[1] <= mon[1], "must fit: {got:?} in {mon:?}"); - assert!(got[0] <= mon[0] * 0.98 + 0.5 && got[1] <= mon[1] * 0.94 + 0.5, "margin respected"); + assert!( + got[0] <= mon[0] * 0.98 + 0.5 && got[1] <= mon[1] * 0.94 + 0.5, + "margin respected" + ); } #[test] @@ -2277,7 +2284,7 @@ mod icon_glyph_tests { use super::SimplePanel; use ab_glyph::{Font, FontRef}; - /// True when at least one font in `Proportional`'s fallback chain has a glyph for `c`. + /// True when at least one font in `Proportional`'s fallback chain has a glyph for `c`. /// /// Reads egui's own `FontDefinitions::default()` rather than a vendored copy of the `.ttf`s, so /// the test keeps testing the fonts the app actually ships as egui is upgraded. `glyph_id` @@ -2296,7 +2303,11 @@ mod icon_glyph_tests { // Guards the test itself: if these ever start reporting renderable, the check has broken // rather than the fonts having improved. for c in ['◆', '⚭', '✓', '🧬'] { - assert!(!renderable(c), "{c} (U+{:04X}) should be missing from Proportional", c as u32); + assert!( + !renderable(c), + "{c} (U+{:04X}) should be missing from Proportional", + c as u32 + ); } assert!(renderable('♂'), "sanity: ♂ is present"); } diff --git a/crates/navigator-ui/src/ui/modals.rs b/crates/navigator-ui/src/ui/modals.rs index aa3facaf..a907e9bf 100644 --- a/crates/navigator-ui/src/ui/modals.rs +++ b/crates/navigator-ui/src/ui/modals.rs @@ -238,11 +238,19 @@ impl NavigatorApp { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(self.tr("mdka.birth")); - ui.add(egui::TextEdit::singleline(&mut edit.birth_year).hint_text("e.g. 1830").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.birth_year) + .hint_text("e.g. 1830") + .desired_width(150.0), + ); }); ui.vertical(|ui| { ui.label(self.tr("mdka.death")); - ui.add(egui::TextEdit::singleline(&mut edit.death_year).hint_text("e.g. 1908").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.death_year) + .hint_text("e.g. 1908") + .desired_width(150.0), + ); }); }); ui.add_space(4.0); @@ -251,11 +259,19 @@ impl NavigatorApp { ui.horizontal(|ui| { ui.vertical(|ui| { ui.label(self.tr("mdka.lat")); - ui.add(egui::TextEdit::singleline(&mut edit.latitude).hint_text("e.g. 52.75").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.latitude) + .hint_text("e.g. 52.75") + .desired_width(150.0), + ); }); ui.vertical(|ui| { ui.label(self.tr("mdka.lon")); - ui.add(egui::TextEdit::singleline(&mut edit.longitude).hint_text("e.g. -9.43").desired_width(150.0)); + ui.add( + egui::TextEdit::singleline(&mut edit.longitude) + .hint_text("e.g. -9.43") + .desired_width(150.0), + ); }); }); ui.add_space(4.0); @@ -332,11 +348,7 @@ impl NavigatorApp { // modals here. let (mut close, mut copy) = (false, false); modal_frame(ctx, "diagnosis_modal", 640.0, |ui| { - ui.label( - egui::RichText::new(self.tr("diagnosis.title")) - .strong() - .size(16.0), - ); + ui.label(egui::RichText::new(self.tr("diagnosis.title")).strong().size(16.0)); ui.label(egui::RichText::new(self.tr("diagnosis.subtitle")).weak()); ui.separator(); egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { @@ -413,340 +425,364 @@ impl NavigatorApp { settings_tab = self.sub_bar(ui, settings_tab, &SettingsTab::ALL); egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { match settings_tab { - SettingsTab::General => { - // --- Appearance --- - ui.horizontal(|ui| { - ui.label(self.tr("settings.theme")); - ui.selectable_value(&mut theme_dark, true, self.tr("settings.dark")); - ui.selectable_value(&mut theme_dark, false, self.tr("settings.light")); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.uiScale")); - let scale_resp = ui.add( - egui::Slider::new(&mut form.ui_scale, 0.8..=2.5) - .step_by(0.05) - .fixed_decimals(2), - ); - scale_dragging = scale_resp.dragged(); - if ui.small_button("100%").clicked() { - form.ui_scale = 1.0; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.language")); - egui::ComboBox::from_id_salt("settings_lang") - .selected_text(lang.label()) - .show_ui(ui, |ui| { - for &l in crate::i18n::Lang::all() { - ui.selectable_value(&mut lang, l, l.label()); + SettingsTab::General => { + // --- Appearance --- + ui.horizontal(|ui| { + ui.label(self.tr("settings.theme")); + ui.selectable_value(&mut theme_dark, true, self.tr("settings.dark")); + ui.selectable_value(&mut theme_dark, false, self.tr("settings.light")); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.uiScale")); + let scale_resp = ui.add( + egui::Slider::new(&mut form.ui_scale, 0.8..=2.5) + .step_by(0.05) + .fixed_decimals(2), + ); + scale_dragging = scale_resp.dragged(); + if ui.small_button("100%").clicked() { + form.ui_scale = 1.0; } }); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.interfaceMode")); - ui.selectable_value(&mut ui_mode, UiMode::Simple, self.tr("settings.modeSimple")); - ui.selectable_value(&mut ui_mode, UiMode::Advanced, self.tr("settings.modeAdvanced")); - }); - } - - SettingsTab::Connection => { - ui.horizontal(|ui| { - ui.label(self.tr("settings.appviewUrl")); - ui.add( - egui::TextEdit::singleline(&mut form.appview_url) - .hint_text("https://decoding-us.org") - .desired_width(320.0), - ); - }); - ui.label( - egui::RichText::new(self.tr("settings.appviewUrlHint")) - .weak() - .small(), - ); - ui.horizontal(|ui| { - ui.label(self.tr("settings.yTreeProvider")); - let cur = if form.y_tree_provider.eq_ignore_ascii_case("ftdna") { - "FTDNA" - } else { - "Decoding-Us" - }; - egui::ComboBox::from_id_salt("settings_y_provider") - .selected_text(cur) - .show_ui(ui, |ui| { - ui.selectable_value(&mut form.y_tree_provider, "decodingus".to_string(), "Decoding-Us"); - ui.selectable_value(&mut form.y_tree_provider, "ftdna".to_string(), "FTDNA"); + ui.horizontal(|ui| { + ui.label(self.tr("settings.language")); + egui::ComboBox::from_id_salt("settings_lang") + .selected_text(lang.label()) + .show_ui(ui, |ui| { + for &l in crate::i18n::Lang::all() { + ui.selectable_value(&mut lang, l, l.label()); + } + }); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.interfaceMode")); + ui.selectable_value(&mut ui_mode, UiMode::Simple, self.tr("settings.modeSimple")); + ui.selectable_value(&mut ui_mode, UiMode::Advanced, self.tr("settings.modeAdvanced")); }); - }); - ui.horizontal(|ui| { - if ui.button(self.tr("settings.refreshTrees")).clicked() { - refresh_trees = true; } - ui.label(egui::RichText::new(self.tr("settings.refreshTreesHint")).weak().small()); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.treeTtl")); - ui.add(egui::TextEdit::singleline(&mut form.tree_ttl_days).desired_width(60.0)); - }); - } - SettingsTab::Ancestry => { - // --- Chromosome painter (copying-LAI) calibration --- - // Reset buttons restore the painter's calibrated defaults (see `lai_knob_defaults`). - let lai = navigator_app::lai_knob_defaults(); - ui.label(egui::RichText::new(self.tr("settings.painterHint")).small().weak()); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.recomb")); - ui.add(egui::Slider::new(&mut form.lai_recomb_per_cm, 0.02..=3.0).step_by(0.01).fixed_decimals(2)); - if ui.small_button(format!("{:.2}", lai.recomb_per_cm)).clicked() { - form.lai_recomb_per_cm = lai.recomb_per_cm; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.cap")); - ui.add(egui::Slider::new(&mut form.lai_max_ref_haps, 10..=400)); - if ui.small_button(lai.max_ref_haps.to_string()).clicked() { - form.lai_max_ref_haps = lai.max_ref_haps; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.gate")); - ui.add(egui::Slider::new(&mut form.lai_min_ancestry, 0.0..=0.20).step_by(0.005).fixed_decimals(3)); - if ui.small_button(format!("{:.2}", lai.min_ancestry)).clicked() { - form.lai_min_ancestry = lai.min_ancestry; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.switch")); - ui.add(egui::Slider::new(&mut form.lai_switch_per_cm, 0.01..=0.5).step_by(0.01).fixed_decimals(2)); - if ui.small_button(format!("{:.2}", lai.switch_per_cm)).clicked() { - form.lai_switch_per_cm = lai.switch_per_cm; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.minSeg")); - ui.add(egui::Slider::new(&mut form.lai_min_segment_cm, 0.5..=12.0).step_by(0.5).fixed_decimals(1)); - if ui.small_button(format!("{:.1}", lai.min_segment_cm)).clicked() { - form.lai_min_segment_cm = lai.min_segment_cm; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.sizeNorm")); - ui.add(egui::Slider::new(&mut form.lai_size_normalize, 0.0..=1.0).step_by(0.05).fixed_decimals(2)); - if ui.small_button(format!("{:.2}", lai.size_normalize)).clicked() { - form.lai_size_normalize = lai.size_normalize; - } - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.painter.mismatch")); - ui.add(egui::Slider::new(&mut form.lai_mismatch, 0.005..=0.10).step_by(0.005).fixed_decimals(3)); - if ui.small_button(format!("{:.3}", lai.mismatch)).clicked() { - form.lai_mismatch = lai.mismatch; + SettingsTab::Connection => { + ui.horizontal(|ui| { + ui.label(self.tr("settings.appviewUrl")); + ui.add( + egui::TextEdit::singleline(&mut form.appview_url) + .hint_text("https://decoding-us.org") + .desired_width(320.0), + ); + }); + ui.label(egui::RichText::new(self.tr("settings.appviewUrlHint")).weak().small()); + ui.horizontal(|ui| { + ui.label(self.tr("settings.yTreeProvider")); + let cur = if form.y_tree_provider.eq_ignore_ascii_case("ftdna") { + "FTDNA" + } else { + "Decoding-Us" + }; + egui::ComboBox::from_id_salt("settings_y_provider") + .selected_text(cur) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.y_tree_provider, + "decodingus".to_string(), + "Decoding-Us", + ); + ui.selectable_value(&mut form.y_tree_provider, "ftdna".to_string(), "FTDNA"); + }); + }); + ui.horizontal(|ui| { + if ui.button(self.tr("settings.refreshTrees")).clicked() { + refresh_trees = true; + } + ui.label(egui::RichText::new(self.tr("settings.refreshTreesHint")).weak().small()); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.treeTtl")); + ui.add(egui::TextEdit::singleline(&mut form.tree_ttl_days).desired_width(60.0)); + }); } - }); - ui.label(egui::RichText::new(self.tr("settings.painterApply")).small().weak()); - } - SettingsTab::Ai => { - // --- AI assistant (local LLM) --- - ui.checkbox(&mut form.llm_enabled, self.tr("settings.ai.enable")); - ui.add_enabled_ui(form.llm_enabled, |ui| { - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.baseUrl")); - ui.add( - egui::TextEdit::singleline(&mut form.llm_base_url) - .hint_text(navigator_app::llm::DEFAULT_LLM_BASE_URL) - .desired_width(300.0), - ); - }); - // Quick-pick host ports. - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.presets")); - if ui.small_button("LM Studio").clicked() { - form.llm_base_url = "http://localhost:1234/v1".into(); - } - if ui.small_button("Ollama").clicked() { - form.llm_base_url = "http://localhost:11434/v1".into(); - } - if ui.small_button("llama.cpp").clicked() { - form.llm_base_url = "http://localhost:8080/v1".into(); - } - }); - ui.horizontal(|ui| { - if ui - .add_enabled(!self.llm_testing, egui::Button::new(self.tr("settings.ai.test"))) - .clicked() - { - test_llm = Some(form.llm_base_url.trim().to_string()); - } - if self.llm_testing { - ui.spinner(); - } - if let Some(msg) = &self.llm_test_msg { - ui.label(egui::RichText::new(msg).weak().small()); - } - }); - // Model picker — populated by a successful Test connection. - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.model")); - let current = if form.llm_model.is_empty() { - self.tr("settings.ai.modelAuto").to_string() - } else { - form.llm_model.clone() - }; - egui::ComboBox::from_id_salt("settings_llm_model") - .selected_text(current) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut form.llm_model, - String::new(), - self.tr("settings.ai.modelAuto"), - ); - for m in &self.llm_models { - ui.selectable_value(&mut form.llm_model, m.clone(), m); - } - }); - }); - ui.horizontal(|ui| { - ui.label(self.tr("settings.ai.maxTokens")); - ui.add(egui::TextEdit::singleline(&mut form.llm_max_tokens).desired_width(80.0)); - ui.label(egui::RichText::new(self.tr("settings.ai.maxTokensHint")).weak().small()); - }); - // Privacy line — turns to a warning for a non-loopback URL. - if navigator_app::llm::is_loopback_url(&form.llm_base_url) { - ui.label(egui::RichText::new(self.tr("settings.ai.local")).weak().small()); - } else { - ui.label( - egui::RichText::new(self.tr("settings.ai.remoteWarn")) - .small() - .color(egui::Color32::from_rgb(230, 170, 80)), - ); + SettingsTab::Ancestry => { + // --- Chromosome painter (copying-LAI) calibration --- + // Reset buttons restore the painter's calibrated defaults (see `lai_knob_defaults`). + let lai = navigator_app::lai_knob_defaults(); + ui.label(egui::RichText::new(self.tr("settings.painterHint")).small().weak()); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.recomb")); + ui.add( + egui::Slider::new(&mut form.lai_recomb_per_cm, 0.02..=3.0) + .step_by(0.01) + .fixed_decimals(2), + ); + if ui.small_button(format!("{:.2}", lai.recomb_per_cm)).clicked() { + form.lai_recomb_per_cm = lai.recomb_per_cm; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.cap")); + ui.add(egui::Slider::new(&mut form.lai_max_ref_haps, 10..=400)); + if ui.small_button(lai.max_ref_haps.to_string()).clicked() { + form.lai_max_ref_haps = lai.max_ref_haps; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.gate")); + ui.add( + egui::Slider::new(&mut form.lai_min_ancestry, 0.0..=0.20) + .step_by(0.005) + .fixed_decimals(3), + ); + if ui.small_button(format!("{:.2}", lai.min_ancestry)).clicked() { + form.lai_min_ancestry = lai.min_ancestry; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.switch")); + ui.add( + egui::Slider::new(&mut form.lai_switch_per_cm, 0.01..=0.5) + .step_by(0.01) + .fixed_decimals(2), + ); + if ui.small_button(format!("{:.2}", lai.switch_per_cm)).clicked() { + form.lai_switch_per_cm = lai.switch_per_cm; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.minSeg")); + ui.add( + egui::Slider::new(&mut form.lai_min_segment_cm, 0.5..=12.0) + .step_by(0.5) + .fixed_decimals(1), + ); + if ui.small_button(format!("{:.1}", lai.min_segment_cm)).clicked() { + form.lai_min_segment_cm = lai.min_segment_cm; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.sizeNorm")); + ui.add( + egui::Slider::new(&mut form.lai_size_normalize, 0.0..=1.0) + .step_by(0.05) + .fixed_decimals(2), + ); + if ui.small_button(format!("{:.2}", lai.size_normalize)).clicked() { + form.lai_size_normalize = lai.size_normalize; + } + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.painter.mismatch")); + ui.add( + egui::Slider::new(&mut form.lai_mismatch, 0.005..=0.10) + .step_by(0.005) + .fixed_decimals(3), + ); + if ui.small_button(format!("{:.3}", lai.mismatch)).clicked() { + form.lai_mismatch = lai.mismatch; + } + }); + ui.label(egui::RichText::new(self.tr("settings.painterApply")).small().weak()); } - }); - } - SettingsTab::References => { - // --- Reference genomes --- - ui.checkbox(&mut form.prompt_before_download, self.tr("settings.promptDownload")); - egui::Grid::new("settings_refs") - .striped(true) - .num_columns(5) - .show(ui, |ui| { - for h in [ - "settings.build", - "settings.status", - "settings.localFasta", - "settings.autoDownload", - "settings.integrity", - ] { - ui.strong(self.tr(h)); - } - ui.end_row(); - for row in &mut form.references { - ui.label(&row.build); - ui.label(egui::RichText::new(&row.status).weak()); + SettingsTab::Ai => { + // --- AI assistant (local LLM) --- + ui.checkbox(&mut form.llm_enabled, self.tr("settings.ai.enable")); + ui.add_enabled_ui(form.llm_enabled, |ui| { ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.baseUrl")); ui.add( - egui::TextEdit::singleline(&mut row.local_path) - .hint_text("(none)") - .desired_width(180.0), + egui::TextEdit::singleline(&mut form.llm_base_url) + .hint_text(navigator_app::llm::DEFAULT_LLM_BASE_URL) + .desired_width(300.0), ); - if ui.button("📂").on_hover_text(self.tr("settings.browse")).clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("FASTA", &["fa", "fasta", "fna", "gz"]) - .pick_file() - { - row.local_path = p.display().to_string(); - } + }); + // Quick-pick host ports. + ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.presets")); + if ui.small_button("LM Studio").clicked() { + form.llm_base_url = "http://localhost:1234/v1".into(); + } + if ui.small_button("Ollama").clicked() { + form.llm_base_url = "http://localhost:11434/v1".into(); + } + if ui.small_button("llama.cpp").clicked() { + form.llm_base_url = "http://localhost:8080/v1".into(); } }); - ui.checkbox(&mut row.auto_download, ""); ui.horizontal(|ui| { - if ui.small_button(self.tr("settings.verify")).clicked() { - verify_build = Some(row.build.clone()); + if ui + .add_enabled(!self.llm_testing, egui::Button::new(self.tr("settings.ai.test"))) + .clicked() + { + test_llm = Some(form.llm_base_url.trim().to_string()); + } + if self.llm_testing { + ui.spinner(); } - if !row.verify.is_empty() { - ui.label(egui::RichText::new(&row.verify).small().weak()); + if let Some(msg) = &self.llm_test_msg { + ui.label(egui::RichText::new(msg).weak().small()); } }); - ui.end_row(); - } - }); - if form.references.is_empty() { - ui.label(egui::RichText::new(self.tr("settings.loadingRefs")).weak()); - } - } + // Model picker — populated by a successful Test connection. + ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.model")); + let current = if form.llm_model.is_empty() { + self.tr("settings.ai.modelAuto").to_string() + } else { + form.llm_model.clone() + }; + egui::ComboBox::from_id_salt("settings_llm_model") + .selected_text(current) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut form.llm_model, + String::new(), + self.tr("settings.ai.modelAuto"), + ); + for m in &self.llm_models { + ui.selectable_value(&mut form.llm_model, m.clone(), m); + } + }); + }); + ui.horizontal(|ui| { + ui.label(self.tr("settings.ai.maxTokens")); + ui.add(egui::TextEdit::singleline(&mut form.llm_max_tokens).desired_width(80.0)); + ui.label(egui::RichText::new(self.tr("settings.ai.maxTokensHint")).weak().small()); + }); + // Privacy line — turns to a warning for a non-loopback URL. + if navigator_app::llm::is_loopback_url(&form.llm_base_url) { + ui.label(egui::RichText::new(self.tr("settings.ai.local")).weak().small()); + } else { + ui.label( + egui::RichText::new(self.tr("settings.ai.remoteWarn")) + .small() + .color(egui::Color32::from_rgb(230, 170, 80)), + ); + } + }); + } - SettingsTab::Tools => { - // --- Tools: VCF liftover --- - ui.label(egui::RichText::new(self.tr("liftvcf.title")).strong()); - ui.label(egui::RichText::new(self.tr("liftvcf.hint")).weak().small()); - ui.horizontal(|ui| { - ui.label(self.tr("liftvcf.input")); - ui.add( - egui::TextEdit::singleline(&mut form.lift_in) - .hint_text("input.vcf[.gz]") - .desired_width(260.0), - ); - if ui.button("📂").clicked() { - if let Some(p) = rfd::FileDialog::new().add_filter("VCF", &["vcf", "gz"]).pick_file() { - form.lift_in = p.display().to_string(); + SettingsTab::References => { + // --- Reference genomes --- + ui.checkbox(&mut form.prompt_before_download, self.tr("settings.promptDownload")); + egui::Grid::new("settings_refs") + .striped(true) + .num_columns(5) + .show(ui, |ui| { + for h in [ + "settings.build", + "settings.status", + "settings.localFasta", + "settings.autoDownload", + "settings.integrity", + ] { + ui.strong(self.tr(h)); + } + ui.end_row(); + for row in &mut form.references { + ui.label(&row.build); + ui.label(egui::RichText::new(&row.status).weak()); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut row.local_path) + .hint_text("(none)") + .desired_width(180.0), + ); + if ui.button("📂").on_hover_text(self.tr("settings.browse")).clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("FASTA", &["fa", "fasta", "fna", "gz"]) + .pick_file() + { + row.local_path = p.display().to_string(); + } + } + }); + ui.checkbox(&mut row.auto_download, ""); + ui.horizontal(|ui| { + if ui.small_button(self.tr("settings.verify")).clicked() { + verify_build = Some(row.build.clone()); + } + if !row.verify.is_empty() { + ui.label(egui::RichText::new(&row.verify).small().weak()); + } + }); + ui.end_row(); + } + }); + if form.references.is_empty() { + ui.label(egui::RichText::new(self.tr("settings.loadingRefs")).weak()); } } - }); - ui.horizontal(|ui| { - ui.label(self.tr("liftvcf.target")); - egui::ComboBox::from_id_salt("liftvcf_target") - .selected_text(&form.lift_target) - .show_ui(ui, |ui| { - for b in ["chm13v2.0", "GRCh38", "GRCh37"] { - ui.selectable_value(&mut form.lift_target, b.to_string(), b); + + SettingsTab::Tools => { + // --- Tools: VCF liftover --- + ui.label(egui::RichText::new(self.tr("liftvcf.title")).strong()); + ui.label(egui::RichText::new(self.tr("liftvcf.hint")).weak().small()); + ui.horizontal(|ui| { + ui.label(self.tr("liftvcf.input")); + ui.add( + egui::TextEdit::singleline(&mut form.lift_in) + .hint_text("input.vcf[.gz]") + .desired_width(260.0), + ); + if ui.button("📂").clicked() { + if let Some(p) = rfd::FileDialog::new().add_filter("VCF", &["vcf", "gz"]).pick_file() { + form.lift_in = p.display().to_string(); + } } }); - ui.checkbox(&mut form.lift_filter_par, self.tr("liftvcf.filterPar")); - }); - ui.horizontal(|ui| { - ui.label(self.tr("liftvcf.output")); - ui.add( - egui::TextEdit::singleline(&mut form.lift_out) - .hint_text("lifted.vcf[.gz]") - .desired_width(260.0), - ); - if ui.button("📂").clicked() { - if let Some(p) = rfd::FileDialog::new() - .add_filter("VCF", &["vcf", "gz"]) - .set_file_name("lifted.vcf") - .save_file() + ui.horizontal(|ui| { + ui.label(self.tr("liftvcf.target")); + egui::ComboBox::from_id_salt("liftvcf_target") + .selected_text(&form.lift_target) + .show_ui(ui, |ui| { + for b in ["chm13v2.0", "GRCh38", "GRCh37"] { + ui.selectable_value(&mut form.lift_target, b.to_string(), b); + } + }); + ui.checkbox(&mut form.lift_filter_par, self.tr("liftvcf.filterPar")); + }); + ui.horizontal(|ui| { + ui.label(self.tr("liftvcf.output")); + ui.add( + egui::TextEdit::singleline(&mut form.lift_out) + .hint_text("lifted.vcf[.gz]") + .desired_width(260.0), + ); + if ui.button("📂").clicked() { + if let Some(p) = rfd::FileDialog::new() + .add_filter("VCF", &["vcf", "gz"]) + .set_file_name("lifted.vcf") + .save_file() + { + form.lift_out = p.display().to_string(); + } + } + }); + let lift_ready = !form.lift_in.trim().is_empty() && !form.lift_out.trim().is_empty(); + if ui + .add_enabled(lift_ready, egui::Button::new(self.tr("liftvcf.run"))) + .clicked() { - form.lift_out = p.display().to_string(); + lift_request = true; } } - }); - let lift_ready = !form.lift_in.trim().is_empty() && !form.lift_out.trim().is_empty(); - if ui - .add_enabled(lift_ready, egui::Button::new(self.tr("liftvcf.run"))) - .clicked() - { - lift_request = true; - } - } - SettingsTab::Advanced => { - ui.checkbox(&mut form.prefer_external_calls, self.tr("settings.preferExternalCalls")); - ui.label( - egui::RichText::new(self.tr("settings.preferExternalCallsHint")) - .weak() - .small(), - ); - ui.label( - egui::RichText::new(format!( - "{}: {}", - self.tr("settings.cacheDir"), - AppSettings::cache_base_dir().display() - )) - .weak(), - ); - ui.label(egui::RichText::new(self.tr("settings.advancedEnv")).weak()); - } + SettingsTab::Advanced => { + ui.checkbox(&mut form.prefer_external_calls, self.tr("settings.preferExternalCalls")); + ui.label( + egui::RichText::new(self.tr("settings.preferExternalCallsHint")) + .weak() + .small(), + ); + ui.label( + egui::RichText::new(format!( + "{}: {}", + self.tr("settings.cacheDir"), + AppSettings::cache_base_dir().display() + )) + .weak(), + ); + ui.label(egui::RichText::new(self.tr("settings.advancedEnv")).weak()); + } } }); ui.separator(); @@ -1042,7 +1078,10 @@ impl NavigatorApp { ui.add_space(12.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add(egui::Button::new(egui::RichText::new(self.tr("update.download")).color(egui::Color32::WHITE)).fill(ACCENT)) + .add( + egui::Button::new(egui::RichText::new(self.tr("update.download")).color(egui::Color32::WHITE)) + .fill(ACCENT), + ) .clicked() { let url = info.download_url.clone().unwrap_or_else(|| info.release_url.clone()); @@ -1816,23 +1855,36 @@ impl NavigatorApp { /// the encrypted channel. Neither is undoable. The three headings below are the whole point of /// the dialog — what we send, what they learn, and what never leaves the device. pub(crate) fn consent_modal(&mut self, ctx: &egui::Context) { - let Some(entry) = self.consent_prompt.clone() else { return }; + let Some(entry) = self.consent_prompt.clone() else { + return; + }; let mut decision: Option = None; let mut close = false; modal_frame(ctx, "matching_consent_modal", 480.0, |ui| { - ui.label(egui::RichText::new(self.tr("matching.consent.title")).strong().size(16.0)); + ui.label( + egui::RichText::new(self.tr("matching.consent.title")) + .strong() + .size(16.0), + ); ui.separator(); ui.add_space(8.0); ui.label(self.tr("matching.consent.body")); ui.add_space(8.0); - egui::Grid::new("consent_facts").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { - ui.strong(self.tr("matching.col.purpose")); - ui.label(if entry.purpose.is_empty() { "—" } else { &entry.purpose }); - ui.end_row(); - ui.strong(self.tr("matching.consent.request")); - ui.label(egui::RichText::new(&entry.request_uri).small()); - ui.end_row(); - }); + egui::Grid::new("consent_facts") + .num_columns(2) + .spacing([12.0, 4.0]) + .show(ui, |ui| { + ui.strong(self.tr("matching.col.purpose")); + ui.label(if entry.purpose.is_empty() { + "—" + } else { + &entry.purpose + }); + ui.end_row(); + ui.strong(self.tr("matching.consent.request")); + ui.label(egui::RichText::new(&entry.request_uri).small()); + ui.end_row(); + }); ui.add_space(10.0); for (title, body) in [ ("matching.consent.sendTitle", "matching.consent.sendBody"), diff --git a/crates/navigator-ui/src/ui/rowcache.rs b/crates/navigator-ui/src/ui/rowcache.rs index 4674d817..4aca3e8d 100644 --- a/crates/navigator-ui/src/ui/rowcache.rs +++ b/crates/navigator-ui/src/ui/rowcache.rs @@ -318,7 +318,10 @@ mod tests { let before = seen.get(); cache.get(2, Some(YVariantStatus::Conflict), "rs1", &data, |v| v % 2 == 0); - assert!(seen.get() == before, "the status filter is part of the key, not the predicate here"); + assert!( + seen.get() == before, + "the status filter is part of the key, not the predicate here" + ); } /// Indices are only ever handed back for a collection of the length they were derived from — diff --git a/crates/navigator-ui/src/worker.rs b/crates/navigator-ui/src/worker.rs index 6996900e..1fa4b8aa 100644 --- a/crates/navigator-ui/src/worker.rs +++ b/crates/navigator-ui/src/worker.rs @@ -14,18 +14,14 @@ use std::sync::{Arc, Mutex}; use navigator_app::CancelToken; use navigator_app::{ - AnalysisStep, - AlignmentProbe, AncestryResult, App, AppError, AuditEntry, BatchImportSummary, BuildNeed, - ChatTurn, Consensus, Coverage, DenovoCall, DescentReport, DmConversationSummary, DmMessage, DnaType, - ExchangeSessionInfo, - FtdnaGenealogy, FtdnaImportOptions, FtdnaImportPlan, FtdnaImportSummary, FtdnaResolution, HaploAssignment, - HeteroplasmySite, IbdComparison, IbdDetectorConfig, IbdSuggestion, IdentityVerification, IncomingRequest, - MatchingEntry, - NarratedBrief, PaintingResult, PrivateBucket, ProjectImportSummary, ProjectOverview, ProjectSampleReport, - ArchaicMarkerResult, ArchaicSegmentResult, ProjectStrChart, ReadMetrics, RecruitmentInvitation, RefBuildStatus, RohResult, SexInferenceResult, - SignalKind, - SourceType, StoredIbdExchange, StrConcordanceRow, SubjectAnalysisStatus, SubjectBrief, SvAnalysisResult, YMatch, - YstrClustering, + AlignmentProbe, AnalysisStep, AncestryResult, App, AppError, ArchaicMarkerResult, ArchaicSegmentResult, AuditEntry, + BatchImportSummary, BuildNeed, ChatTurn, Consensus, Coverage, DenovoCall, DescentReport, DmConversationSummary, + DmMessage, DnaType, ExchangeSessionInfo, FtdnaGenealogy, FtdnaImportOptions, FtdnaImportPlan, FtdnaImportSummary, + FtdnaResolution, HaploAssignment, HeteroplasmySite, IbdComparison, IbdDetectorConfig, IbdSuggestion, + IdentityVerification, IncomingRequest, MatchingEntry, NarratedBrief, PaintingResult, PrivateBucket, + ProjectImportSummary, ProjectOverview, ProjectSampleReport, ProjectStrChart, ReadMetrics, RecruitmentInvitation, + RefBuildStatus, RohResult, SexInferenceResult, SignalKind, SourceType, StoredIbdExchange, StrConcordanceRow, + SubjectAnalysisStatus, SubjectBrief, SvAnalysisResult, YMatch, YstrClustering, }; use navigator_domain::chipprofile::ChipProfile; use navigator_domain::du_domain::ids::SampleGuid; @@ -76,7 +72,10 @@ pub enum Command { /// Build (off the UI thread) the plain-language Subject Brief for a subject (Simple mode). LoadSubjectBrief(SampleGuid), /// Build (off the UI thread) a YFull-style Y/mtDNA descent report for a subject. - LoadDescentReport { guid: SampleGuid, dna: DnaType }, + LoadDescentReport { + guid: SampleGuid, + dna: DnaType, + }, /// Build (off the UI thread) a per-marker branch report over `node`'s subtree for a subject. LoadBranchReport { guid: SampleGuid, @@ -93,7 +92,10 @@ pub enum Command { question: String, }, /// Explain a single result signal in plain language (per-tab "Explain this", M5). - NarrateSignal { guid: SampleGuid, kind: SignalKind }, + NarrateSignal { + guid: SampleGuid, + kind: SignalKind, + }, /// Deep-analyze every sample in a project as a cancellable background job, streaming /// per-sample `DeepAnalyzeProgress` and yielding between samples so the UI stays responsive. /// Skips what the fast path already filled; cancelled via [`Command::CancelAnalysis`]. @@ -1230,7 +1232,10 @@ pub enum Event { /// Separate from [`Event::Error`] so the UI can offer the report without having to guess, from /// a string, whether an error has one. Only emitted when the preflight actually failed a /// check — a tree-download or network error must not raise a file report. - Diagnosed { message: String, report: String }, + Diagnosed { + message: String, + report: String, + }, /// A run stopped because the user cancelled it. /// /// Distinct from both `Error` (this is not a failure) and `Noop` (which would leave the @@ -1325,7 +1330,10 @@ async fn settle_alignment_command(app: &App, alignment_id: i64, event: Event) -> /// [`Event::Genealogy`] — the refresh emitted after any genealogy mutation so the detail card /// reflects the new state without a separate "changed" round-trip. async fn reload_genealogy(app: &App, guid: SampleGuid) -> Event { - ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { guid, data }) + ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { + guid, + data, + }) } /// Map a fallible app call to an [`Event`]: `ok` names the success event, and **any** error becomes @@ -1358,9 +1366,7 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::RefreshTrees => ev(app.refresh_trees().await, Event::TreesRefreshed), Command::CreateProject(new) => ev(app.create_project(new).await, Event::ProjectCreated), // ImportProjectDir streams ImportProgress from the spawn loop; reaching here is a bug. - Command::ImportProjectDir { .. } => { - Event::Error("internal: unrouted ImportProjectDir".into()) - } + Command::ImportProjectDir { .. } => Event::Error("internal: unrouted ImportProjectDir".into()), Command::PlanFtdnaImport { project_id, project_name, @@ -1384,30 +1390,30 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::CommitFtdnaImport { plan, resolutions } => { ev(app.commit_ftdna_import(&plan, &resolutions).await, Event::FtdnaImported) } - Command::LoadGenealogy(guid) => ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { guid, data }), - Command::ClusterProject(project_id) => ev( - app.cluster_project_ystr(project_id).await, - |clustering| Event::ProjectClustering { project_id, clustering }, - ), + Command::LoadGenealogy(guid) => ev(app.subject_genealogy(guid).await, |data| Event::Genealogy { + guid, + data, + }), + Command::ClusterProject(project_id) => ev(app.cluster_project_ystr(project_id).await, |clustering| { + Event::ProjectClustering { project_id, clustering } + }), // ResolveReference is handled in the spawn loop (it streams progress events); reaching // here would mean a routing bug. Command::ResolveReference { build } => Event::Error(format!("internal: unrouted ResolveReference {build}")), - Command::LoadSamples(project_id) => { - ev(app.list_biosamples(project_id).await, |samples| Event::Samples { project_id, samples }) - } - Command::LoadProjectReport(project_id) => { - ev(app.project_report(project_id).await, |rows| Event::ProjectReport { project_id, rows }) - } - Command::LoadProjectStrChart(project_id) => { - ev(app.project_str_chart(project_id).await, |chart| Event::ProjectStrChart { project_id, chart }) - } - Command::LoadSubjectBrief(guid) => ev( - app.subject_brief(guid).await, - |brief| Event::SubjectBrief { - guid, - brief: Box::new(brief), - }, - ), + Command::LoadSamples(project_id) => ev(app.list_biosamples(project_id).await, |samples| Event::Samples { + project_id, + samples, + }), + Command::LoadProjectReport(project_id) => ev(app.project_report(project_id).await, |rows| { + Event::ProjectReport { project_id, rows } + }), + Command::LoadProjectStrChart(project_id) => ev(app.project_str_chart(project_id).await, |chart| { + Event::ProjectStrChart { project_id, chart } + }), + Command::LoadSubjectBrief(guid) => ev(app.subject_brief(guid).await, |brief| Event::SubjectBrief { + guid, + brief: Box::new(brief), + }), Command::LoadDescentReport { guid, dna } => Event::DescentReportLoaded { guid, dna, @@ -1432,13 +1438,11 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::LoadSubjectStatus => ev(app.subject_analysis_status().await, Event::SubjectStatus), Command::LoadHaploSummary => ev(app.haplogroup_terminals().await, Event::HaploSummary), Command::LoadAllBiosamples => ev(app.list_all_biosamples().await, Event::AllBiosamples), - Command::AddBiosample(b) => { - ev( - app.add_biosample(b.project_id, b.donor_identifier, b.sample_accession, b.sex) - .await, - |_| Event::BiosamplesChanged, - ) - } + Command::AddBiosample(b) => ev( + app.add_biosample(b.project_id, b.donor_identifier, b.sample_accession, b.sex) + .await, + |_| Event::BiosamplesChanged, + ), Command::UpdateBiosample { guid, donor_identifier, @@ -1446,19 +1450,19 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { description, center_name, sex, - } => { - ev( - app.update_biosample(guid, donor_identifier, sample_accession, description, center_name, sex) - .await, - |_| Event::BiosamplesChanged, - ) - } - Command::AddExternalId { guid, source, external_id } => { - match app.add_external_id(guid, &source, &external_id).await { - Ok(_) => reload_genealogy(app, guid).await, - Err(e) => Event::Error(e.to_string()), - } - } + } => ev( + app.update_biosample(guid, donor_identifier, sample_accession, description, center_name, sex) + .await, + |_| Event::BiosamplesChanged, + ), + Command::AddExternalId { + guid, + source, + external_id, + } => match app.add_external_id(guid, &source, &external_id).await { + Ok(_) => reload_genealogy(app, guid).await, + Err(e) => Event::Error(e.to_string()), + }, Command::DeleteExternalId { guid, id } => match app.delete_external_id(id).await { Ok(()) => reload_genealogy(app, guid).await, Err(e) => Event::Error(e.to_string()), @@ -1472,15 +1476,15 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Err(e) => Event::Error(e.to_string()), }, Command::DeleteBiosample(guid) => ev(app.delete_biosample(guid).await, |_| Event::BiosamplesChanged), - Command::ClearBiosampleData(guid) => { - ev(app.clear_biosample_data(guid).await, |_| Event::BiosampleDataCleared(guid)) - } - Command::ClearHaplogroupData(guid) => { - ev(app.clear_haplogroup_data(guid).await, |_| Event::HaplogroupDataReset(guid)) - } - Command::DeleteSequenceRun { id, biosample_guid } => { - ev(app.delete_sequence_run(id).await, |_| Event::RunsChanged(biosample_guid)) - } + Command::ClearBiosampleData(guid) => ev(app.clear_biosample_data(guid).await, |_| { + Event::BiosampleDataCleared(guid) + }), + Command::ClearHaplogroupData(guid) => ev(app.clear_haplogroup_data(guid).await, |_| { + Event::HaplogroupDataReset(guid) + }), + Command::DeleteSequenceRun { id, biosample_guid } => ev(app.delete_sequence_run(id).await, |_| { + Event::RunsChanged(biosample_guid) + }), Command::MergeSequenceRuns { biosample_guid, primary, @@ -1489,12 +1493,12 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { app.merge_sequence_runs(biosample_guid, primary, secondary).await, |_| Event::RunsChanged(biosample_guid), ), - Command::DeleteAlignment { id, sequence_run_id } => { - ev(app.delete_alignment(id).await, |_| Event::AlignmentsChanged(sequence_run_id)) - } - Command::DeleteStrProfile { id, biosample_guid } => { - ev(app.delete_str_profile(id).await, |_| Event::StrProfilesChanged(biosample_guid)) - } + Command::DeleteAlignment { id, sequence_run_id } => ev(app.delete_alignment(id).await, |_| { + Event::AlignmentsChanged(sequence_run_id) + }), + Command::DeleteStrProfile { id, biosample_guid } => ev(app.delete_str_profile(id).await, |_| { + Event::StrProfilesChanged(biosample_guid) + }), Command::LoadReferenceSettings => Event::ReferenceSettings(app.reference_settings()), Command::TestLlmConnection { base_url } => { Event::LlmConnection(app.llm_models_at(&base_url).await.map_err(|e| e.to_string())) @@ -1502,18 +1506,15 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::SetReferenceOverrides(rows) => { ev(app.set_reference_overrides(&rows), |_| Event::ReferenceSettingsChanged) } - Command::VerifyReference { build } => ev( - app.verify_reference(&build).await, - |outcome| { - let status = match outcome { - navigator_app::VerifyOutcome::Verified => "✓ verified".to_string(), - navigator_app::VerifyOutcome::Mismatch { .. } => "✗ mismatch (corrupted?)".to_string(), - navigator_app::VerifyOutcome::NoSidecar => "• no checksum on record".to_string(), - navigator_app::VerifyOutcome::NotCached => "not cached".to_string(), - }; - Event::ReferenceVerified { build, status } - }, - ), + Command::VerifyReference { build } => ev(app.verify_reference(&build).await, |outcome| { + let status = match outcome { + navigator_app::VerifyOutcome::Verified => "✓ verified".to_string(), + navigator_app::VerifyOutcome::Mismatch { .. } => "✗ mismatch (corrupted?)".to_string(), + navigator_app::VerifyOutcome::NoSidecar => "• no checksum on record".to_string(), + navigator_app::VerifyOutcome::NotCached => "not cached".to_string(), + }; + Event::ReferenceVerified { build, status } + }), Command::LiftVcf { source, target, @@ -1546,24 +1547,28 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } } } - Command::DeleteVariantSet { id, biosample_guid } => { - ev(app.delete_variant_set(id).await, |_| Event::VariantSetsChanged(biosample_guid)) - } - Command::DeleteChipProfile { id, biosample_guid } => { - ev(app.delete_chip_profile(id).await, |_| Event::ChipProfilesChanged(biosample_guid)) - } - Command::DeleteMtdnaSequence { id, biosample_guid } => { - ev(app.delete_mtdna_sequence(id).await, |_| Event::MtdnaChanged(biosample_guid)) - } + Command::DeleteVariantSet { id, biosample_guid } => ev(app.delete_variant_set(id).await, |_| { + Event::VariantSetsChanged(biosample_guid) + }), + Command::DeleteChipProfile { id, biosample_guid } => ev(app.delete_chip_profile(id).await, |_| { + Event::ChipProfilesChanged(biosample_guid) + }), + Command::DeleteMtdnaSequence { id, biosample_guid } => ev(app.delete_mtdna_sequence(id).await, |_| { + Event::MtdnaChanged(biosample_guid) + }), Command::AssignBiosampleProject { guid, project_id } => { - ev(app.add_biosample_to_project(guid, project_id).await, |_| Event::BiosamplesChanged) + ev(app.add_biosample_to_project(guid, project_id).await, |_| { + Event::BiosamplesChanged + }) } Command::UpdateProject { id, name, description, administrator, - } => ev(app.update_project(id, name, description, administrator).await, |_| Event::ProjectsChanged), + } => ev(app.update_project(id, name, description, administrator).await, |_| { + Event::ProjectsChanged + }), Command::DeleteProject(id) => ev(app.delete_project(id).await, |_| Event::ProjectsChanged), Command::UpdateSequenceRun { id, @@ -1573,20 +1578,18 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { test_type, library_layout, sequencing_facility, - } => { - ev( - app.update_sequence_run( - id, - platform_name, - instrument_model, - test_type, - library_layout, - sequencing_facility, - ) - .await, - |_| Event::RunsChanged(biosample_guid), + } => ev( + app.update_sequence_run( + id, + platform_name, + instrument_model, + test_type, + library_layout, + sequencing_facility, ) - } + .await, + |_| Event::RunsChanged(biosample_guid), + ), Command::UpdateAlignment { id, sequence_run_id, @@ -1597,10 +1600,13 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { app.update_alignment(id, reference_build, aligner, variant_caller).await, |_| Event::AlignmentsChanged(sequence_run_id), ), - Command::LoadRuns(biosample_guid) => { - ev(app.list_sequence_runs(biosample_guid).await, |runs| Event::Runs { biosample_guid, runs }) - } - Command::AddRun(new) => ev(app.record_sequence_run(new).await, |run| Event::RunsChanged(run.biosample_guid)), + Command::LoadRuns(biosample_guid) => ev(app.list_sequence_runs(biosample_guid).await, |runs| Event::Runs { + biosample_guid, + runs, + }), + Command::AddRun(new) => ev(app.record_sequence_run(new).await, |run| { + Event::RunsChanged(run.biosample_guid) + }), Command::LoadConsensus(guid) => { let y = app.haplogroup_consensus(guid, DnaType::Y).await.unwrap_or(None); let mt = app.haplogroup_consensus(guid, DnaType::Mt).await.unwrap_or(None); @@ -1621,40 +1627,31 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Command::YMatches { biosample_guid, project_id, - } => ev( - app.y_matches(biosample_guid, project_id).await, - |matches| Event::YMatches { + } => ev(app.y_matches(biosample_guid, project_id).await, |matches| { + Event::YMatches { biosample_guid, matches, - }, - ), - Command::LoadStrProfiles(guid) => ev( - app.list_str_profiles(guid).await, - |profiles| Event::StrProfiles { - biosample_guid: guid, - profiles, - }, - ), + } + }), + Command::LoadStrProfiles(guid) => ev(app.list_str_profiles(guid).await, |profiles| Event::StrProfiles { + biosample_guid: guid, + profiles, + }), Command::ImportStrProfile { biosample_guid, panel_name, provider, source, path, - } => { - ev( - app.import_str_profile_from_csv(biosample_guid, &panel_name, provider, source, &path) - .await, - |_| Event::StrProfilesChanged(biosample_guid), - ) - } - Command::LoadVariantSets(guid) => ev( - app.list_variant_sets(guid).await, - |sets| Event::VariantSets { - biosample_guid: guid, - sets, - }, + } => ev( + app.import_str_profile_from_csv(biosample_guid, &panel_name, provider, source, &path) + .await, + |_| Event::StrProfilesChanged(biosample_guid), ), + Command::LoadVariantSets(guid) => ev(app.list_variant_sets(guid).await, |sets| Event::VariantSets { + biosample_guid: guid, + sets, + }), Command::ImportVariants { biosample_guid, path, @@ -1668,98 +1665,89 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { source_label, source_type, text, - } => { - ev( - app.add_variants(biosample_guid, &source_label, source_type, &text) - .await, - |_| Event::VariantSetsChanged(biosample_guid), - ) - } - Command::LoadChipProfiles(guid) => ev( - app.list_chip_profiles(guid).await, - |profiles| Event::ChipProfiles { - biosample_guid: guid, - profiles, - }, + } => ev( + app.add_variants(biosample_guid, &source_label, source_type, &text) + .await, + |_| Event::VariantSetsChanged(biosample_guid), ), + Command::LoadChipProfiles(guid) => ev(app.list_chip_profiles(guid).await, |profiles| Event::ChipProfiles { + biosample_guid: guid, + profiles, + }), Command::ImportChipProfile { biosample_guid, provider, path, - } => { - ev( - app.import_chip_profile_from_csv(biosample_guid, provider, None, &path) - .await, - |_| Event::ChipProfilesChanged(biosample_guid), - ) - } - Command::LoadMtdna(guid) => ev( - app.list_mtdna_sequences(guid).await, - |sequences| Event::MtdnaSequences { + } => ev( + app.import_chip_profile_from_csv(biosample_guid, provider, None, &path) + .await, + |_| Event::ChipProfilesChanged(biosample_guid), + ), + Command::LoadMtdna(guid) => ev(app.list_mtdna_sequences(guid).await, |sequences| { + Event::MtdnaSequences { biosample_guid: guid, sequences, - }, - ), + } + }), Command::ImportMtdna { biosample_guid, path } => { - ev(app.import_mtdna_from_fasta(biosample_guid, &path).await, |_| Event::MtdnaChanged(biosample_guid)) + ev(app.import_mtdna_from_fasta(biosample_guid, &path).await, |_| { + Event::MtdnaChanged(biosample_guid) + }) } - Command::LoadMtdnaVariants { mtdna_id } => { - ev(app.mtdna_variants(mtdna_id).await, |variants| Event::MtdnaVariants { mtdna_id, variants }) + Command::LoadMtdnaVariants { mtdna_id } => ev(app.mtdna_variants(mtdna_id).await, |variants| { + Event::MtdnaVariants { mtdna_id, variants } + }), + Command::AssignMtdnaHaplogroup { mtdna_id } => ev(app.assign_mtdna_haplogroup(mtdna_id).await, |assignment| { + Event::Haplogroup { mtdna_id, assignment } + }), + Command::AssignYBisdna { biosample_guid } => { + ev(app.assign_y_bisdna(biosample_guid, None).await, |assignment| { + Event::YBisdnaHaplogroup { + biosample_guid, + assignment, + } + }) } - Command::AssignMtdnaHaplogroup { mtdna_id } => { - ev(app.assign_mtdna_haplogroup(mtdna_id).await, |assignment| Event::Haplogroup { mtdna_id, assignment }) + Command::YHaploReport { alignment_id } => { + ev(app.y_haplogroup_report(alignment_id).await, |(assignment, lineage)| { + Event::YHaploReport { + alignment_id, + assignment, + lineage, + } + }) } - Command::AssignYBisdna { biosample_guid } => ev( - app.assign_y_bisdna(biosample_guid, None).await, - |assignment| Event::YBisdnaHaplogroup { - biosample_guid, - assignment, - }, - ), - Command::YHaploReport { alignment_id } => ev( - app.y_haplogroup_report(alignment_id).await, - |(assignment, lineage)| Event::YHaploReport { + Command::AssignYHaplogroup { alignment_id } => ev(app.assign_y_haplogroup(alignment_id).await, |assignment| { + Event::YHaplogroup { alignment_id, assignment, - lineage, - }, - ), - Command::AssignYHaplogroup { alignment_id } => ev( - app.assign_y_haplogroup(alignment_id).await, - |assignment| Event::YHaplogroup { + } + }), + Command::AssignMtdnaHaplogroupFromAlignment { alignment_id } => ev( + app.assign_mtdna_haplogroup_from_alignment(alignment_id).await, + |assignment| Event::MtHaplogroup { alignment_id, assignment, }, ), - Command::AssignMtdnaHaplogroupFromAlignment { alignment_id } => { - ev( - app.assign_mtdna_haplogroup_from_alignment(alignment_id).await, - |assignment| Event::MtHaplogroup { - alignment_id, - assignment, - }, - ) - } Command::EstimateAncestryFromConsensus { biosample_guid } => { // Estimate from the pooled consensus, then surface it as the donor-level result. - ev( - app.estimate_ancestry_from_consensus(biosample_guid).await, - |result| Event::DonorAncestry { + ev(app.estimate_ancestry_from_consensus(biosample_guid).await, |result| { + Event::DonorAncestry { alignment_id: navigator_app::CONSENSUS_SOURCE_ID, result, - }, - ) + } + }) } Command::EstimateDeepAncestry { biosample_guid } => { // Heavy: genotypes the best CHM13 alignment at ~1.15M sites, then fits qpAdm f4. Persists // the ANCIENT_ADMIXTURE result (or nothing, when the model doesn't apply). - ev( - app.estimate_deep_ancestry(biosample_guid).await, - |result| Event::DeepAncestryEstimated { + ev(app.estimate_deep_ancestry(biosample_guid).await, |result| { + Event::DeepAncestryEstimated { biosample_guid, result: result.map(Box::new), - }, - ) + } + }) } Command::PaintAncestryFromConsensus { biosample_guid } => { // Painting from the consensus needs no genotyping pass — fast, no progress stream. @@ -1771,61 +1759,58 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { }, ) } - Command::LoadPainting { biosample_guid } => ev( - app.cached_painting(biosample_guid).await, - |result| Event::AncestryPainting { + Command::LoadPainting { biosample_guid } => ev(app.cached_painting(biosample_guid).await, |result| { + Event::AncestryPainting { alignment_id: navigator_app::CONSENSUS_SOURCE_ID, result: result.unwrap_or_default(), - }, - ), + } + }), Command::ComputeRohFromConsensus { biosample_guid } => { // ROH from the consensus needs no genotyping pass — fast, no progress stream. - ev( - app.compute_roh_from_consensus(biosample_guid).await, - |result| Event::RohResultReady { + ev(app.compute_roh_from_consensus(biosample_guid).await, |result| { + Event::RohResultReady { biosample_guid, result: Some(Box::new(result)), - }, - ) + } + }) } - Command::LoadRoh { biosample_guid } => ev( - app.cached_roh(biosample_guid).await, - |result| Event::RohResultReady { + Command::LoadRoh { biosample_guid } => { + ev(app.cached_roh(biosample_guid).await, |result| Event::RohResultReady { biosample_guid, result: result.map(Box::new), - }, - ), + }) + } Command::ComputeArchaicFromConsensus { biosample_guid } => { // A pure read over the cached consensus + the marker panel — no genotyping pass. - ev( - app.estimate_archaic_from_consensus(biosample_guid).await, - |result| Event::ArchaicResultReady { + ev(app.estimate_archaic_from_consensus(biosample_guid).await, |result| { + Event::ArchaicResultReady { biosample_guid, result: Some(Box::new(result)), - }, - ) + } + }) } - Command::LoadArchaic { biosample_guid } => ev( - app.cached_archaic(biosample_guid).await, - |result| Event::ArchaicResultReady { + Command::LoadArchaic { biosample_guid } => ev(app.cached_archaic(biosample_guid).await, |result| { + Event::ArchaicResultReady { biosample_guid, result: result.map(Box::new), - }, - ), - Command::CallArchaicSegments { biosample_guid } => ev( - app.call_archaic_segments_for_subject(biosample_guid).await, - |result| Event::ArchaicSegmentsReady { - biosample_guid, - result: Some(Box::new(result)), - }, - ), - Command::LoadArchaicSegments { biosample_guid } => ev( - app.cached_archaic_segments(biosample_guid).await, - |result| Event::ArchaicSegmentsReady { - biosample_guid, - result: result.map(Box::new), - }, - ), + } + }), + Command::CallArchaicSegments { biosample_guid } => { + ev(app.call_archaic_segments_for_subject(biosample_guid).await, |result| { + Event::ArchaicSegmentsReady { + biosample_guid, + result: Some(Box::new(result)), + } + }) + } + Command::LoadArchaicSegments { biosample_guid } => { + ev(app.cached_archaic_segments(biosample_guid).await, |result| { + Event::ArchaicSegmentsReady { + biosample_guid, + result: result.map(Box::new), + } + }) + } Command::LoadConsensusAncestryDetail { biosample_guid } => { let fine = app .consensus_ancestry(biosample_guid, "FINE_ADMIXTURE") @@ -1871,38 +1856,35 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Err(e) => Event::Error(e.to_string()), }, Command::AddDataBatch { biosample_guid, paths } => { - ev( - app.add_data_batch(biosample_guid, paths, |_, _| {}).await, - |summary| Event::DataBatchImported { + ev(app.add_data_batch(biosample_guid, paths, |_, _| {}).await, |summary| { + Event::DataBatchImported { biosample_guid, summary, - }, - ) + } + }) } Command::CreateSubjectAndImport { donor_identifier, sex, paths, } => match app.add_biosample(None, donor_identifier, None, sex).await { - Ok(bio) => ev( - app.add_data_batch(bio.guid, paths, |_, _| {}).await, - |summary| Event::SubjectCreatedAndImported { + Ok(bio) => ev(app.add_data_batch(bio.guid, paths, |_, _| {}).await, |summary| { + Event::SubjectCreatedAndImported { biosample_guid: bio.guid, summary, - }, - ), + } + }), Err(e) => Event::Error(e.to_string()), }, - Command::LoadAlignments(sequence_run_id) => ev( - app.list_alignments(sequence_run_id).await, - |alignments| Event::Alignments { + Command::LoadAlignments(sequence_run_id) => ev(app.list_alignments(sequence_run_id).await, |alignments| { + Event::Alignments { sequence_run_id, alignments, - }, - ), - Command::AddAlignment(new) => { - ev(app.record_alignment(new).await, |a| Event::AlignmentsChanged(a.sequence_run_id)) - } + } + }), + Command::AddAlignment(new) => ev(app.record_alignment(new).await, |a| { + Event::AlignmentsChanged(a.sequence_run_id) + }), Command::ProbeAlignment { path } => ev(app.probe_alignment(path).await, Event::AlignmentProbe), Command::DefaultAlignment { biosample_guid } => match app.default_alignment_for_subject(biosample_guid).await { Ok(Some((run_id, alignment_id))) => Event::DefaultAlignment { run_id, alignment_id }, @@ -1919,125 +1901,118 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Ok(None) => Event::Noop, Err(e) => Event::Error(e.to_string()), }, - Command::LoadYProfile { biosample_guid } => ev( - app.cached_y_profile(biosample_guid).await, - |profile| Event::YProfile { + Command::LoadYProfile { biosample_guid } => { + ev(app.cached_y_profile(biosample_guid).await, |profile| Event::YProfile { biosample_guid, profile, - }, - ), - Command::BuildYProfile { biosample_guid } => ev( - app.build_y_profile(biosample_guid).await, - |profile| Event::YProfile { + }) + } + Command::BuildYProfile { biosample_guid } => { + ev(app.build_y_profile(biosample_guid).await, |profile| Event::YProfile { biosample_guid, profile: Some(profile), - }, - ), + }) + } Command::LoadYSnpNames { biosample_guid, positions, - } => ev(app.y_snp_names_at(biosample_guid, &positions).await, |names| Event::YSnpNames { names }), - Command::LoadMtProfile { biosample_guid } => ev( - app.cached_mt_profile(biosample_guid).await, - |profile| Event::MtProfile { - biosample_guid, - profile, - }, - ), - Command::BuildMtProfile { biosample_guid } => ev( - app.build_mt_profile(biosample_guid).await, - |profile| Event::MtProfile { - biosample_guid, - profile: Some(profile), - }, - ), - Command::LoadAutosomalProfile { biosample_guid } => ev( - app.cached_autosomal_profile(biosample_guid).await, - |profile| Event::AutosomalProfile { + } => ev(app.y_snp_names_at(biosample_guid, &positions).await, |names| { + Event::YSnpNames { names } + }), + Command::LoadMtProfile { biosample_guid } => ev(app.cached_mt_profile(biosample_guid).await, |profile| { + Event::MtProfile { biosample_guid, profile, - }, - ), - Command::BuildAutosomalProfile { biosample_guid } => ev( - app.build_autosomal_profile(biosample_guid).await, - |profile| Event::AutosomalProfile { + } + }), + Command::BuildMtProfile { biosample_guid } => { + ev(app.build_mt_profile(biosample_guid).await, |profile| Event::MtProfile { biosample_guid, profile: Some(profile), - }, - ), - Command::LoadCoverage(alignment_id) => { - ev(app.cached_coverage(alignment_id).await, |result| Event::Coverage { alignment_id, result }) + }) } + Command::LoadAutosomalProfile { biosample_guid } => { + ev(app.cached_autosomal_profile(biosample_guid).await, |profile| { + Event::AutosomalProfile { + biosample_guid, + profile, + } + }) + } + Command::BuildAutosomalProfile { biosample_guid } => { + ev(app.build_autosomal_profile(biosample_guid).await, |profile| { + Event::AutosomalProfile { + biosample_guid, + profile: Some(profile), + } + }) + } + Command::LoadCoverage(alignment_id) => ev(app.cached_coverage(alignment_id).await, |result| Event::Coverage { + alignment_id, + result, + }), Command::LoadCoverageBulk(ids) => ev(app.cached_coverage_bulk(&ids).await, Event::CoverageBulk), - Command::LoadGenomeRegions { alignment_id, build } => ev( - app.genome_regions(&build).await, - |regions| Event::GenomeRegions { + Command::LoadGenomeRegions { alignment_id, build } => { + ev(app.genome_regions(&build).await, |regions| Event::GenomeRegions { alignment_id, regions: Some(regions), - }, - ), - Command::RunCoverage(alignment_id) => ev( - app.run_coverage_for_alignment(alignment_id).await, - |result| Event::Coverage { - alignment_id, - result: Some(result), - }, - ), - Command::LoadSex(alignment_id) => { - ev(app.cached_sex(alignment_id).await, |result| Event::Sex { alignment_id, result }) + }) } - Command::RunSex(alignment_id) => ev( - app.run_sex(alignment_id).await, - |result| Event::Sex { + Command::RunCoverage(alignment_id) => ev(app.run_coverage_for_alignment(alignment_id).await, |result| { + Event::Coverage { alignment_id, result: Some(result), - }, - ), - Command::LoadReadMetrics(alignment_id) => { - ev(app.cached_read_metrics(alignment_id).await, |result| Event::ReadMetrics { alignment_id, result }) - } - Command::RunReadMetrics(alignment_id) => ev( - app.run_read_metrics(alignment_id).await, - |result| Event::ReadMetrics { + } + }), + Command::LoadSex(alignment_id) => ev(app.cached_sex(alignment_id).await, |result| Event::Sex { + alignment_id, + result, + }), + Command::RunSex(alignment_id) => ev(app.run_sex(alignment_id).await, |result| Event::Sex { + alignment_id, + result: Some(result), + }), + Command::LoadReadMetrics(alignment_id) => ev(app.cached_read_metrics(alignment_id).await, |result| { + Event::ReadMetrics { alignment_id, result } + }), + Command::RunReadMetrics(alignment_id) => { + ev(app.run_read_metrics(alignment_id).await, |result| Event::ReadMetrics { alignment_id, result: Some(result), - }, - ), - Command::LoadSv(alignment_id) => { - ev(app.cached_sv(alignment_id).await, |result| Event::Sv { alignment_id, result }) + }) } - Command::RunSv(alignment_id) => ev( - app.run_sv(alignment_id, cancel.clone()).await, - |result| Event::Sv { + Command::LoadSv(alignment_id) => ev(app.cached_sv(alignment_id).await, |result| Event::Sv { + alignment_id, + result, + }), + Command::RunSv(alignment_id) => ev(app.run_sv(alignment_id, cancel.clone()).await, |result| Event::Sv { + alignment_id, + result: Some(result), + }), + Command::LoadDenovo { alignment_id, contig } => { + ev(app.cached_denovo(alignment_id, &contig).await, |result| Event::Denovo { alignment_id, - result: Some(result), - }, - ), - Command::LoadDenovo { alignment_id, contig } => ev( - app.cached_denovo(alignment_id, &contig).await, + contig, + result, + }) + } + Command::RunDenovo { alignment_id, contig } => ev( + app.run_denovo_for_alignment(alignment_id, contig.clone()).await, |result| Event::Denovo { alignment_id, contig, - result, + result: Some(result), }, ), - Command::RunDenovo { alignment_id, contig } => { - ev( - app.run_denovo_for_alignment(alignment_id, contig.clone()).await, - |result| Event::Denovo { - alignment_id, - contig, - result: Some(result), - }, - ) - } Command::LoadAllAlignments => ev(app.list_all_alignments().await, Event::AllAlignments), - Command::CompareIbdConsensus { a, b } => { - ev(app.compare_ibd_consensus(a, b, IbdDetectorConfig::default()).await, Event::Ibd) - } - Command::CompareIbdSources { a, b } => { - ev(app.compare_ibd_sources(a, b, IbdDetectorConfig::default()).await, Event::Ibd) - } + Command::CompareIbdConsensus { a, b } => ev( + app.compare_ibd_consensus(a, b, IbdDetectorConfig::default()).await, + Event::Ibd, + ), + Command::CompareIbdSources { a, b } => ev( + app.compare_ibd_sources(a, b, IbdDetectorConfig::default()).await, + Event::Ibd, + ), Command::VerifyIdentityConsensus { a, b } => ev(app.verify_identity_consensus(a, b).await, Event::Identity), Command::LoadIbdSuggestions => ev(app.ibd_suggestions().await, Event::IbdSuggestions), Command::RequestIntroduction { @@ -2047,10 +2022,11 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Ok(_) => ev(app.matching_entries().await, Event::Matching), Err(e) => Event::Error(e.to_string()), }, - Command::DismissCandidate { suggested_sample_guid } => ev( - app.ibd_dismiss(&suggested_sample_guid).await, - |_| Event::CandidateDismissed { suggested_sample_guid }, - ), + Command::DismissCandidate { suggested_sample_guid } => { + ev(app.ibd_dismiss(&suggested_sample_guid).await, |_| { + Event::CandidateDismissed { suggested_sample_guid } + }) + } Command::UseLocalIdentity => ev(app.use_local_identity(), |did| Event::Authenticated(Some(did))), Command::RefreshMatching => ev(app.refresh_matching().await, Event::Matching), Command::MatchingConsent { @@ -2092,10 +2068,9 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } } Command::LoadIbdExchanges { biosample_guid } => { - ev( - app.list_ibd_exchanges_for_subject(biosample_guid).await, - |rows| Event::IbdExchanges { biosample_guid, rows }, - ) + ev(app.list_ibd_exchanges_for_subject(biosample_guid).await, |rows| { + Event::IbdExchanges { biosample_guid, rows } + }) } Command::DmInitiate { partner_did } => ev(app.dm_initiate(&partner_did).await, |_| Event::DmInitiated), Command::LoadDmInbox => match (app.dm_incoming().await, app.dm_ready().await) { @@ -2107,35 +2082,36 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { } Command::DmConnect { info } => ev(app.dm_connect(&info).await, |_| Event::DmConnected), Command::LoadDmConversations => ev(app.dm_conversations().await, Event::DmConversations), - Command::LoadDmMessages { session_id } => { - ev(app.dm_messages(&session_id).await, |rows| Event::DmMessages { session_id, rows }) - } + Command::LoadDmMessages { session_id } => ev(app.dm_messages(&session_id).await, |rows| Event::DmMessages { + session_id, + rows, + }), Command::DmSend { session_id, text } => { ev(app.dm_send(&session_id, &text).await, |_| Event::DmSent { session_id }) } - Command::DmSync { session_id } => { - ev(app.dm_sync(&session_id).await, |new_count| Event::DmSynced { session_id, new_count }) - } + Command::DmSync { session_id } => ev(app.dm_sync(&session_id).await, |new_count| Event::DmSynced { + session_id, + new_count, + }), Command::LoadRecruitmentInvitations => ev(app.recruitment_invitations().await, Event::RecruitmentInvitations), Command::RespondRecruitment { campaign_id, accept } => { - ev(app.recruitment_respond(campaign_id, accept).await, |_| Event::RecruitmentResponded) + ev(app.recruitment_respond(campaign_id, accept).await, |_| { + Event::RecruitmentResponded + }) } Command::BackfillLabs => ev(app.backfill_run_labs().await, Event::LabsResolved), Command::AuthStatus => Event::Authenticated(app.current_account()), Command::SyncStatus => Event::SyncOnline(app.is_online()), - Command::PullSync => ev( - app.pull_sync().await, - |o| Event::PullDone { - in_sync: o.in_sync, - applied: o.applied, - adopted: o.adopted, - repushed: o.repushed, - conflicts: o.conflicts, - }, - ), - Command::VerifySourceFiles => { - ev(app.verify_source_files().await, |missing| Event::SourceFilesVerified { missing }) - } + Command::PullSync => ev(app.pull_sync().await, |o| Event::PullDone { + in_sync: o.in_sync, + applied: o.applied, + adopted: o.adopted, + repushed: o.repushed, + conflicts: o.conflicts, + }), + Command::VerifySourceFiles => ev(app.verify_source_files().await, |missing| Event::SourceFilesVerified { + missing, + }), Command::Login { handle } => ev(app.login(&handle).await, |did| Event::Authenticated(Some(did))), Command::Logout => ev(app.logout().await, |_| Event::Authenticated(None)), // Publishes enqueue to the durable outbox then drain — handled in the spawn loop (they emit @@ -2170,74 +2146,65 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { Err(e) => Event::Error(format!("write {}: {e}", path.display())), } } - Command::LoadPcaReference => ev( - app.ancestry_pca_reference().await, - |points| Event::PcaReference { - alignment_id: navigator_app::CONSENSUS_SOURCE_ID, - points, - }, - ), + Command::LoadPcaReference => ev(app.ancestry_pca_reference().await, |points| Event::PcaReference { + alignment_id: navigator_app::CONSENSUS_SOURCE_ID, + points, + }), Command::SetHaploOverride { biosample_guid, dna_type, haplogroup, reason, - } => { - ev( - app.set_manual_override(biosample_guid, dna_type, &haplogroup, reason.as_deref()) - .await, - |_| Event::ReconciliationChanged { - biosample_guid, - dna_type, - }, - ) - } - Command::ClearHaploOverride { - biosample_guid, - dna_type, } => ev( - app.clear_manual_override(biosample_guid, dna_type).await, + app.set_manual_override(biosample_guid, dna_type, &haplogroup, reason.as_deref()) + .await, |_| Event::ReconciliationChanged { biosample_guid, dna_type, }, ), + Command::ClearHaploOverride { + biosample_guid, + dna_type, + } => ev(app.clear_manual_override(biosample_guid, dna_type).await, |_| { + Event::ReconciliationChanged { + biosample_guid, + dna_type, + } + }), Command::LoadAudit { biosample_guid, dna_type, - } => ev( - app.reconciliation_audit(biosample_guid, dna_type).await, - |entries| Event::Audit { + } => ev(app.reconciliation_audit(biosample_guid, dna_type).await, |entries| { + Event::Audit { biosample_guid, dna_type, entries, - }, - ), - Command::LoadHeteroplasmy { alignment_id } => { - ev(app.mtdna_heteroplasmy(alignment_id).await, |sites| Event::Heteroplasmy { alignment_id, sites }) - } + } + }), + Command::LoadHeteroplasmy { alignment_id } => ev(app.mtdna_heteroplasmy(alignment_id).await, |sites| { + Event::Heteroplasmy { alignment_id, sites } + }), Command::PublishReconciliation { biosample_guid, .. } => { Event::Error(format!("internal: unrouted PublishReconciliation {biosample_guid:?}")) } // ---- social (Community tab) ---------------------------------------- Command::LoadSupportThreads => ev(app.support_threads().await, Event::SupportThreads), - Command::LoadSupportThread { conversation_id } => ev( - app.support_thread(&conversation_id).await, - |messages| Event::SupportThread { + Command::LoadSupportThread { conversation_id } => ev(app.support_thread(&conversation_id).await, |messages| { + Event::SupportThread { conversation_id, messages, - }, - ), - Command::OpenSupportThread { subject, body } => ev( - app.open_support_thread(&subject, &body).await, + } + }), + Command::OpenSupportThread { subject, body } => { + ev(app.open_support_thread(&subject, &body).await, |conversation_id| { + Event::SupportThreadPosted { conversation_id } + }) + } + Command::ReplySupportThread { conversation_id, body } => ev( + app.reply_support_thread(&conversation_id, &body).await, |conversation_id| Event::SupportThreadPosted { conversation_id }, ), - Command::ReplySupportThread { conversation_id, body } => { - ev( - app.reply_support_thread(&conversation_id, &body).await, - |conversation_id| Event::SupportThreadPosted { conversation_id }, - ) - } Command::LoadCommunityFeed => ev(app.community_feed().await, Event::CommunityFeed), Command::PostCommunity { content, @@ -2248,23 +2215,22 @@ pub async fn handle(app: &App, cmd: Command, cancel: &CancelToken) -> Event { // `feed.post` record; a publish failure is surfaced but the post itself is not lost. Ok(_) => { if publish_pds { - ev(app.publish_feed_post(&content, topic.as_deref()).await, |_| Event::CommunityPosted) + ev(app.publish_feed_post(&content, topic.as_deref()).await, |_| { + Event::CommunityPosted + }) } else { Event::CommunityPosted } } Err(e) => Event::Error(e.to_string()), }, - Command::LoadNotifications => ev( - app.notifications().await, - |n| Event::Notifications { - items: n.items, - unread: n.unread, - }, - ), - Command::MarkNotificationRead { id } => { - ev(app.mark_notification_read(id.as_deref()).await, |_| Event::NotificationsMarked) - } + Command::LoadNotifications => ev(app.notifications().await, |n| Event::Notifications { + items: n.items, + unread: n.unread, + }), + Command::MarkNotificationRead { id } => ev(app.mark_notification_read(id.as_deref()).await, |_| { + Event::NotificationsMarked + }), } } @@ -2293,7 +2259,9 @@ async fn resolve_reference_streaming( wake(); } }; - let event = ev(app.resolve_reference(&build, &mut progress).await, |path| Event::ReferenceReady { build, path }); + let event = ev(app.resolve_reference(&build, &mut progress).await, |path| { + Event::ReferenceReady { build, path } + }); let _ = evt_tx.send(event); wake(); } @@ -2323,12 +2291,7 @@ async fn ensure_references_streaming( /// (else they error or degrade to a whole-file scan); building it eagerly — with a visible bar — /// keeps a freshly imported file from looking stuck on its first analysis. A file that already has /// an index returns instantly with `built: None` (no progress noise). -async fn ensure_index_streaming( - app: &App, - alignment_id: i64, - evt_tx: &Sender, - wake: &(dyn Fn() + Send + Sync), -) { +async fn ensure_index_streaming(app: &App, alignment_id: i64, evt_tx: &Sender, wake: &(dyn Fn() + Send + Sync)) { // Progress runs on a blocking thread, so the callback must be Send — capture owned clones, not // borrows. Throttling already happens in the analysis layer (per ~32 MB); forward each tick. let tx = evt_tx.clone(); @@ -2428,19 +2391,23 @@ async fn run_full_analysis_streaming( // be Fn + Sync; the event Sender is !Sync, so guard it with a Mutex. let evt = Arc::new(Mutex::new(evt_tx.clone())); let wk = wake.clone(); - app.run_unified_metrics_with_progress(alignment_id, move |done, tot| { - let within = if tot > 0 { done as f32 / tot as f32 } else { 0.0 }; - if let Ok(tx) = evt.lock() { - let _ = tx.send(Event::AnalysisProgress { - step: 1, - total, - label: "Quality metrics".into(), - detail: format!("scanning genome — {:.0}%", within * 100.0), - fraction: within / total as f32, - }); - } - wk(); - }, cancel.clone()) + app.run_unified_metrics_with_progress( + alignment_id, + move |done, tot| { + let within = if tot > 0 { done as f32 / tot as f32 } else { 0.0 }; + if let Ok(tx) = evt.lock() { + let _ = tx.send(Event::AnalysisProgress { + step: 1, + total, + label: "Quality metrics".into(), + detail: format!("scanning genome — {:.0}%", within * 100.0), + fraction: within / total as f32, + }); + } + wk(); + }, + cancel.clone(), + ) .await .map(|r| (r.coverage, r.read_metrics, r.sex)) } @@ -2831,7 +2798,12 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo // visible progress bar — a first CRAM/BAM that needs a multi-GB reference // download otherwise looks like it didn't register (§ ensure_references_streaming). Command::AddDataBatch { biosample_guid, paths } => { - let event = handle(&app, Command::AddDataBatch { biosample_guid, paths }, &CancelToken::none()).await; + let event = handle( + &app, + Command::AddDataBatch { biosample_guid, paths }, + &CancelToken::none(), + ) + .await; let imported = matches!(event, Event::DataBatchImported { .. }); let _ = evt_tx.send(event); wake(); @@ -2878,7 +2850,12 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::BuildAutosomalProfile { biosample_guid }, &CancelToken::none()).await; + let event = handle( + &app, + Command::BuildAutosomalProfile { biosample_guid }, + &CancelToken::none(), + ) + .await; let _ = evt_tx.send(event); wake(); } @@ -2887,73 +2864,125 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::StrConcordance { biosample_guid }, &CancelToken::none()).await; + let event = + handle(&app, Command::StrConcordance { biosample_guid }, &CancelToken::none()) + .await; let _ = evt_tx.send(event); wake(); } Command::RunSv(alignment_id) => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; let (gen, token) = cancels.begin(); - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::RunSv(alignment_id), &token).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::RunSv(alignment_id), &token).await, + ) + .await; cancels.end(gen); let _ = evt_tx.send(event); wake(); } Command::LoadHeteroplasmy { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::LoadHeteroplasmy { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::LoadHeteroplasmy { alignment_id }, &CancelToken::none()) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::AssignYHaplogroup { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::AssignYHaplogroup { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::AssignYHaplogroup { alignment_id }, &CancelToken::none()) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::YHaploReport { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::YHaploReport { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::YHaploReport { alignment_id }, &CancelToken::none()).await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::AssignMtdnaHaplogroupFromAlignment { alignment_id } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::AssignMtdnaHaplogroupFromAlignment { alignment_id }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle( + &app, + Command::AssignMtdnaHaplogroupFromAlignment { alignment_id }, + &CancelToken::none(), + ) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::FindPrivateY { alignment_id, mask } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::FindPrivateY { alignment_id, mask }, &CancelToken::none()).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::FindPrivateY { alignment_id, mask }, &CancelToken::none()) + .await, + ) + .await; let _ = evt_tx.send(event); wake(); } Command::RunDenovo { alignment_id, contig } => { if let Ok(Some(build)) = app.reference_build_of_alignment(alignment_id).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake) + .await; } ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; let (gen, token) = cancels.begin(); - let event = settle_alignment_command(&app, alignment_id, handle(&app, Command::RunDenovo { alignment_id, contig }, &token).await).await; + let event = settle_alignment_command( + &app, + alignment_id, + handle(&app, Command::RunDenovo { alignment_id, contig }, &token).await, + ) + .await; cancels.end(gen); let _ = evt_tx.send(event); wake(); @@ -2962,12 +2991,19 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo for src in [a, b] { if let navigator_app::IbdSource::Alignment(aln) = src { if let Ok(Some(build)) = app.reference_build_of_alignment(aln).await { - ensure_references_streaming(&app, std::slice::from_ref(&build), &evt_tx, &*wake).await; + ensure_references_streaming( + &app, + std::slice::from_ref(&build), + &evt_tx, + &*wake, + ) + .await; } ensure_index_streaming(&app, aln, &evt_tx, &*wake).await; } } - let event = handle(&app, Command::CompareIbdSources { a, b }, &CancelToken::none()).await; + let event = + handle(&app, Command::CompareIbdSources { a, b }, &CancelToken::none()).await; let _ = evt_tx.send(event); wake(); } @@ -2976,7 +3012,8 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::BuildYProfile { biosample_guid }, &CancelToken::none()).await; + let event = + handle(&app, Command::BuildYProfile { biosample_guid }, &CancelToken::none()).await; let _ = evt_tx.send(event); wake(); } @@ -2985,7 +3022,9 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_references_streaming(&app, &builds, &evt_tx, &*wake).await; } ensure_indexes_for_subject_streaming(&app, biosample_guid, &evt_tx, &*wake).await; - let event = handle(&app, Command::BuildMtProfile { biosample_guid }, &CancelToken::none()).await; + let event = + handle(&app, Command::BuildMtProfile { biosample_guid }, &CancelToken::none()) + .await; let _ = evt_tx.send(event); wake(); } @@ -3007,8 +3046,15 @@ pub fn spawn(db_path: PathBuf, wake: impl Fn() + Send + Sync + 'static) -> (Unbo ensure_index_streaming(&app, alignment_id, &evt_tx, &*wake).await; // Simple one-click: include the autosomal ancestry step. let (gen, cancel) = cancels.begin(); - run_full_analysis_streaming(&app, alignment_id, true, cancel, &evt_tx, wake.clone()) - .await; + run_full_analysis_streaming( + &app, + alignment_id, + true, + cancel, + &evt_tx, + wake.clone(), + ) + .await; cancels.end(gen); } Ok(None) => { @@ -3177,8 +3223,21 @@ mod tests { } // Delete both → empty genealogy. - let _ = handle(&app, Command::DeleteMdka { guid, lineage: "Y".into() }, &CancelToken::none()).await; - let ev = handle(&app, Command::DeleteExternalId { guid, id: kit_id }, &CancelToken::none()).await; + let _ = handle( + &app, + Command::DeleteMdka { + guid, + lineage: "Y".into(), + }, + &CancelToken::none(), + ) + .await; + let ev = handle( + &app, + Command::DeleteExternalId { guid, id: kit_id }, + &CancelToken::none(), + ) + .await; match ev { Event::Genealogy { data, .. } => assert!(data.is_empty(), "all genealogy removed"), other => panic!("expected Genealogy, got {other:?}"), @@ -3211,7 +3270,9 @@ mod tests { name: "Trio".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await; let pid = match created { Event::ProjectCreated(p) => p.id, @@ -3359,7 +3420,9 @@ mod tests { name: "P".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::ProjectCreated(p) => p.id, @@ -3374,7 +3437,9 @@ mod tests { donor_identifier: "HG002".into(), sample_accession: None, sex: Some("male".into()), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3398,7 +3463,9 @@ mod tests { donor_identifier: "NA12878".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3422,7 +3489,9 @@ mod tests { pf_reads_aligned: None, mean_read_length: None, mean_insert_size: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::RunsChanged(g) => assert_eq!(g, guid), @@ -3444,7 +3513,9 @@ mod tests { bam_path: None, reference_path: None, content_sha256: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::AlignmentsChanged(r) => assert_eq!(r, run_id), @@ -3466,7 +3537,9 @@ mod tests { donor_identifier: "draft".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3520,7 +3593,9 @@ mod tests { pf_reads_aligned: None, mean_read_length: None, mean_insert_size: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::RunsChanged(_) => {} @@ -3572,7 +3647,9 @@ mod tests { donor_identifier: "spare".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3601,7 +3678,9 @@ mod tests { name: "P".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::ProjectCreated(p) => p.id, @@ -3614,7 +3693,9 @@ mod tests { donor_identifier: "loose".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3660,7 +3741,13 @@ mod tests { } // clearing the project (None) removes it from the project list - match handle(&app, Command::AssignBiosampleProject { guid, project_id: None }, &CancelToken::none()).await { + match handle( + &app, + Command::AssignBiosampleProject { guid, project_id: None }, + &CancelToken::none(), + ) + .await + { Event::BiosamplesChanged => {} other => panic!("got {other:?}"), } @@ -3679,7 +3766,9 @@ mod tests { name: "Old".into(), description: None, administrator: "jk".into(), - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::ProjectCreated(p) => p.id, @@ -3721,7 +3810,9 @@ mod tests { donor_identifier: "member".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3759,7 +3850,9 @@ mod tests { donor_identifier: "subj".into(), sample_accession: None, sex: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::BiosamplesChanged => {} @@ -3781,7 +3874,9 @@ mod tests { pf_reads_aligned: None, mean_read_length: None, mean_insert_size: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::RunsChanged(_) => {} @@ -3833,7 +3928,9 @@ mod tests { bam_path: None, reference_path: None, content_sha256: None, - }), &CancelToken::none()) + }), + &CancelToken::none(), + ) .await { Event::AlignmentsChanged(_) => {}