diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e33782d7..2fe8dbbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,7 @@ jobs: - name: Install Rust MSRV toolchain uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 with: - toolchain: "1.87" + toolchain: "1.91" - name: Install Linux build deps run: | diff --git a/AGENTS.md b/AGENTS.md index 108414ac..214194f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ cargo test --test integration_test test_intercept_slope_match ``` src/ main.rs — Entry point, dispatches subcommands - cli.rs — CLI argument parsing (clap derive, subcommand structure) + cli.rs — CLI argument parsing (usage-rs derive, subcommand structure) config.rs — YAML configuration loading (serde), nested tool configs io.rs — Shared I/O utilities (gzip-transparent file reading) gtf.rs — GTF annotation file parser (with configurable attribute extraction) @@ -243,7 +243,7 @@ To prepare a release: | Crate | Purpose | | ---------------------- | ------------------------------------- | -| `clap` v4 | CLI argument parsing (derive) | +| `usage-rs` 6.1.1 | CLI argument parsing (derive; experimental) | | `rust-htslib` | BAM file I/O (statically linked) | | `plotters` | Chart generation (PNG + SVG) | | `serde` | YAML config deserialization | diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b67cce4..dd3b761c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # RustQC Changelog +## Unreleased + +### Other changes + +- Replace clap with [usage-rs](https://usage.jdx.dev/rust/) 6.1.1 for CLI parsing. Flags, shorts, env vars, and defaults are unchanged; help text is rendered by usage rather than clap's template. MSRV is 1.91 (usage-rs's requirement). + ## [Version 0.2.1](https://github.com/seqeralabs/RustQC/releases/tag/v0.2.1) - 2026-04-09 ### Bug fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 181de963..50f3c3d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,27 @@ To run all hooks manually: prek run --all-files ``` +## Completions, manpages, and CLI docs + +The CLI is declared with [usage-rs](https://usage.jdx.dev/rust/) (pinned to +6.1.1). The binary prints its portable spec, and [usage-cli](https://usage.jdx.dev/) +at the same version turns that spec into completions, manpages, and markdown: + +```bash +# Dump the spec (also available in-process as `Cli::to_kdl()`) +cargo run --quiet -- __usage_spec__ > rustqc.usage.kdl + +# Generate artifacts with usage-cli 6.1.1 +usage g markdown -f rustqc.usage.kdl --out-dir docs +usage g manpage -f rustqc.usage.kdl > rustqc.1 +usage g completion bash rustqc --file rustqc.usage.kdl +``` + +Install `usage-cli` from the same release as the `usage-rs` crate so the KDL +round-trips. Completions are also served at runtime: with the `completions` +feature, `rustqc` answers hidden `__complete_word__` requests from generated +shell scripts (`Cli::completion_script`). + ## Project structure RustQC is a **binary crate** with a nested module structure. All top-level modules are declared in `main.rs` (no `lib.rs`). @@ -80,7 +101,7 @@ RustQC is a **binary crate** with a nested module structure. All top-level modul ``` src/ main.rs Entry point, dispatches subcommands - cli.rs CLI argument parsing (clap derive) + cli.rs CLI argument parsing (usage-rs derive) config.rs YAML configuration loading (serde) io.rs Shared I/O utilities (gzip-transparent file reading) gtf.rs GTF annotation file parser diff --git a/Cargo.lock b/Cargo.lock index da20215e..6556d40c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,7 +105,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.117", ] [[package]] @@ -223,46 +223,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "cmake" version = "0.1.58" @@ -385,9 +345,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -427,7 +387,7 @@ checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -438,7 +398,7 @@ checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -470,7 +430,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -615,7 +575,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -963,7 +923,7 @@ checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1303,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1481,7 +1441,6 @@ version = "0.2.1" dependencies = [ "anyhow", "cc", - "clap", "coitrees", "console", "crc32fast", @@ -1502,6 +1461,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "usage-rs", ] [[package]] @@ -1564,7 +1524,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1617,12 +1577,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "strum_macros" version = "0.26.4" @@ -1633,7 +1587,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.117", ] [[package]] @@ -1647,6 +1601,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1655,7 +1620,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1684,7 +1649,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1695,7 +1660,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1750,6 +1715,43 @@ dependencies = [ "serde", ] +[[package]] +name = "usage-argv" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a650954c59792f8836d1ff9c55e811d6a8a3cd6a3094ed518149f6603e0a6a" + +[[package]] +name = "usage-derive" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a9c96fae9f55e0ed7cfcbe7feca43196ab49a3058430d12c937472585056c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "usage-rs" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fed5079f9b4481952bd0dde26bd7912863d7e5c72d1d50565cbd56c37e216d49" +dependencies = [ + "usage-argv", + "usage-derive", + "usage-test", +] + +[[package]] +name = "usage-test" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06be18e6cab835b5fa16a6ac98ed7f9104c15f8d42020097f1c195d9848d243d" +dependencies = [ + "usage-argv", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1834,7 +1836,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1959,7 +1961,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1970,7 +1972,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2118,7 +2120,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -2134,7 +2136,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2212,7 +2214,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2233,7 +2235,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2253,7 +2255,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2287,7 +2289,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ebf02f0e..46846b71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "rustqc" version = "0.2.1" edition = "2021" -rust-version = "1.87" +rust-version = "1.91" authors = ["Phil Ewels ", "Jon Manning "] description = "Fast RNA-seq QC in a single pass: dupRadar, featureCounts, 8 RSeQC tools, preseq, samtools stats, and Qualimap — reimplemented in Rust" license = "GPL-3.0-or-later" @@ -22,8 +22,8 @@ name = "rustqc" path = "src/main.rs" [dependencies] -# CLI argument parsing -clap = { version = "4", features = ["derive", "env"] } +# CLI argument parsing (usage-rs is experimental; pin a concrete version) +usage = { package = "usage-rs", version = "6.1.1", features = ["completions"] } # BAM file reading rust-htslib = { version = "1", features = ["static"] } diff --git a/docs/src/content/docs/about/credits.mdx b/docs/src/content/docs/about/credits.mdx index 171086ee..3d6fed60 100644 --- a/docs/src/content/docs/about/credits.mdx +++ b/docs/src/content/docs/about/credits.mdx @@ -106,7 +106,7 @@ RustQC is built with the following open-source Rust libraries: |---------|---------| | [rust-htslib](https://github.com/rust-bio/rust-htslib) | SAM/BAM/CRAM file I/O via htslib bindings | | [plotters](https://github.com/plotters-rs/plotters) | Plot generation (PNG and SVG) | -| [clap](https://github.com/clap-rs/clap) | Command-line argument parsing | +| [usage-rs](https://github.com/jdx/usage) | Command-line argument parsing | | [anyhow](https://github.com/dtolnay/anyhow) | Error handling | | [rayon](https://github.com/rayon-rs/rayon) | Data parallelism | | [coitrees](https://github.com/dcjones/coitrees) | Cache-oblivious interval trees | diff --git a/docs/src/content/docs/usage/library.mdx b/docs/src/content/docs/usage/library.mdx index 22215be7..001b92bb 100644 --- a/docs/src/content/docs/usage/library.mdx +++ b/docs/src/content/docs/usage/library.mdx @@ -76,7 +76,7 @@ for line in reader.lines() { # Ok::<(), anyhow::Error>(()) ``` -Use the `Strandedness` enum (it derives `serde::Deserialize` and clap's +Use the `Strandedness` enum (it derives `serde::Deserialize` and usage's `ValueEnum`, so it integrates with both YAML configs and CLI parsers): ```rust diff --git a/src/cli.rs b/src/cli.rs index 6e6459e8..4220de04 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,21 +10,32 @@ //! //! A GTF gene annotation file is required for all analyses. -use clap::{CommandFactory, Parser, Subcommand}; - use rustqc::Strandedness; +use usage::{Args, Cli, Subcommands}; /// Fast quality control tools for sequencing data, written in Rust. -#[derive(Parser, Debug)] -#[command(name = "rustqc", version, about, long_about = None)] +#[derive(Cli, Debug)] +#[usage( + bin = "rustqc", + version, + long_version = rustqc_long_version(), + long_version_spec = "0.2.1", + author = "Phil Ewels , Jon Manning ", + license = "GPL-3.0-or-later", + repository = "https://github.com/seqeralabs/RustQC", + unknown_flags = "error", + args_override_self = false, + completion, + term_width = 120 +)] pub struct Cli { /// The analysis subcommand to run. - #[command(subcommand)] + #[usage(subcommand)] pub command: Commands, } /// Available analysis subcommands. -#[derive(Subcommand, Debug)] +#[derive(Subcommands, Debug)] pub enum Commands { /// RNA-Seq QC — single-pass analysis of BAM/SAM/CRAM files. /// @@ -35,25 +46,17 @@ pub enum Commands { } /// Arguments for the `rna` subcommand. -#[derive(Parser, Debug)] -#[command( - next_line_help = false, - term_width = 120, - help_template = "\ -{about-with-newline} -{usage-heading} {usage} - -{all-args}" -)] +#[derive(Args, Debug)] +#[usage(unknown_flags = "error", args_override_self = false, term_width = 120)] pub struct RnaArgs { // ── Input / Output ────────────────────────────────────────────────── /// Duplicate-marked alignment file(s) - #[arg(value_name = "INPUT", num_args = 1.., required = true, help_heading = "Input / Output")] + #[usage(arg, required, value_name = "INPUT", help_heading = "Input / Output")] pub input: Vec, /// GTF gene annotation (plain or .gz) - #[arg( - short, + #[usage( + short = 'g', long, value_name = "GTF", env = "RUSTQC_GTF", @@ -62,8 +65,8 @@ pub struct RnaArgs { pub gtf: String, /// Reference FASTA (required for CRAM) - #[arg( - short, + #[usage( + short = 'r', long, value_name = "FASTA", env = "RUSTQC_REFERENCE", @@ -72,18 +75,18 @@ pub struct RnaArgs { pub reference: Option, /// Output directory [default: .] - #[arg( - short, + #[usage( + short = 'o', long, - default_value = ".", - hide_default_value = true, + default = ".", + hide_default_value, env = "RUSTQC_OUTDIR", help_heading = "Input / Output" )] pub outdir: String, /// Override sample name for output filenames (default: derived from BAM filename) - #[arg( + #[usage( long, value_name = "NAME", env = "RUSTQC_SAMPLE_NAME", @@ -92,26 +95,34 @@ pub struct RnaArgs { pub sample_name: Option, /// Write outputs to a flat directory (no subdirs) - #[arg( - long, - default_value_t = false, - env = "RUSTQC_FLAT_OUTPUT", - help_heading = "Input / Output" - )] + #[usage(long, env = "RUSTQC_FLAT_OUTPUT", help_heading = "Input / Output")] pub flat_output: bool, /// YAML configuration file (see also: RUSTQC_CONFIG env var) - #[arg(short, long, value_name = "CONFIG", help_heading = "Input / Output")] + #[usage( + short = 'c', + long, + value_name = "CONFIG", + help_heading = "Input / Output" + )] pub config: Option, /// JSON summary path (use "-" for stdout) - #[arg(short = 'j', long = "json-summary", value_name = "PATH", num_args = 0..=1, default_missing_value = "", env = "RUSTQC_JSON_SUMMARY", help_heading = "Input / Output")] + #[usage( + short = 'j', + long = "json-summary", + value_name = "PATH", + default_missing = "", + allow_hyphen_values, + env = "RUSTQC_JSON_SUMMARY", + help_heading = "Input / Output" + )] pub json_summary: Option, // ── Library ───────────────────────────────────────────────────────── /// Strandedness: unstranded, forward, reverse - #[arg( - short, + #[usage( + short = 's', long, value_enum, env = "RUSTQC_STRANDED", @@ -120,34 +131,34 @@ pub struct RnaArgs { pub stranded: Option, /// Paired-end reads - #[arg(short, long, env = "RUSTQC_PAIRED", help_heading = "Library")] + #[usage(short = 'p', long, env = "RUSTQC_PAIRED", help_heading = "Library")] pub paired: bool, // ── General ───────────────────────────────────────────────────────── /// Number of threads [default: 1] - #[arg( - short, + #[usage( + short = 't', long, - default_value_t = 1, - hide_default_value = true, + default = "1", + hide_default_value, env = "RUSTQC_THREADS", help_heading = "General" )] pub threads: usize, /// MAPQ cutoff for quality filtering [default: 30] - #[arg( + #[usage( short = 'Q', long = "mapq", - default_value_t = 30, - hide_default_value = true, + default = "30", + hide_default_value, env = "RUSTQC_MAPQ", help_heading = "General" )] pub mapq_cut: u8, /// GTF attribute for biotype grouping - #[arg( + #[usage( long, value_name = "ATTR", env = "RUSTQC_BIOTYPE_ATTRIBUTE", @@ -156,29 +167,24 @@ pub struct RnaArgs { pub biotype_attribute: Option, /// Skip duplicate-marking check - #[arg( - long, - default_value_t = false, - env = "RUSTQC_SKIP_DUP_CHECK", - help_heading = "General" - )] + #[usage(long, env = "RUSTQC_SKIP_DUP_CHECK", help_heading = "General")] pub skip_dup_check: bool, /// Suppress output except warnings/errors - #[arg( + #[usage( short = 'q', long, - conflicts_with = "verbose", + conflicts("--verbose"), env = "RUSTQC_QUIET", help_heading = "General" )] pub quiet: bool, /// Show additional detail - #[arg( + #[usage( short = 'v', long, - conflicts_with = "quiet", + conflicts("--quiet"), env = "RUSTQC_VERBOSE", help_heading = "General" )] @@ -186,7 +192,7 @@ pub struct RnaArgs { // ── Tool parameters ───────────────────────────────────────────────── /// infer_experiment: sample size [default: 200000] - #[arg( + #[usage( long = "infer-experiment-sample-size", value_name = "N", env = "RUSTQC_INFER_EXPERIMENT_SAMPLE_SIZE", @@ -195,7 +201,7 @@ pub struct RnaArgs { pub infer_experiment_sample_size: Option, /// junction_annotation: min intron size [default: 50] - #[arg( + #[usage( long = "min-intron", value_name = "N", env = "RUSTQC_MIN_INTRON", @@ -204,7 +210,7 @@ pub struct RnaArgs { pub min_intron: Option, /// junction_saturation: random seed for reproducible results - #[arg( + #[usage( long = "junction-saturation-seed", value_name = "N", env = "RUSTQC_JUNCTION_SATURATION_SEED", @@ -213,7 +219,7 @@ pub struct RnaArgs { pub junction_saturation_seed: Option, /// junction_saturation: min coverage [default: 1] - #[arg( + #[usage( long = "junction-saturation-min-coverage", value_name = "N", env = "RUSTQC_JUNCTION_SATURATION_MIN_COVERAGE", @@ -222,7 +228,7 @@ pub struct RnaArgs { pub junction_saturation_min_coverage: Option, /// junction_saturation: start % [default: 5] - #[arg( + #[usage( long = "junction-saturation-percentile-floor", value_name = "N", env = "RUSTQC_JUNCTION_SATURATION_PERCENTILE_FLOOR", @@ -231,7 +237,7 @@ pub struct RnaArgs { pub junction_saturation_percentile_floor: Option, /// junction_saturation: end % [default: 100] - #[arg( + #[usage( long = "junction-saturation-percentile-ceiling", value_name = "N", env = "RUSTQC_JUNCTION_SATURATION_PERCENTILE_CEILING", @@ -240,7 +246,7 @@ pub struct RnaArgs { pub junction_saturation_percentile_ceiling: Option, /// junction_saturation: step % [default: 5] - #[arg( + #[usage( long = "junction-saturation-percentile-step", value_name = "N", env = "RUSTQC_JUNCTION_SATURATION_PERCENTILE_STEP", @@ -249,7 +255,7 @@ pub struct RnaArgs { pub junction_saturation_percentile_step: Option, /// inner_distance: sample size [default: 1000000] - #[arg( + #[usage( long = "inner-distance-sample-size", value_name = "N", env = "RUSTQC_INNER_DISTANCE_SAMPLE_SIZE", @@ -258,37 +264,37 @@ pub struct RnaArgs { pub inner_distance_sample_size: Option, /// inner_distance: lower bound [default: -250] - #[arg( + #[usage( long = "inner-distance-lower-bound", value_name = "N", - allow_hyphen_values = true, + allow_negative_numbers, env = "RUSTQC_INNER_DISTANCE_LOWER_BOUND", help_heading = "Tool parameters" )] pub inner_distance_lower_bound: Option, /// inner_distance: upper bound [default: 250] - #[arg( + #[usage( long = "inner-distance-upper-bound", value_name = "N", - allow_hyphen_values = true, + allow_negative_numbers, env = "RUSTQC_INNER_DISTANCE_UPPER_BOUND", help_heading = "Tool parameters" )] pub inner_distance_upper_bound: Option, /// inner_distance: bin width [default: 5] - #[arg( + #[usage( long = "inner-distance-step", value_name = "N", - allow_hyphen_values = true, + allow_negative_numbers, env = "RUSTQC_INNER_DISTANCE_STEP", help_heading = "Tool parameters" )] pub inner_distance_step: Option, /// TIN: random seed for reproducible results - #[arg( + #[usage( long = "tin-seed", value_name = "N", env = "RUSTQC_TIN_SEED", @@ -297,34 +303,23 @@ pub struct RnaArgs { pub tin_seed: Option, /// Skip TIN analysis - #[arg( - long, - default_value_t = false, - env = "RUSTQC_SKIP_TIN", - help_heading = "Tool parameters" - )] + #[usage(long, env = "RUSTQC_SKIP_TIN", help_heading = "Tool parameters")] pub skip_tin: bool, /// Skip read duplication analysis - #[arg( + #[usage( long, - default_value_t = false, env = "RUSTQC_SKIP_READ_DUPLICATION", help_heading = "Tool parameters" )] pub skip_read_duplication: bool, /// Skip preseq library complexity analysis - #[arg( - long, - default_value_t = false, - env = "RUSTQC_SKIP_PRESEQ", - help_heading = "Tool parameters" - )] + #[usage(long, env = "RUSTQC_SKIP_PRESEQ", help_heading = "Tool parameters")] pub skip_preseq: bool, /// preseq: random seed for bootstrap CIs - #[arg( + #[usage( long = "preseq-seed", value_name = "N", env = "RUSTQC_PRESEQ_SEED", @@ -333,7 +328,7 @@ pub struct RnaArgs { pub preseq_seed: Option, /// preseq: max extrapolation depth - #[arg( + #[usage( long = "preseq-max-extrap", value_name = "N", env = "RUSTQC_PRESEQ_MAX_EXTRAP", @@ -342,7 +337,7 @@ pub struct RnaArgs { pub preseq_max_extrap: Option, /// preseq: step size between points - #[arg( + #[usage( long = "preseq-step-size", value_name = "N", env = "RUSTQC_PRESEQ_STEP_SIZE", @@ -351,7 +346,7 @@ pub struct RnaArgs { pub preseq_step_size: Option, /// preseq: bootstrap replicates for CIs - #[arg( + #[usage( long = "preseq-n-bootstraps", value_name = "N", env = "RUSTQC_PRESEQ_N_BOOTSTRAPS", @@ -360,7 +355,7 @@ pub struct RnaArgs { pub preseq_n_bootstraps: Option, /// preseq: max segment length for PE merging - #[arg( + #[usage( long = "preseq-seg-len", value_name = "N", env = "RUSTQC_PRESEQ_SEG_LEN", @@ -369,14 +364,12 @@ pub struct RnaArgs { pub preseq_seg_len: Option, } -/// Parse command-line arguments and return the Cli struct. +/// Extended `--version` text: crate version, git commit, build time, and CPU info. /// -/// Sets a `long_version` that includes the git commit, build timestamp, -/// and CPU info line, shown when the user runs `rustqc --version`. -pub fn parse_args() -> Cli { - use clap::FromArgMatches; - - let long_version: &'static str = Box::leak( +/// Leaked so the usage derive can treat it as `&'static str` next to +/// `env!("CARGO_PKG_VERSION")` in the generated `-V` / `--version` arms. +fn rustqc_long_version() -> &'static str { + Box::leak( format!( "{} ({}, built {})\n{}", env!("CARGO_PKG_VERSION"), @@ -385,20 +378,39 @@ pub fn parse_args() -> Cli { rustqc::cpu::cpu_info_line(), ) .into_boxed_str(), - ); - let cmd = Cli::command().long_version(long_version); - let matches = cmd.get_matches(); - Cli::from_arg_matches(&matches).expect("clap arg matching failed") + ) +} + +/// Parse process argv, printing help/version and exiting the way `usage` does. +/// +/// `--version` includes the git commit, build timestamp, and CPU info line. +pub fn parse_args() -> Cli { + Cli::parse() } #[cfg(test)] mod tests { use super::*; + use std::ffi::OsStr; + + /// Convert clap-shaped string argv (including argv0) into the `&OsStr` slice usage expects. + fn os_args<'a>(args: &'a [&'a str]) -> Vec<&'a OsStr> { + args.iter().copied().map(OsStr::new).collect() + } + + fn parse_cli(args: &[&str]) -> Cli { + Cli::try_parse_from(&os_args(args)) + .unwrap_or_else(|err| panic!("expected a successful parse for {args:?}, got {err:?}")) + } + + fn parse_cli_err(args: &[&str]) -> bool { + Cli::try_parse_from(&os_args(args)).is_err() + } #[test] fn test_rna_default_args_gtf() { // Test that defaults are sensible with a GTF annotation - let cli = Cli::parse_from(["rustqc", "rna", "test.bam", "--gtf", "genes.gtf"]); + let cli = parse_cli(&["rustqc", "rna", "test.bam", "--gtf", "genes.gtf"]); match cli.command { Commands::Rna(args) => { assert_eq!(args.input, vec!["test.bam"]); @@ -421,7 +433,7 @@ mod tests { #[test] fn test_rna_multiple_bams() { // Test that multiple BAM files are accepted - let cli = Cli::parse_from([ + let cli = parse_cli(&[ "rustqc", "rna", "a.bam", @@ -442,7 +454,7 @@ mod tests { #[test] fn test_rna_gtf_all_args() { - let cli = Cli::parse_from([ + let cli = parse_cli(&[ "rustqc", "rna", "test.bam", @@ -478,13 +490,15 @@ mod tests { #[test] fn test_rna_missing_gtf() { // --gtf is required, so omitting it should fail - let result = Cli::try_parse_from(["rustqc", "rna", "test.bam"]); - assert!(result.is_err(), "Expected error when --gtf is not provided"); + assert!( + parse_cli_err(&["rustqc", "rna", "test.bam"]), + "Expected error when --gtf is not provided" + ); } #[test] fn test_rna_rseqc_params() { - let cli = Cli::parse_from([ + let cli = parse_cli(&[ "rustqc", "rna", "test.bam", @@ -519,7 +533,7 @@ mod tests { #[test] fn test_rna_preseq_params() { - let cli = Cli::parse_from([ + let cli = parse_cli(&[ "rustqc", "rna", "test.bam", @@ -549,7 +563,7 @@ mod tests { #[test] fn test_rna_tool_seeds() { - let cli = Cli::parse_from([ + let cli = parse_cli(&[ "rustqc", "rna", "test.bam", @@ -575,7 +589,7 @@ mod tests { #[test] fn test_rna_skip_preseq() { - let cli = Cli::parse_from([ + let cli = parse_cli(&[ "rustqc", "rna", "test.bam", @@ -591,4 +605,51 @@ mod tests { _ => panic!("Expected Rna subcommand"), } } + + #[test] + fn test_rna_unknown_flag_is_rejected() { + assert!( + parse_cli_err(&["rustqc", "rna", "test.bam", "--gtf", "genes.gtf", "--wat"]), + "Expected error for an unknown flag" + ); + } + + #[test] + fn test_rna_json_summary_bare_flag() { + let cli = parse_cli(&[ + "rustqc", + "rna", + "test.bam", + "--gtf", + "genes.gtf", + "--json-summary", + ]); + match cli.command { + Commands::Rna(args) => { + assert_eq!(args.json_summary.as_deref(), Some("")); + } + #[allow(unreachable_patterns)] + _ => panic!("Expected Rna subcommand"), + } + } + + #[test] + fn test_rna_json_summary_stdout() { + let cli = parse_cli(&[ + "rustqc", + "rna", + "test.bam", + "--gtf", + "genes.gtf", + "--json-summary", + "-", + ]); + match cli.command { + Commands::Rna(args) => { + assert_eq!(args.json_summary.as_deref(), Some("-")); + } + #[allow(unreachable_patterns)] + _ => panic!("Expected Rna subcommand"), + } + } } diff --git a/src/lib.rs b/src/lib.rs index 9a228cae..08bd6e65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,7 +62,6 @@ //! assert_eq!(s.to_string(), "reverse"); //! ``` -use clap::ValueEnum; use serde::Deserialize; pub mod config; @@ -76,7 +75,7 @@ pub mod summary; /// /// Determines how read strand is interpreted relative to the gene annotation /// strand during counting. Accepted CLI values: `unstranded`, `forward`, `reverse`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, usage::ValueEnum, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Strandedness { /// Count reads on either strand (library is not strand-specific).