diff --git a/src/commands/clip.rs b/src/commands/clip.rs index ebe375c06..9fd3700e0 100644 --- a/src/commands/clip.rs +++ b/src/commands/clip.rs @@ -37,7 +37,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use super::command::Command; use super::common::{ BamIoOptions, CompressionOptions, QueueMemoryOptions, SchedulerOptions, ThreadingOptions, - build_pipeline_config, + build_pipeline_config, parse_bool, }; /// Clips reads in a BAM file to remove overlaps @@ -100,7 +100,7 @@ pub struct Clip { pub sort_order: Option, /// Clip overlapping read pairs - #[arg(long = "clip-overlapping-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "clip-overlapping-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub clip_overlapping_reads: bool, /// Clip reads that extend past their mate's start position @@ -111,6 +111,7 @@ pub struct Clip { num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, + value_parser = parse_bool, )] pub clip_extending_past_mate: bool, @@ -131,11 +132,11 @@ pub struct Clip { pub read_two_three_prime: usize, /// Upgrade existing clipping to the specified clipping mode - #[arg(short = 'H', long = "upgrade-clipping", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'H', long = "upgrade-clipping", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub upgrade_clipping: bool, /// Automatically clip extended attributes that match read length - #[arg(short = 'a', long = "auto-clip-attributes", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'a', long = "auto-clip-attributes", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub auto_clip_attributes: bool, /// Output file for clipping metrics diff --git a/src/commands/common.rs b/src/commands/common.rs index a213ffc4d..914208e88 100644 --- a/src/commands/common.rs +++ b/src/commands/common.rs @@ -104,11 +104,11 @@ pub struct ConsensusCallingOptions { pub min_input_base_quality: u8, /// Produce per-base tags (cd, ce) in addition to per-read tags - #[arg(short = 'B', long = "output-per-base-tags", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'B', long = "output-per-base-tags", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub output_per_base_tags: bool, /// Quality-trim reads before consensus calling (removes low-quality bases from ends) - #[arg(long = "trim", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "trim", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub trim: bool, /// Minimum consensus base quality (output consensus bases below this are masked to N) @@ -214,7 +214,7 @@ impl Default for ReadGroupOptions { #[derive(Debug, Clone, Args)] pub struct OverlappingConsensusOptions { /// Consensus call overlapping bases in read pairs before UMI consensus calling - #[arg(long = "consensus-call-overlapping-bases", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "consensus-call-overlapping-bases", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub consensus_call_overlapping_bases: bool, } @@ -315,7 +315,7 @@ pub struct SchedulerOptions { /// /// Shows per-step timing, throughput, contention metrics, and /// per-thread work distribution. - #[arg(long = "pipeline-stats", default_value_t = false, hide = true)] + #[arg(long = "pipeline-stats", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool, hide = true)] pub pipeline_stats: bool, /// Timeout in seconds for deadlock detection (default: 10, 0 = disabled). @@ -329,7 +329,7 @@ pub struct SchedulerOptions { /// /// Uses progressive doubling: 2x -> 4x -> unbind, with restoration /// after 30s of sustained progress. - #[arg(long = "deadlock-recover", default_value_t = false, hide = true)] + #[arg(long = "deadlock-recover", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool, hide = true)] pub deadlock_recover: bool, } @@ -451,7 +451,7 @@ pub struct QueueMemoryOptions { /// When true, total memory = queue-memory * threads. For example, /// --queue-memory 768 with --threads 16 allocates 12 GB total. /// Set to false for a fixed total memory budget regardless of thread count. - #[arg(long = "queue-memory-per-thread", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "queue-memory-per-thread", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub queue_memory_per_thread: bool, /// DEPRECATED: Use --queue-memory instead. Memory limit for pipeline queues in megabytes. @@ -623,6 +623,16 @@ impl QueueMemoryOptions { } } +/// Parses a boolean value from a string, accepting: true/false, yes/no, y/n, t/f +/// (case-insensitive). Matches sopt/fgbio behavior. +pub(crate) fn parse_bool(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "true" | "t" | "yes" | "y" => Ok(true), + "false" | "f" | "no" | "n" => Ok(false), + _ => Err(format!("Invalid boolean value '{s}'. Expected: true|false|yes|no|y|n|t|f")), + } +} + /// Parses a memory size string into bytes. /// /// Accepts both plain numbers (interpreted as MB) and human-readable formats like: @@ -1208,4 +1218,76 @@ mod tests { let cmd = TestBoolFlags::try_parse_from(args).expect("valid CLI args should parse"); assert_eq!(cmd.queue_memory.queue_memory_per_thread, expected); } + + #[rstest] + #[case("true", true)] + #[case("false", false)] + #[case("yes", true)] + #[case("no", false)] + #[case("t", true)] + #[case("f", false)] + #[case("y", true)] + #[case("n", false)] + #[case("True", true)] + #[case("TRUE", true)] + #[case("False", false)] + #[case("FALSE", false)] + #[case("Yes", true)] + #[case("YES", true)] + #[case("No", false)] + #[case("NO", false)] + #[case("T", true)] + #[case("F", false)] + #[case("Y", true)] + #[case("N", false)] + #[case("tRuE", true)] + #[case("fAlSe", false)] + #[case("yEs", true)] + fn test_parse_bool_valid(#[case] input: &str, #[case] expected: bool) { + assert_eq!(parse_bool(input).expect("should parse"), expected); + } + + #[rstest] + #[case("")] + #[case("tru")] + #[case("fals")] + #[case("truee")] + #[case("noo")] + #[case("yess")] + #[case("maybe")] + #[case("0")] + #[case("1")] + #[case("on")] + #[case("off")] + #[case(" true")] + #[case("true ")] + fn test_parse_bool_invalid(#[case] input: &str) { + assert!(parse_bool(input).is_err(), "expected error for input: {input:?}"); + } + + #[rstest] + #[case(&["test", "--trim", "yes"], true)] + #[case(&["test", "--trim", "no"], false)] + #[case(&["test", "--trim", "y"], true)] + #[case(&["test", "--trim", "n"], false)] + #[case(&["test", "--trim", "t"], true)] + #[case(&["test", "--trim", "f"], false)] + #[case(&["test", "--trim", "YES"], true)] + #[case(&["test", "--trim", "NO"], false)] + #[case(&["test", "--trim=yes"], true)] + #[case(&["test", "--trim=no"], false)] + fn test_extended_bool_values_in_cli(#[case] args: &[&str], #[case] expected: bool) { + let cmd = TestBoolFlags::try_parse_from(args).expect("valid CLI args should parse"); + assert_eq!(cmd.consensus.trim, expected); + } + + #[rstest] + #[case(&["test", "--trim", "maybe"])] + #[case(&["test", "--trim", "0"])] + #[case(&["test", "--trim", "1"])] + #[case(&["test", "--trim", "on"])] + #[case(&["test", "--trim", "off"])] + fn test_extended_bool_values_in_cli_invalid(#[case] args: &[&str]) { + assert!(TestBoolFlags::try_parse_from(args).is_err()); + } } diff --git a/src/commands/compare/bams.rs b/src/commands/compare/bams.rs index 862422882..f9c66f060 100644 --- a/src/commands/compare/bams.rs +++ b/src/commands/compare/bams.rs @@ -38,6 +38,7 @@ use std::path::{Path, PathBuf}; use std::thread; use crate::commands::command::Command; +use crate::commands::common::parse_bool; use super::raw_compare::{raw_compare_structured, raw_records_byte_equal}; @@ -164,14 +165,14 @@ pub struct CompareBams { pub max_diffs: usize, /// Quiet mode - only exit code indicates result (0=equal, 1=different) - #[arg(short = 'q', long = "quiet")] + #[arg(short = 'q', long = "quiet", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub quiet: bool, /// Ignore record order when comparing in grouping mode. /// Required for comparing output from consensus commands (simplex/duplex/codec) /// when run with --threads, as parallel processing causes non-deterministic ordering. /// Only valid with --mode grouping. - #[arg(long = "ignore-order", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "ignore-order", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub ignore_order: bool, /// Initial buffer size for --ignore-order mode (number of records) diff --git a/src/commands/compare/metrics.rs b/src/commands/compare/metrics.rs index fe6574a52..ecad35e9e 100644 --- a/src/commands/compare/metrics.rs +++ b/src/commands/compare/metrics.rs @@ -3,6 +3,7 @@ //! This is useful for comparing metrics files produced by fgbio and fgumi, //! which may have slightly different floating-point representations. +use crate::commands::common::parse_bool; use anyhow::Result; use clap::Parser; use fgumi_lib::logging::OperationTimer; @@ -69,11 +70,11 @@ pub struct CompareMetrics { pub max_diffs: usize, /// Quiet mode - only exit code indicates result (0=equal, 1=different) - #[arg(short = 'q', long = "quiet")] + #[arg(short = 'q', long = "quiet", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub quiet: bool, /// Verbose mode - print success message when files match - #[arg(short = 'v', long = "verbose")] + #[arg(short = 'v', long = "verbose", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub verbose: bool, } diff --git a/src/commands/correct.rs b/src/commands/correct.rs index 0420b32ca..91f5853ba 100644 --- a/src/commands/correct.rs +++ b/src/commands/correct.rs @@ -72,7 +72,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::commands::command::Command; use crate::commands::common::{ BamIoOptions, CompressionOptions, QueueMemoryOptions, RejectsOptions, SchedulerOptions, - ThreadingOptions, build_pipeline_config, + ThreadingOptions, build_pipeline_config, parse_bool, }; /// Result of matching an observed UMI to an expected UMI. @@ -229,7 +229,7 @@ pub struct CorrectUmis { pub umi_tag: String, /// Don't store original UMIs in a separate tag. - #[arg(long)] + #[arg(long, default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub dont_store_original_umis: bool, /// Size of the LRU cache for UMI matching. @@ -241,7 +241,7 @@ pub struct CorrectUmis { pub min_corrected: Option, /// Reverse complement UMIs before matching. - #[arg(long)] + #[arg(long, default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub revcomp: bool, /// Threading options for parallel processing. diff --git a/src/commands/dedup.rs b/src/commands/dedup.rs index 07f909595..6ba5894ad 100644 --- a/src/commands/dedup.rs +++ b/src/commands/dedup.rs @@ -53,7 +53,7 @@ use serde::{Deserialize, Serialize}; use crate::commands::command::Command; use crate::commands::common::{ BamIoOptions, CompressionOptions, QueueMemoryOptions, SchedulerOptions, ThreadingOptions, - build_pipeline_config, + build_pipeline_config, parse_bool, }; use fgumi_lib::sort::PA_TAG; use fgumi_lib::sort::bam_fields; @@ -1073,7 +1073,7 @@ pub struct MarkDuplicates { pub family_size_histogram: Option, /// Remove duplicates instead of just marking them - #[arg(short = 'r', long = "remove-duplicates", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'r', long = "remove-duplicates", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub remove_duplicates: bool, /// The tag containing the raw UMI sequence @@ -1097,7 +1097,7 @@ pub struct MarkDuplicates { pub min_map_q: Option, /// Include reads flagged as not passing QC - #[arg(short = 'n', long = "include-non-pf-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'n', long = "include-non-pf-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub include_non_pf_reads: bool, /// UMI grouping strategy @@ -1126,7 +1126,7 @@ pub struct MarkDuplicates { /// Skip UMI-based grouping; group by position only. Forces identity strategy /// and ignores any existing UMI tags. - #[arg(long = "no-umi")] + #[arg(long = "no-umi", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub no_umi: bool, /// Scheduler and pipeline options diff --git a/src/commands/downsample.rs b/src/commands/downsample.rs index db30e8c70..6d1053f8f 100644 --- a/src/commands/downsample.rs +++ b/src/commands/downsample.rs @@ -24,7 +24,7 @@ use std::io::Write; use std::path::PathBuf; use crate::commands::command::Command; -use crate::commands::common::{BamIoOptions, CompressionOptions}; +use crate::commands::common::{BamIoOptions, CompressionOptions, parse_bool}; /// MI tag for molecular identifier const MI_TAG: Tag = Tag::new(b'M', b'I'); @@ -76,7 +76,7 @@ pub struct Downsample { pub seed: Option, /// Validate that MI tags appear in consecutive groups (error if seen non-consecutively) - #[arg(long = "validate-mi-order", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "validate-mi-order", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub validate_mi_order: bool, /// Output file for kept family size histogram diff --git a/src/commands/duplex_metrics.rs b/src/commands/duplex_metrics.rs index 1b248d53d..4c0165fd2 100644 --- a/src/commands/duplex_metrics.rs +++ b/src/commands/duplex_metrics.rs @@ -6,6 +6,7 @@ //! - Ideal duplex fraction calculation using proper binomial CDF //! - Optional interval filtering (BED or Picard interval list format) to restrict analysis to specific regions +use crate::commands::common::parse_bool; use anyhow::{Context, Result}; use clap::Parser; use fgoxide::io::DelimFile; @@ -100,7 +101,7 @@ pub struct DuplexMetrics { pub min_ba_reads: usize, /// Collect duplex UMI counts (memory intensive) - #[arg(long = "duplex-umi-counts", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "duplex-umi-counts", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub duplex_umi_counts: bool, /// Optional intervals file to restrict analysis (BED or Picard interval list format) diff --git a/src/commands/fastq.rs b/src/commands/fastq.rs index 4b244ca2f..816890447 100644 --- a/src/commands/fastq.rs +++ b/src/commands/fastq.rs @@ -3,6 +3,7 @@ //! This tool reads a BAM file and outputs interleaved FASTQ to stdout for piping to aligners. //! Input should be queryname-sorted or template-coordinate sorted. +use crate::commands::common::parse_bool; use anyhow::{Context, Result}; use clap::Parser; use fgumi_lib::bam_io::create_bam_reader; @@ -73,7 +74,7 @@ pub struct Fastq { pub input: PathBuf, /// Don't append /1 and /2 to read names. - #[arg(short = 'n', long = "no-read-suffix", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'n', long = "no-read-suffix", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub no_suffix: bool, /// Exclude reads with any of these flags present [0x900 = secondary|supplementary]. diff --git a/src/commands/filter.rs b/src/commands/filter.rs index 07303d3cf..5b2d223e8 100644 --- a/src/commands/filter.rs +++ b/src/commands/filter.rs @@ -41,7 +41,7 @@ use std::time::Instant; use crate::commands::command::Command; use crate::commands::common::{ BamIoOptions, CompressionOptions, QueueMemoryOptions, SchedulerOptions, ThreadingOptions, - build_pipeline_config, + build_pipeline_config, parse_bool, }; /// Filters and masks consensus reads based on various quality metrics. @@ -147,7 +147,7 @@ pub struct Filter { pub max_no_call_fraction: f64, /// Reverse per-base tags for negative strand reads - #[arg(short = 'R', long = "reverse-per-base-tags", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'R', long = "reverse-per-base-tags", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub reverse_per_base_tags: bool, /// Threading options for parallel processing @@ -155,7 +155,7 @@ pub struct Filter { pub threading: ThreadingOptions, /// Filter templates together (all primary reads must pass) - #[arg(long = "filter-by-template", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "filter-by-template", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub filter_by_template: bool, /// Optional output BAM file for rejected reads @@ -167,7 +167,7 @@ pub struct Filter { pub stats: Option, /// Require single-strand agreement for duplex consensus (mask bases where AB and BA disagree) - #[arg(short = 's', long = "require-single-strand-agreement", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 's', long = "require-single-strand-agreement", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub require_single_strand_agreement: bool, /// Compression options for output BAM. diff --git a/src/commands/group.rs b/src/commands/group.rs index eaef1299e..b0b3283c5 100644 --- a/src/commands/group.rs +++ b/src/commands/group.rs @@ -3,7 +3,7 @@ use crate::commands::command::Command; use crate::commands::common::{ BamIoOptions, CompressionOptions, QueueMemoryOptions, SchedulerOptions, ThreadingOptions, - build_pipeline_config, + build_pipeline_config, parse_bool, }; use ahash::AHashMap; use anyhow::{Context, Result, bail}; @@ -772,7 +772,7 @@ pub struct GroupReadsByUmi { pub min_map_q: Option, /// Include non-PF reads - #[arg(short = 'n', long = "include-non-pf-reads")] + #[arg(short = 'n', long = "include-non-pf-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub include_non_pf_reads: bool, /// Allow fully unmapped templates (both reads unmapped). @@ -789,7 +789,7 @@ pub struct GroupReadsByUmi { /// For paired UMIs (e.g., "ACGT-TGCA"), edit distance is computed on the /// concatenated sequence with dashes removed (30 bases for 15bp-15bp UMIs). /// With --edits 1, only 1 mismatch is allowed across ALL bases. - #[arg(long = "allow-unmapped")] + #[arg(long = "allow-unmapped", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub allow_unmapped: bool, /// The UMI assignment strategy @@ -819,7 +819,7 @@ pub struct GroupReadsByUmi { /// Skip UMI-based grouping; group by position only. Forces identity strategy /// and ignores any existing UMI tags. - #[arg(long = "no-umi")] + #[arg(long = "no-umi", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub no_umi: bool, /// Scheduler and pipeline statistics options. @@ -832,7 +832,7 @@ pub struct GroupReadsByUmi { /// Enable comprehensive memory debugging (reports every 1 second) #[cfg(feature = "memory-debug")] - #[arg(long)] + #[arg(long, default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub debug_memory: bool, /// Memory report interval in seconds (default: 1, minimum: 1) diff --git a/src/commands/review.rs b/src/commands/review.rs index 1195f91b5..55ba2b781 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -4,6 +4,7 @@ //! raw reads to facilitate manual review of variant calls. It creates filtered //! BAM files and a detailed TSV report. +use crate::commands::common::parse_bool; use anyhow::{Result, bail}; use clap::Parser; use fgumi_lib::logging::OperationTimer; @@ -91,7 +92,7 @@ pub struct Review { pub sample: Option, /// Ignore N bases in consensus reads - #[arg(short = 'N', long = "ignore-ns", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(short = 'N', long = "ignore-ns", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub ignore_ns: bool, /// Only output detailed information for variants at or below this MAF diff --git a/src/commands/simulate/consensus_reads.rs b/src/commands/simulate/consensus_reads.rs index ac917a054..70ea2dec3 100644 --- a/src/commands/simulate/consensus_reads.rs +++ b/src/commands/simulate/consensus_reads.rs @@ -1,7 +1,7 @@ //! Generate consensus BAM with tags for filter. use crate::commands::command::Command; -use crate::commands::common::CompressionOptions; +use crate::commands::common::{CompressionOptions, parse_bool}; use crate::commands::simulate::common::{StrandBiasArgs, generate_random_sequence}; use anyhow::{Context, Result}; use clap::Parser; @@ -90,7 +90,7 @@ pub struct ConsensusReads { pub error_rate_stddev: f64, /// Generate duplex consensus tags (aD, bD, aM, bM, aE, bE) - #[arg(long = "duplex")] + #[arg(long = "duplex", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub duplex: bool, /// Base quality for consensus reads diff --git a/src/commands/simulate/fastq_reads.rs b/src/commands/simulate/fastq_reads.rs index 8d000f594..3e9007c7d 100644 --- a/src/commands/simulate/fastq_reads.rs +++ b/src/commands/simulate/fastq_reads.rs @@ -1,6 +1,7 @@ //! Generate paired-end FASTQ files with UMI sequences. use crate::commands::command::Command; +use crate::commands::common::parse_bool; use crate::commands::simulate::common::{ FamilySizeArgs, InsertSizeArgs, QualityArgs, SimulationCommon, generate_random_sequence, }; @@ -57,7 +58,7 @@ pub struct FastqReads { pub read_structure_r2: String, /// Generate duplex-style reads (A/B strand pairs) - #[arg(long = "duplex")] + #[arg(long = "duplex", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub duplex: bool, /// Reference FASTA file for sampling template sequences. diff --git a/src/commands/simulate/grouped_reads.rs b/src/commands/simulate/grouped_reads.rs index 9d98b6750..21e034cae 100644 --- a/src/commands/simulate/grouped_reads.rs +++ b/src/commands/simulate/grouped_reads.rs @@ -6,7 +6,7 @@ use super::sort::TemplateCoordKey; use crate::commands::command::Command; -use crate::commands::common::CompressionOptions; +use crate::commands::common::{CompressionOptions, parse_bool}; use crate::commands::simulate::common::{ FamilySizeArgs, InsertSizeArgs, MoleculeInfo, PositionDistArgs, QualityArgs, ReferenceArgs, SimulationCommon, StrandBiasArgs, compute_position, generate_random_sequence, pad_sequence, @@ -57,7 +57,7 @@ pub struct GroupedReads { pub truth_output: PathBuf, /// Generate duplex-style MI tags (e.g., "1/A", "1/B") - #[arg(long = "duplex")] + #[arg(long = "duplex", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub duplex: bool, /// Mapping quality for aligned reads diff --git a/src/commands/sort.rs b/src/commands/sort.rs index fa2f16ec2..5ee6b8408 100644 --- a/src/commands/sort.rs +++ b/src/commands/sort.rs @@ -30,7 +30,7 @@ use log::info; use std::path::PathBuf; use crate::commands::command::Command; -use crate::commands::common::CompressionOptions; +use crate::commands::common::{CompressionOptions, parse_bool}; /// Sort order for BAM files. /// @@ -161,7 +161,7 @@ pub struct Sort { pub input: PathBuf, /// Output BAM file (required unless --verify is used). - #[arg(short = 'o', long = "output", conflicts_with = "verify")] + #[arg(short = 'o', long = "output")] pub output: Option, /// Verify the input file is correctly sorted (no output written). @@ -169,7 +169,7 @@ pub struct Sort { /// Reads records sequentially and checks that each record's sort key /// is >= the previous record's key. Exits 0 if sorted correctly, /// non-zero if any records are out of order. - #[arg(long = "verify", conflicts_with = "output")] + #[arg(long = "verify", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub verify: bool, /// Sort order. @@ -196,7 +196,7 @@ pub struct Sort { /// /// When enabled (default), --max-memory specifies memory per thread. /// Total memory = `max_memory` × threads. Disable for fixed total memory. - #[arg(long = "memory-per-thread", default_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "memory-per-thread", default_value = "true", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub memory_per_thread: bool, /// Temporary directory for intermediate files. @@ -230,7 +230,7 @@ pub struct Sort { /// Only valid for coordinate sort. The index file will be written to /// `.bai`. Uses single-threaded compression for accurate virtual /// position tracking. - #[arg(long = "write-index", default_value = "false", conflicts_with = "verify")] + #[arg(long = "write-index", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub write_index: bool, /// Cell barcode tag for template-coordinate sort. @@ -323,6 +323,13 @@ fn verify_sort_order( impl Command for Sort { fn execute(&self, command_line: &str) -> Result<()> { + if self.verify && self.output.is_some() { + bail!("--verify cannot be used with --output"); + } + if self.verify && self.write_index { + bail!("--write-index cannot be used with --verify"); + } + // Validate inputs validate_file_exists(&self.input, "Input BAM")?; @@ -977,6 +984,25 @@ mod tests { Ok(()) } + #[test] + fn test_verify_conflicts_with_output() { + let sort = Sort { + verify: true, + output: Some(PathBuf::from("out.bam")), + ..make_sort(SortOrderArg::Coordinate, "CB") + }; + let err = sort.execute("test").unwrap_err(); + assert!(err.to_string().contains("--verify cannot be used with --output")); + } + + #[test] + fn test_verify_conflicts_with_write_index() { + let sort = + Sort { verify: true, write_index: true, ..make_sort(SortOrderArg::Coordinate, "CB") }; + let err = sort.execute("test").unwrap_err(); + assert!(err.to_string().contains("--write-index cannot be used with --verify")); + } + #[test] fn test_verify_coordinate_fails_on_unsorted() -> Result<()> { use fgumi_lib::sort::extract_coordinate_key_inline; diff --git a/src/commands/zipper.rs b/src/commands/zipper.rs index 4ad992602..7bb5d8a3f 100644 --- a/src/commands/zipper.rs +++ b/src/commands/zipper.rs @@ -47,7 +47,7 @@ //! - `TagInfo`: Holds sets of tags to remove/reverse/revcomp //! - `merge()`: Core function that transfers metadata between templates use crate::commands::command::Command; -use crate::commands::common::CompressionOptions; +use crate::commands::common::{CompressionOptions, parse_bool}; use anyhow::{Context, Result}; use bstr::ByteSlice; use clap::Parser; @@ -165,14 +165,14 @@ pub struct Zipper { /// Exclude reads from the unmapped BAM that are not present in the aligned BAM. /// Useful when reads were intentionally removed (e.g., by adapter trimming) prior to alignment. - #[arg(long = "exclude-missing-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "exclude-missing-reads", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub exclude_missing_reads: bool, /// Skip adding `pa` (primary alignment) tags to secondary/supplementary reads. /// By default, zipper adds a `pa` tag containing the primary alignment's template /// sort key coordinates, which enables correct template-coordinate sorting and /// deduplication of these reads. Use this flag if you don't need this functionality. - #[arg(long = "skip-pa-tags", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set)] + #[arg(long = "skip-pa-tags", default_value = "false", num_args = 0..=1, default_missing_value = "true", action = clap::ArgAction::Set, value_parser = parse_bool)] pub skip_pa_tags: bool, }