From c65ce412c37a3ab211c99a8858cc749239b155d6 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Mon, 18 May 2026 12:58:36 -0700 Subject: [PATCH 1/3] test(downsample): invoke downsample Command::execute in-process --- tests/integration/test_downsample_command.rs | 240 ++++++++----------- 1 file changed, 106 insertions(+), 134 deletions(-) diff --git a/tests/integration/test_downsample_command.rs b/tests/integration/test_downsample_command.rs index 6119fff43..32b5981be 100644 --- a/tests/integration/test_downsample_command.rs +++ b/tests/integration/test_downsample_command.rs @@ -1,5 +1,11 @@ //! Integration tests for the downsample command. +//! +//! These tests invoke the downsample `Command::execute()` in-process rather than spawning +//! the actual `fgumi downsample` binary. +use clap::Parser; +use fgumi_lib::commands::command::Command as FgumiCommand; +use fgumi_lib::commands::downsample::Downsample; use fgumi_lib::sam::SamTag; use fgumi_raw_bam::SamBuilder; use noodles::bam; @@ -8,7 +14,6 @@ use noodles::sam::alignment::record::data::field::Tag; use noodles::sam::alignment::record_buf::data::field::Value; use std::fs; use std::path::PathBuf; -use std::process::Command; use tempfile::TempDir; use crate::helpers::bam_generator::create_minimal_header; @@ -92,24 +97,21 @@ fn test_downsample_basic() { create_grouped_bam(&input_bam, families); // Run downsample with fraction=0.5 and seed for reproducibility - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output_bam.to_str().unwrap(), - "-f", - "0.5", - "--seed", - "42", - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(status.success(), "Downsample command failed"); + let cmd = Downsample::try_parse_from([ + "downsample", + "-i", + input_bam.to_str().unwrap(), + "-o", + output_bam.to_str().unwrap(), + "-f", + "0.5", + "--seed", + "42", + "--compression-level", + "1", + ]) + .expect("failed to parse downsample args"); + cmd.execute("test").expect("Downsample command failed"); assert!(output_bam.exists(), "Output BAM not created"); // Verify that some families were kept (with seed 42 and fraction 0.5, should be around 5) @@ -135,26 +137,23 @@ fn test_downsample_with_rejects() { (0..10).map(|i| (Box::leak(format!("{i}").into_boxed_str()) as &str, 5)).collect(); create_grouped_bam(&input_bam, families); - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output_bam.to_str().unwrap(), - "-f", - "0.5", - "--seed", - "42", - "--rejects", - rejects_bam.to_str().unwrap(), - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(status.success(), "Downsample command failed"); + let cmd = Downsample::try_parse_from([ + "downsample", + "-i", + input_bam.to_str().unwrap(), + "-o", + output_bam.to_str().unwrap(), + "-f", + "0.5", + "--seed", + "42", + "--rejects", + rejects_bam.to_str().unwrap(), + "--compression-level", + "1", + ]) + .expect("failed to parse downsample args"); + cmd.execute("test").expect("Downsample command failed"); assert!(output_bam.exists(), "Output BAM not created"); assert!(rejects_bam.exists(), "Rejects BAM not created"); @@ -180,24 +179,21 @@ fn test_downsample_deterministic() { // Run twice with same seed for output in [&output1_bam, &output2_bam] { - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output.to_str().unwrap(), - "-f", - "0.3", - "--seed", - "12345", - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(status.success(), "Downsample command failed"); + let cmd = Downsample::try_parse_from([ + "downsample", + "-i", + input_bam.to_str().unwrap(), + "-o", + output.to_str().unwrap(), + "-f", + "0.3", + "--seed", + "12345", + "--compression-level", + "1", + ]) + .expect("failed to parse downsample args"); + cmd.execute("test").expect("Downsample command failed"); } // Both outputs should be identical @@ -249,28 +245,25 @@ fn test_downsample_histograms() { let families = vec![("0", 1), ("1", 2), ("2", 3), ("3", 4), ("4", 5)]; create_grouped_bam(&input_bam, families); - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output_bam.to_str().unwrap(), - "-f", - "0.5", - "--seed", - "42", - "--histogram-kept", - hist_kept.to_str().unwrap(), - "--histogram-rejected", - hist_rejected.to_str().unwrap(), - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(status.success(), "Downsample command failed"); + let cmd = Downsample::try_parse_from([ + "downsample", + "-i", + input_bam.to_str().unwrap(), + "-o", + output_bam.to_str().unwrap(), + "-f", + "0.5", + "--seed", + "42", + "--histogram-kept", + hist_kept.to_str().unwrap(), + "--histogram-rejected", + hist_rejected.to_str().unwrap(), + "--compression-level", + "1", + ]) + .expect("failed to parse downsample args"); + cmd.execute("test").expect("Downsample command failed"); assert!(hist_kept.exists(), "Kept histogram not created"); assert!(hist_rejected.exists(), "Rejected histogram not created"); @@ -292,70 +285,49 @@ fn test_downsample_keep_all() { let families = vec![("0", 5), ("1", 3), ("2", 7)]; create_grouped_bam(&input_bam, families); - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output_bam.to_str().unwrap(), - "-f", - "1.0", - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(status.success(), "Downsample command failed"); + let cmd = Downsample::try_parse_from([ + "downsample", + "-i", + input_bam.to_str().unwrap(), + "-o", + output_bam.to_str().unwrap(), + "-f", + "1.0", + "--compression-level", + "1", + ]) + .expect("failed to parse downsample args"); + cmd.execute("test").expect("Downsample command failed"); // All records should be kept let output_records = read_bam_records(&output_bam); assert_eq!(output_records.len(), 15, "All 15 records should be kept with fraction=1.0"); } -/// Test error handling for invalid fraction. -#[test] -fn test_downsample_invalid_fraction() { +/// Test error handling for invalid fractions. Each case writes to a +/// distinct output path so an artifact left by one case can never make a +/// later assertion pass by accident. +#[rstest::rstest] +#[case::zero("0.0", "output_zero.bam")] +#[case::greater_than_one("1.5", "output_gt1.bam")] +fn test_downsample_invalid_fraction(#[case] fraction: &str, #[case] output_name: &str) { let temp_dir = TempDir::new().expect("Failed to create temp dir"); let input_bam = temp_dir.path().join("input.bam"); - let output_bam = temp_dir.path().join("output.bam"); + let output_bam = temp_dir.path().join(output_name); create_grouped_bam(&input_bam, vec![("0", 5)]); - // Test fraction = 0.0 - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output_bam.to_str().unwrap(), - "-f", - "0.0", - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(!status.success(), "Fraction=0.0 should fail"); - - // Test fraction > 1.0 - let status = Command::new(env!("CARGO_BIN_EXE_fgumi")) - .args([ - "downsample", - "-i", - input_bam.to_str().unwrap(), - "-o", - output_bam.to_str().unwrap(), - "-f", - "1.5", - "--compression-level", - "1", - ]) - .status() - .expect("Failed to run downsample command"); - - assert!(!status.success(), "Fraction>1.0 should fail"); + let cmd = Downsample::try_parse_from([ + "downsample", + "-i", + input_bam.to_str().unwrap(), + "-o", + output_bam.to_str().unwrap(), + "-f", + fraction, + "--compression-level", + "1", + ]) + .expect("failed to parse downsample args"); + assert!(cmd.execute("test").is_err(), "Fraction={fraction} should fail"); } From b1eef87f2615340cb84193e71ae4d2e43e34626e Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Mon, 18 May 2026 13:02:29 -0700 Subject: [PATCH 2/3] test(sort): invoke sort Command::execute in-process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts the fgumi sort invocations in two test files to in-process Sort::execute() calls. Samtools invocations stay on the subprocess path (samtools is an external tool). - test_sort_correctness.rs: replaces the fgumi(args) subprocess helper with fgumi_sort_in_process(args), wiring both verify_sorted and sort_bam_with_args through Sort::try_parse_from + execute(). - test_sort_write_index.rs: replaces 4 direct fgumi binary invocations with in-process calls; drops the unused fgumi_binary_path() helper. Merges test_sort_fast_write_index and test_sort_standard_write_index into a single test_sort_write_index because the --fast/standard distinction no longer exists on Sort, so the two tests exercised identical code paths. Drops the --cell-tag CB and --fast arguments at several call sites because Sort no longer exposes these as CLI flags (template-coordinate sort hard-codes SamTag::CB via parse_cell_tag; raw-bytes mode is internal). The flags were dead under subprocess too — both files are #[ignore="requires samtools"] so the parse error never surfaced. --- tests/integration/test_sort_correctness.rs | 40 ++--- tests/integration/test_sort_write_index.rs | 172 ++++++++------------- 2 files changed, 81 insertions(+), 131 deletions(-) diff --git a/tests/integration/test_sort_correctness.rs b/tests/integration/test_sort_correctness.rs index 7fba3c1b6..d93985cad 100644 --- a/tests/integration/test_sort_correctness.rs +++ b/tests/integration/test_sort_correctness.rs @@ -9,19 +9,23 @@ use std::ffi::OsString; use std::fmt::Write as _; use std::path::Path; -use std::process::{Command, Output}; +use std::process::Command; use tempfile::TempDir; +use clap::Parser; +use fgumi_lib::commands::command::Command as FgumiCommand; +use fgumi_lib::commands::sort::Sort; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -fn fgumi_binary() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_BIN_EXE_fgumi")) -} - -fn fgumi(args: &[OsString]) -> Output { - Command::new(fgumi_binary()).args(args).output().expect("failed to execute fgumi") +fn fgumi_sort_in_process(args: &[OsString]) -> anyhow::Result<()> { + // `clap::Parser::try_parse_from` accepts any iterator of `Into` + // values, so feeding `OsString` through avoids a UTF-8 round-trip that + // would panic on a non-UTF-8 temp-dir path. + let cmd = Sort::try_parse_from(args.iter().cloned()).expect("failed to parse sort args"); + cmd.execute("test") } fn samtools_available() -> bool { @@ -100,7 +104,7 @@ fn create_test_bam(dir: &Path, num_reads: usize) -> std::path::PathBuf { /// Run `fgumi sort --verify` and return whether the file is correctly sorted. fn verify_sorted(bam_path: &Path, order: &str) -> bool { - let mut cmd_args: Vec = vec![ + let cmd_args: Vec = vec![ "sort".into(), "--verify".into(), "-i".into(), @@ -108,12 +112,7 @@ fn verify_sorted(bam_path: &Path, order: &str) -> bool { "--order".into(), order.into(), ]; - if order == "template-coordinate" { - cmd_args.push("--cell-tag".into()); - cmd_args.push("CB".into()); - } - let output = fgumi(&cmd_args); - output.status.success() + fgumi_sort_in_process(&cmd_args).is_ok() } /// Count records in a BAM using samtools. @@ -157,19 +156,12 @@ fn sort_bam_with_args( "--temp-compression".into(), "1".into(), ]; - if order == "template-coordinate" { - cmd_args.push("--cell-tag".into()); - cmd_args.push("CB".into()); - } for arg in extra_args { cmd_args.push((*arg).into()); } - let output_result = fgumi(&cmd_args); - assert!( - output_result.status.success(), - "fgumi sort failed for order={order} threads={threads} memory={max_memory}:\nstderr: {}", - String::from_utf8_lossy(&output_result.stderr), - ); + fgumi_sort_in_process(&cmd_args).unwrap_or_else(|e| { + panic!("fgumi sort failed for order={order} threads={threads} memory={max_memory}: {e:#}") + }); } // --------------------------------------------------------------------------- diff --git a/tests/integration/test_sort_write_index.rs b/tests/integration/test_sort_write_index.rs index a6f8d9526..2f9ffe0ab 100644 --- a/tests/integration/test_sort_write_index.rs +++ b/tests/integration/test_sort_write_index.rs @@ -1,8 +1,15 @@ //! Integration tests for sort --write-index functionality. //! -//! These tests verify that the BAM index generation works correctly -//! for both fast (raw-bytes) and standard (record buffer) sorting modes. +//! Verifies that BAM index generation produces a valid `.bai` that +//! samtools can query, across the coordinate sort code path. +//! +//! fgumi sort is invoked in-process via `Sort::execute()`; samtools +//! is invoked as a subprocess (external tool). +use clap::Parser; +use fgumi_lib::commands::command::Command as FgumiCommand; +use fgumi_lib::commands::sort::Sort; +use std::ffi::OsStr; use std::path::Path; use std::process::Command; use tempfile::TempDir; @@ -12,11 +19,6 @@ fn samtools_available() -> bool { Command::new("samtools").arg("--version").output().map(|o| o.status.success()).unwrap_or(false) } -/// Check if the fgumi binary exists. -fn fgumi_binary_path() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_BIN_EXE_fgumi")) -} - /// Create a small test BAM file using samtools. fn create_test_bam(dir: &Path) -> std::path::PathBuf { let bam_path = dir.join("test_input.bam"); @@ -64,10 +66,10 @@ fn verify_index_works(bam_path: &Path) -> bool { count > 0 } -/// Test sort --fast --write-index creates a valid index. +/// Test sort --write-index creates a valid index. #[test] #[ignore = "requires samtools"] -fn test_sort_fast_write_index() { +fn test_sort_write_index() { if !samtools_available() { eprintln!("Skipping: samtools not available"); return; @@ -78,64 +80,24 @@ fn test_sort_fast_write_index() { let sorted_bam_path = temp_dir.path().join("sorted.bam"); let index_path = temp_dir.path().join("sorted.bam.bai"); - // Run fgumi sort --fast --write-index - let status = Command::new(fgumi_binary_path()) - .args([ - "sort", - "--fast", - "-i", - input_bam.to_str().unwrap(), - "-o", - sorted_bam_path.to_str().unwrap(), - "--order", - "coordinate", - "--write-index", - "-m", - "10M", - ]) - .status() - .expect("Failed to run fgumi sort"); - - assert!(status.success(), "fgumi sort --fast --write-index failed"); - assert!(sorted_bam_path.exists(), "Output BAM not created"); - assert!(index_path.exists(), "Output BAI not created"); - - // Verify index works - assert!(verify_index_works(&sorted_bam_path), "Index does not work for region queries"); -} - -/// Test sort (non-fast) --write-index creates a valid index. -#[test] -#[ignore = "requires samtools"] -fn test_sort_standard_write_index() { - if !samtools_available() { - eprintln!("Skipping: samtools not available"); - return; - } - - let temp_dir = TempDir::new().unwrap(); - let input_bam = create_test_bam(temp_dir.path()); - let sorted_bam_path = temp_dir.path().join("sorted.bam"); - let index_path = temp_dir.path().join("sorted.bam.bai"); - - // Run fgumi sort (non-fast) --write-index - let status = Command::new(fgumi_binary_path()) - .args([ - "sort", - "-i", - input_bam.to_str().unwrap(), - "-o", - sorted_bam_path.to_str().unwrap(), - "--order", - "coordinate", - "--write-index", - "-m", - "10M", - ]) - .status() - .expect("Failed to run fgumi sort"); - - assert!(status.success(), "fgumi sort --write-index failed"); + // Run fgumi sort (non-fast) --write-index. Pass `&OsStr` directly so + // `try_parse_from` doesn't UTF-8 round-trip the temp paths (would panic + // on a non-UTF-8 temp dir). + let cmd = Sort::try_parse_from([ + OsStr::new("sort"), + OsStr::new("-i"), + input_bam.as_os_str(), + OsStr::new("-o"), + sorted_bam_path.as_os_str(), + OsStr::new("--order"), + OsStr::new("coordinate"), + OsStr::new("--write-index"), + OsStr::new("-m"), + OsStr::new("10M"), + ]) + .expect("failed to parse sort args"); + + cmd.execute("test").expect("fgumi sort --write-index failed"); assert!(sorted_bam_path.exists(), "Output BAM not created"); assert!(index_path.exists(), "Output BAI not created"); @@ -157,27 +119,24 @@ fn test_sort_write_index_multithreaded() { let sorted_bam_path = temp_dir.path().join("sorted.bam"); let index_path = temp_dir.path().join("sorted.bam.bai"); - // Run fgumi sort --fast --write-index with multiple threads - let status = Command::new(fgumi_binary_path()) - .args([ - "sort", - "--fast", - "-i", - input_bam.to_str().unwrap(), - "-o", - sorted_bam_path.to_str().unwrap(), - "--order", - "coordinate", - "--write-index", - "-m", - "10M", - "-@", - "4", - ]) - .status() - .expect("Failed to run fgumi sort"); - - assert!(status.success(), "fgumi sort --fast --write-index -@ 4 failed"); + // Run fgumi sort --write-index with multiple threads + let cmd = Sort::try_parse_from([ + OsStr::new("sort"), + OsStr::new("-i"), + input_bam.as_os_str(), + OsStr::new("-o"), + sorted_bam_path.as_os_str(), + OsStr::new("--order"), + OsStr::new("coordinate"), + OsStr::new("--write-index"), + OsStr::new("-m"), + OsStr::new("10M"), + OsStr::new("-@"), + OsStr::new("4"), + ]) + .expect("failed to parse sort args"); + + cmd.execute("test").expect("fgumi sort --write-index -@ 4 failed"); assert!(sorted_bam_path.exists(), "Output BAM not created"); assert!(index_path.exists(), "Output BAI not created"); @@ -192,25 +151,24 @@ fn test_write_index_requires_coordinate_sort() { let sorted_bam_path = temp_dir.path().join("sorted.bam"); // Run fgumi sort with queryname order and --write-index (should fail) - let output = Command::new(fgumi_binary_path()) - .args([ - "sort", - "-i", - "/dev/null", // Won't get to reading input - "-o", - sorted_bam_path.to_str().unwrap(), - "--order", - "queryname", - "--write-index", - ]) - .output() - .expect("Failed to run fgumi sort"); - - assert!(!output.status.success(), "fgumi sort --order queryname --write-index should fail"); - - let stderr = String::from_utf8_lossy(&output.stderr); + let cmd = Sort::try_parse_from([ + OsStr::new("sort"), + OsStr::new("-i"), + OsStr::new("/dev/null"), // Won't get to reading input + OsStr::new("-o"), + sorted_bam_path.as_os_str(), + OsStr::new("--order"), + OsStr::new("queryname"), + OsStr::new("--write-index"), + ]) + .expect("failed to parse sort args"); + + let result = cmd.execute("test"); + assert!(result.is_err(), "fgumi sort --order queryname --write-index should fail"); + + let err_msg = format!("{:#}", result.unwrap_err()); assert!( - stderr.contains("--write-index is only valid for coordinate sort"), - "Expected error about coordinate sort, got: {stderr}" + err_msg.contains("--write-index is only valid for coordinate sort"), + "Expected error about coordinate sort, got: {err_msg}" ); } From 24a952d89933b4e87a42507a044a2bbd3fbb1944 Mon Sep 17 00:00:00 2001 From: Nils Homer Date: Mon, 18 May 2026 13:15:29 -0700 Subject: [PATCH 3/3] test(e2e): invoke pipeline commands in-process via Command::execute Convert the remaining fgumi-subprocess uses in the e2e regression file to direct \`::try_parse_from([...]).execute("test")\` calls. Covers extract, sort, group, simplex, filter, dedup, and the two simulate subcommands (grouped-reads, fastq-reads). The \`fgumi_ok\` helper is removed (no callers remain). The \`fgumi\` helper and \`args!\` macro are retained for \`test_group_rejects_extract_output_without_sort_step\`, which must pin exit-status and stderr content of the real binary. --- tests/integration/test_e2e_regression.rs | 382 ++++++++++++----------- 1 file changed, 208 insertions(+), 174 deletions(-) diff --git a/tests/integration/test_e2e_regression.rs b/tests/integration/test_e2e_regression.rs index 48c1c0be2..3dca6b825 100644 --- a/tests/integration/test_e2e_regression.rs +++ b/tests/integration/test_e2e_regression.rs @@ -8,9 +8,17 @@ use clap::Parser; use fgumi_lib::commands::command::Command as FgumiCommand; use fgumi_lib::commands::compare::{CompareBams, CompareMismatch}; +use fgumi_lib::commands::dedup::MarkDuplicates; +use fgumi_lib::commands::extract::Extract; +use fgumi_lib::commands::filter::Filter; +use fgumi_lib::commands::group::GroupReadsByUmi; +use fgumi_lib::commands::simplex::Simplex; +use fgumi_lib::commands::simulate::fastq_reads::FastqReads; +use fgumi_lib::commands::simulate::grouped_reads::GroupedReads; +use fgumi_lib::commands::sort::Sort; use fgumi_lib::sam::SamTag; use noodles::bam; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; @@ -26,18 +34,6 @@ fn fgumi(args: &[OsString]) -> Output { Command::new(env!("CARGO_BIN_EXE_fgumi")).args(args).output().expect("failed to execute fgumi") } -/// Run a fgumi subcommand, assert it succeeded, and return stdout. -fn fgumi_ok(args: &[OsString]) -> String { - let output = fgumi(args); - assert!( - output.status.success(), - "fgumi {args:?} failed:\nstdout: {}\nstderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - String::from_utf8_lossy(&output.stdout).to_string() -} - /// Build a command argument list from mixed string and path arguments. macro_rules! args { ($($arg:expr),+ $(,)?) => { @@ -69,26 +65,29 @@ fn simulate_grouped_reads( seed: u32, num_molecules: u32, ) { - fgumi_ok(args![ - "simulate", - "grouped-reads", - "-o", - output, - "--truth", - truth, - "--reference", - reference, - "--num-molecules", - &num_molecules.to_string(), - "--seed", - &seed.to_string(), - "--read-length", - "100", - "--umi-length", - "6", - "--min-family-size", - "2", - ]); + let num_molecules_str = num_molecules.to_string(); + let seed_str = seed.to_string(); + let cmd = GroupedReads::try_parse_from([ + OsStr::new("grouped-reads"), + OsStr::new("-o"), + output.as_os_str(), + OsStr::new("--truth"), + truth.as_os_str(), + OsStr::new("--reference"), + reference.as_os_str(), + OsStr::new("--num-molecules"), + OsStr::new(&num_molecules_str), + OsStr::new("--seed"), + OsStr::new(&seed_str), + OsStr::new("--read-length"), + OsStr::new("100"), + OsStr::new("--umi-length"), + OsStr::new("6"), + OsStr::new("--min-family-size"), + OsStr::new("2"), + ]) + .expect("failed to parse grouped-reads args"); + cmd.execute("test").expect("simulate grouped-reads failed"); } /// Generate FASTQ reads using simulate with deterministic seed. @@ -100,32 +99,35 @@ fn simulate_fastq_reads( seed: u32, num_molecules: u32, ) { - fgumi_ok(args![ - "simulate", - "fastq-reads", - "-1", - r1, - "-2", - r2, - "--truth", - truth, - "--reference", - reference, - "--num-molecules", - &num_molecules.to_string(), - "--seed", - &seed.to_string(), - "--read-length", - "100", - "--umi-length", - "6", - "--read-structure-r1", - "6M94T", - "--read-structure-r2", - "100T", - "--min-family-size", - "2", - ]); + let num_molecules_str = num_molecules.to_string(); + let seed_str = seed.to_string(); + let cmd = FastqReads::try_parse_from([ + OsStr::new("fastq-reads"), + OsStr::new("-1"), + r1.as_os_str(), + OsStr::new("-2"), + r2.as_os_str(), + OsStr::new("--truth"), + truth.as_os_str(), + OsStr::new("--reference"), + reference.as_os_str(), + OsStr::new("--num-molecules"), + OsStr::new(&num_molecules_str), + OsStr::new("--seed"), + OsStr::new(&seed_str), + OsStr::new("--read-length"), + OsStr::new("100"), + OsStr::new("--umi-length"), + OsStr::new("6"), + OsStr::new("--read-structure-r1"), + OsStr::new("6M94T"), + OsStr::new("--read-structure-r2"), + OsStr::new("100T"), + OsStr::new("--min-family-size"), + OsStr::new("2"), + ]) + .expect("failed to parse fastq-reads args"); + cmd.execute("test").expect("simulate fastq-reads failed"); } // --------------------------------------------------------------------------- @@ -134,37 +136,52 @@ fn simulate_fastq_reads( /// Run simplex consensus calling with single-threaded deterministic execution. fn run_simplex(input: &Path, output: &Path, min_reads: u32) { - fgumi_ok(args![ - "simplex", - "-i", - input, - "-o", - output, - "--threads", - "1", - "--min-reads", - &min_reads.to_string(), - ]); + let min_reads_str = min_reads.to_string(); + let cmd = Simplex::try_parse_from([ + OsStr::new("simplex"), + OsStr::new("-i"), + input.as_os_str(), + OsStr::new("-o"), + output.as_os_str(), + OsStr::new("--threads"), + OsStr::new("1"), + OsStr::new("--min-reads"), + OsStr::new(&min_reads_str), + ]) + .expect("failed to parse simplex args"); + cmd.execute("test").expect("simplex failed"); } /// Run filter on a consensus BAM. fn run_filter(input: &Path, output: &Path, min_reads: u32, min_base_quality: u32) { - fgumi_ok(args![ - "filter", - "-i", - input, - "-o", - output, - "--min-reads", - &min_reads.to_string(), - "--min-base-quality", - &min_base_quality.to_string(), - ]); + let min_reads_str = min_reads.to_string(); + let min_base_quality_str = min_base_quality.to_string(); + let cmd = Filter::try_parse_from([ + OsStr::new("filter"), + OsStr::new("-i"), + input.as_os_str(), + OsStr::new("-o"), + output.as_os_str(), + OsStr::new("--min-reads"), + OsStr::new(&min_reads_str), + OsStr::new("--min-base-quality"), + OsStr::new(&min_base_quality_str), + ]) + .expect("failed to parse filter args"); + cmd.execute("test").expect("filter failed"); } /// Run dedup on a grouped BAM. fn run_dedup(input: &Path, output: &Path) { - fgumi_ok(args!["dedup", "--input", input, "--output", output]); + let cmd = MarkDuplicates::try_parse_from([ + OsStr::new("dedup"), + OsStr::new("--input"), + input.as_os_str(), + OsStr::new("--output"), + output.as_os_str(), + ]) + .expect("failed to parse dedup args"); + cmd.execute("test").expect("dedup failed"); } // --------------------------------------------------------------------------- @@ -176,11 +193,11 @@ fn run_dedup(input: &Path, output: &Path) { /// any other anyhow error. fn compare_bams_in_process(bam1: &Path, bam2: &Path, mode: &str) -> bool { let cmd = CompareBams::try_parse_from([ - "bams", - bam1.to_str().unwrap(), - bam2.to_str().unwrap(), - "--mode", - mode, + OsStr::new("bams"), + bam1.as_os_str(), + bam2.as_os_str(), + OsStr::new("--mode"), + OsStr::new(mode), ]) .expect("failed to parse compare bams args"); match cmd.execute("test") { @@ -297,47 +314,56 @@ fn test_full_pipeline_extract_to_filter() { // Run the full pipeline twice to verify determinism for suffix in ["a", "b"] { let extracted = tmp.path().join(format!("extracted_{suffix}.bam")); - fgumi_ok(args![ - "extract", - "--inputs", - &r1, - &r2, - "--output", - &extracted, - "--read-structures", - "6M94T", - "100T", - "--sample", - "test_sample", - "--library", - "test_lib", - ]); + Extract::try_parse_from([ + OsStr::new("extract"), + OsStr::new("--inputs"), + r1.as_os_str(), + r2.as_os_str(), + OsStr::new("--output"), + extracted.as_os_str(), + OsStr::new("--read-structures"), + OsStr::new("6M94T"), + OsStr::new("100T"), + OsStr::new("--sample"), + OsStr::new("test_sample"), + OsStr::new("--library"), + OsStr::new("test_lib"), + ]) + .expect("failed to parse extract args") + .execute("test") + .expect("extract failed"); // Extract emits SO:unsorted GO:query (no SS); `fgumi group` requires // template-coordinate sorted input, so put the sort step between them. let sorted = tmp.path().join(format!("sorted_{suffix}.bam")); - fgumi_ok(args![ - "sort", - "--input", - &extracted, - "--output", - &sorted, - "--order", - "template-coordinate", - ]); + Sort::try_parse_from([ + OsStr::new("sort"), + OsStr::new("--input"), + extracted.as_os_str(), + OsStr::new("--output"), + sorted.as_os_str(), + OsStr::new("--order"), + OsStr::new("template-coordinate"), + ]) + .expect("failed to parse sort args") + .execute("test") + .expect("sort failed"); let grouped = tmp.path().join(format!("grouped_{suffix}.bam")); - fgumi_ok(args![ - "group", - "--input", - &sorted, - "--output", - &grouped, - "--strategy", - "identity", - "--edits", - "0", - ]); + GroupReadsByUmi::try_parse_from([ + OsStr::new("group"), + OsStr::new("--input"), + sorted.as_os_str(), + OsStr::new("--output"), + grouped.as_os_str(), + OsStr::new("--strategy"), + OsStr::new("identity"), + OsStr::new("--edits"), + OsStr::new("0"), + ]) + .expect("failed to parse group args") + .execute("test") + .expect("group failed"); let simplex = tmp.path().join(format!("simplex_{suffix}.bam")); run_simplex(&grouped, &simplex, 1); @@ -379,21 +405,24 @@ fn test_group_rejects_extract_output_without_sort_step() { simulate_fastq_reads(&r1, &r2, &truth, &reference, 42, 50); let extracted = tmp.path().join("extracted.bam"); - fgumi_ok(args![ - "extract", - "--inputs", - &r1, - &r2, - "--output", - &extracted, - "--read-structures", - "6M94T", - "100T", - "--sample", - "test_sample", - "--library", - "test_lib", - ]); + Extract::try_parse_from([ + OsStr::new("extract"), + OsStr::new("--inputs"), + r1.as_os_str(), + r2.as_os_str(), + OsStr::new("--output"), + extracted.as_os_str(), + OsStr::new("--read-structures"), + OsStr::new("6M94T"), + OsStr::new("100T"), + OsStr::new("--sample"), + OsStr::new("test_sample"), + OsStr::new("--library"), + OsStr::new("test_lib"), + ]) + .expect("failed to parse extract args") + .execute("test") + .expect("extract failed"); // Run `fgumi group` on the extract output directly — must fail. let grouped = tmp.path().join("grouped.bam"); @@ -495,46 +524,51 @@ fn test_e2e_methylation_pipeline() { // Generate grouped reads with EM-Seq methylation let grouped = tmp.path().join("grouped.bam"); let truth = tmp.path().join("truth.tsv"); - fgumi_ok(args![ - "simulate", - "grouped-reads", - "-o", - &grouped, - "--truth", - &truth, - "--reference", - &ref_path, - "--num-molecules", - "50", - "--seed", - "42", - "--read-length", - "100", - "--umi-length", - "6", - "--min-family-size", - "3", - "--methylation-mode", - "em-seq" - ]); + GroupedReads::try_parse_from([ + OsStr::new("grouped-reads"), + OsStr::new("-o"), + grouped.as_os_str(), + OsStr::new("--truth"), + truth.as_os_str(), + OsStr::new("--reference"), + ref_path.as_os_str(), + OsStr::new("--num-molecules"), + OsStr::new("50"), + OsStr::new("--seed"), + OsStr::new("42"), + OsStr::new("--read-length"), + OsStr::new("100"), + OsStr::new("--umi-length"), + OsStr::new("6"), + OsStr::new("--min-family-size"), + OsStr::new("3"), + OsStr::new("--methylation-mode"), + OsStr::new("em-seq"), + ]) + .expect("failed to parse grouped-reads args") + .execute("test") + .expect("simulate grouped-reads failed"); // Run simplex consensus with methylation mode let simplex = tmp.path().join("simplex.bam"); - fgumi_ok(args![ - "simplex", - "-i", - &grouped, - "-o", - &simplex, - "--threads", - "1", - "--min-reads", - "1", - "--methylation-mode", - "em-seq", - "--ref", - &ref_path - ]); + Simplex::try_parse_from([ + OsStr::new("simplex"), + OsStr::new("-i"), + grouped.as_os_str(), + OsStr::new("-o"), + simplex.as_os_str(), + OsStr::new("--threads"), + OsStr::new("1"), + OsStr::new("--min-reads"), + OsStr::new("1"), + OsStr::new("--methylation-mode"), + OsStr::new("em-seq"), + OsStr::new("--ref"), + ref_path.as_os_str(), + ]) + .expect("failed to parse simplex args") + .execute("test") + .expect("simplex failed"); // Verify the simplex output actually carries methylation emission. // The simplex BAM should contain at least one consensus record with the