Skip to content

Commit 4b1968a

Browse files
authored
Merge pull request #164 from ArcInstitute/dev-0.5.1
Dev 0.5.1
2 parents feb733f + 87f8bb5 commit 4b1968a

10 files changed

Lines changed: 245 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
bqtools is a Rust CLI for working with BINSEQ files — a binary format family for high-performance DNA sequence processing. It encodes, decodes, greps, concatenates, samples, and pipes BINSEQ files (`.bq`, `.vbq`, `.cbq`). CBQ is the recommended format for most applications.
8+
9+
## Build & Test Commands
10+
11+
```bash
12+
cargo build # Debug build
13+
cargo build --release # Optimized build (uses LTO, slow)
14+
cargo install --path . # Install binary locally
15+
16+
cargo test --verbose # Run all tests
17+
cargo test --verbose -F fuzzy # Run tests including fuzzy feature
18+
cargo test <test_name> # Run a single test by name
19+
20+
cargo fmt --check # Check formatting
21+
cargo clippy --verbose # Lint (pedantic clippy enabled)
22+
```
23+
24+
Logging is controlled via `BQTOOLS_LOG` environment variable (uses `env_logger`).
25+
26+
## Feature Flags
27+
28+
- `htslib` (default): SAM/BAM/CRAM support via rust-htslib
29+
- `gcs` (default): Google Cloud Storage file reading
30+
- `fuzzy` (optional): Fuzzy matching via `sassy` — requires `RUSTFLAGS="-C target-cpu=native"`
31+
32+
Build without defaults: `cargo build --no-default-features -F fuzzy,gcs`
33+
34+
## Architecture
35+
36+
### Module Layout
37+
38+
- **`src/cli/`** — Clap derive-based argument definitions. `cli.rs` has the top-level `Commands` enum. `input.rs` and `output.rs` handle complex input/output argument parsing (file formats, compression, paired-end, spans).
39+
- **`src/commands/`** — Command implementations, each in its own subdirectory. `utils.rs` has shared compression helpers.
40+
- **`src/types.rs`** — Type aliases (`BoxedReader`, `BoxedWriter`).
41+
- **`src/main.rs`** — CLI dispatch and SIGPIPE handling.
42+
43+
### Key Patterns
44+
45+
**Parallel processing**: Commands use the `paraseq` crate's `ParallelProcessor` trait for embarrassingly parallel batch processing. Each command has a `processor.rs` implementing this trait with thread-local buffers and `Arc<Mutex<T>>` for shared global state.
46+
47+
**Grep backends**: The grep command uses a `PatternMatcher` enum dispatching to three backends — `regex`, `aho-corasick` (fixed-string, multi-pattern), and `sassy` (fuzzy, feature-gated). The same pattern applies to `PatternCounter` for the `-P` pattern-count mode.
48+
49+
**Encode modes**: Encoding dispatches across atomic (single/paired files), recursive (directory walk via `walkdir`), manifest (file list), and batch (multi-file thread distribution) modes.
50+
51+
**Writer abstraction**: `SplitWriter` supports interleaved (single file) and split (separate R1/R2) output modes with polymorphic writers (file, stdout, compressed, chunked).
52+
53+
### Core Dependencies
54+
55+
| Crate | Role |
56+
|-------|------|
57+
| `binseq` | BINSEQ format read/write |
58+
| `bitnuc` | 2-bit/4-bit nucleotide encoding |
59+
| `paraseq` | Parallel FASTX/BINSEQ processing |
60+
| `clap` | CLI argument parsing (derive) |
61+
| `anyhow` | Error handling throughout |
62+
63+
### Testing
64+
65+
Integration tests live in `tests/`. `tests/common.rs` provides a builder (`write_fastx()`) for generating random FASTQ/FASTA test data with configurable compression (none, gzip, zstd). Tests use cartesian products over format/compression/mode combinations. Dev dependencies: `bon` (builder macro), `nucgen` (random sequences), `tempfile`, `itertools`.
66+
67+
## Contribution Guide
68+
69+
When making changes, keep the following documentation in sync:
70+
71+
1. **CLAUDE.md** — Update this file when adding new commands, changing architecture, or modifying build/test workflows.
72+
2. **README.md** — Update usage examples and feature descriptions when adding or changing user-facing functionality (new commands, flags, behavior changes).
73+
3. **Clap doc comments** — All CLI arguments, flags, and subcommands use clap derive macros with `/// doc comments` and `#[clap(long_about)]` attributes. When adding or modifying flags, write clear help text directly on the struct fields in `src/cli/`. These doc comments are the `--help` output users see.
74+
4. **New feature flags** — If adding a Cargo feature flag, document it in both `CLAUDE.md` (Feature Flags section) and `README.md` (Feature Flags / Installation section).

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "bqtools"
3-
version = "0.5.0"
3+
version = "0.5.1"
44
edition = "2021"
55
license = "MIT"
66
authors = ["Noam Teyssier <noam.teyssier@arcinstitute.org>"]

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,18 @@ bqtools grep input.bq --xfile patterns.txt
342342
bqtools grep input.bq --file patterns.txt -x
343343
```
344344

345+
You can count the number of matching records with `-C` or get the fraction of matching records with `--frac`:
346+
347+
```bash
348+
# Count the number of matching records
349+
bqtools grep input.bq "ACGTACGT" -C
350+
351+
# Count matching records and show fraction of total
352+
bqtools grep input.bq "ACGTACGT" -F
353+
```
354+
355+
The output of `--frac` is a TSV with three columns: [Count, Total, Fraction]
356+
345357
`bqtools` also introduces a new feature for the counting the occurrences of individual patterns.
346358
This is useful for seeing how many times each pattern occurs across a sequencing dataset without having to iterate over the dataset multiple times using traditional methods.
347359

src/cli/grep.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ pub struct GrepArgs {
5252
#[clap(short = 'C', long, conflicts_with = "pattern_count")]
5353
pub count: bool,
5454

55+
/// Show match count as a fraction of total records
56+
///
57+
/// Implies --count (-C). Displays the number of matches,
58+
/// total records, and the fraction of records matching.
59+
#[clap(short = 'F', long, conflicts_with = "pattern_count")]
60+
pub frac: bool,
61+
5562
/// Only match patterns that are within this range.
5663
///
5764
/// Will not match if the pattern is outside the range or if

src/cli/input.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
use std::path::PathBuf;
1+
use std::{path::PathBuf, str::FromStr};
22

33
use anyhow::{bail, Result};
44
use clap::Parser;
5-
use log::debug;
5+
use log::{debug, error, warn};
66
use paraseq::fastx;
77

88
#[cfg(not(feature = "gcs"))]
@@ -34,7 +34,8 @@ pub struct InputFile {
3434
#[clap(short, long)]
3535
pub batch_size: Option<usize>,
3636

37-
#[clap(short = 'I', long, help = "Interleaved input file format")]
37+
/// Input is paired-interleaved
38+
#[clap(short = 'I', long, conflicts_with = "paired")]
3839
pub interleaved: bool,
3940

4041
/// Apply encoding to all fasta/fastq files in the provided directory input.
@@ -242,6 +243,10 @@ pub struct BatchEncodingOptions {
242243
pub struct InputBinseq {
243244
#[clap(help = "Input binseq file")]
244245
pub input: String,
246+
247+
/// Span of records to process. If not specified, all records will be processed.
248+
#[clap(long)]
249+
pub span: Option<Span>,
245250
}
246251
impl InputBinseq {
247252
pub fn path(&self) -> &str {
@@ -256,3 +261,67 @@ pub struct MultiInputBinseq {
256261
#[clap(num_args = 1..)]
257262
pub input: Vec<String>,
258263
}
264+
265+
#[derive(Debug, Clone, Copy)]
266+
pub struct Span {
267+
start: Option<usize>,
268+
end: Option<usize>,
269+
}
270+
impl Span {
271+
fn validate(&mut self, max_records: usize) -> Result<()> {
272+
if let Some(start) = self.start {
273+
if start > max_records {
274+
error!(
275+
"Provided start ({}) exceeds maximum number of records ({})",
276+
start, max_records
277+
);
278+
bail!("Maximum number of records exceeded")
279+
}
280+
}
281+
if let Some(end) = self.end {
282+
if end > max_records {
283+
warn!(
284+
"Clipping provided endpoint ({}) to maximum number of records ({})",
285+
end, max_records
286+
);
287+
}
288+
self.end = Some(end.min(max_records));
289+
}
290+
Ok(())
291+
}
292+
pub fn get_range(&mut self, max_records: usize) -> Result<std::ops::Range<usize>> {
293+
self.validate(max_records)?;
294+
match (self.start, self.end) {
295+
(Some(start), Some(end)) => Ok(start..end),
296+
(Some(start), None) => Ok(start..max_records),
297+
(None, Some(end)) => Ok(0..end),
298+
(None, None) => Ok(0..max_records),
299+
}
300+
}
301+
}
302+
303+
impl FromStr for Span {
304+
type Err = String;
305+
306+
fn from_str(s: &str) -> Result<Self, Self::Err> {
307+
let (start_str, end_str) = s
308+
.split_once("..")
309+
.ok_or_else(|| format!("expected range like '10..20', got '{s}'"))?;
310+
311+
let parse_bound = |bound_str: &str, name: &str| {
312+
if bound_str.is_empty() {
313+
Ok(None)
314+
} else {
315+
bound_str
316+
.parse()
317+
.map(Some)
318+
.map_err(|_| format!("invalid {name}: '{bound_str}'"))
319+
}
320+
};
321+
322+
let start = parse_bound(start_str, "start")?;
323+
let end = parse_bound(end_str, "end")?;
324+
325+
Ok(Self { start, end })
326+
}
327+
}

src/commands/decode/mod.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,16 @@ pub fn run(args: &DecodeCommand) -> Result<()> {
5959
None
6060
};
6161
let proc = Decoder::new(writer, format, mate);
62-
reader.process_parallel(proc.clone(), args.output.threads())?;
62+
if let Some(mut span) = args.input.span {
63+
let num_records = reader.num_records()?;
64+
reader.process_parallel_range(
65+
proc.clone(),
66+
args.output.threads(),
67+
span.get_range(num_records)?,
68+
)?
69+
} else {
70+
reader.process_parallel(proc.clone(), args.output.threads())?;
71+
}
6372
let num_records = proc.num_records();
6473
info!("Processed {num_records} records...");
6574
Ok(())

src/commands/grep/filter/processor.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ pub struct FilterProcessor<Pm: PatternMatch> {
2525
/// Only count the number of matches
2626
count: bool,
2727

28+
/// Show count as fraction of total records
29+
frac: bool,
30+
2831
/// Match within range
2932
range: Option<SimpleRange>,
3033

3134
/// Local count
3235
local_count: usize,
3336

37+
/// Local total records processed
38+
local_total: usize,
39+
3440
/// Local primary/extended sequence match indices
3541
smatches: MatchRanges,
3642
xmatches: MatchRanges,
@@ -54,6 +60,7 @@ pub struct FilterProcessor<Pm: PatternMatch> {
5460
/// Global values
5561
global_writer: Arc<Mutex<SplitWriter>>,
5662
global_count: Arc<Mutex<usize>>,
63+
global_total: Arc<Mutex<usize>>,
5764
}
5865
impl<Pm: PatternMatch> FilterProcessor<Pm> {
5966
#[allow(clippy::fn_params_excessive_bools)]
@@ -63,6 +70,7 @@ impl<Pm: PatternMatch> FilterProcessor<Pm> {
6370
and_logic: bool,
6471
invert: bool,
6572
count: bool,
73+
frac: bool,
6674
range: Option<SimpleRange>,
6775
writer: SplitWriter,
6876
format: FileFormat,
@@ -82,14 +90,17 @@ impl<Pm: PatternMatch> FilterProcessor<Pm> {
8290
and_logic,
8391
invert,
8492
count,
93+
frac,
8594
range,
8695
format,
8796
mate,
8897
color,
8998
is_split: writer.is_split(),
9099
global_writer: Arc::new(Mutex::new(writer)),
91100
local_count: 0,
101+
local_total: 0,
92102
global_count: Arc::new(Mutex::new(0)),
103+
global_total: Arc::new(Mutex::new(0)),
93104
}
94105
}
95106
pub fn clear_matches(&mut self) {
@@ -132,13 +143,26 @@ impl<Pm: PatternMatch> FilterProcessor<Pm> {
132143
}
133144
}
134145
pub fn pprint_counts(&self) {
135-
println!("{}", self.global_count.lock());
146+
let count = *self.global_count.lock();
147+
if self.frac {
148+
let total = *self.global_total.lock();
149+
let frac = if total > 0 {
150+
count as f64 / total as f64
151+
} else {
152+
0.0
153+
};
154+
println!("count\ttotal\tfrac");
155+
println!("{count}\t{total}\t{frac:.4}");
156+
} else {
157+
println!("{count}");
158+
}
136159
}
137160
}
138161

139162
impl<Pm: PatternMatch> ParallelProcessor for FilterProcessor<Pm> {
140163
fn process_record<B: BinseqRecord>(&mut self, record: B) -> binseq::Result<()> {
141164
self.clear_matches();
165+
self.local_total += 1;
142166

143167
let sbuf = record.sseq();
144168
let xbuf = record.xseq();
@@ -232,6 +256,10 @@ impl<Pm: PatternMatch> ParallelProcessor for FilterProcessor<Pm> {
232256
*self.global_count.lock() += self.local_count;
233257
self.local_count = 0;
234258

259+
// Increment the global total and reset local
260+
*self.global_total.lock() += self.local_total;
261+
self.local_total = 0;
262+
235263
Ok(())
236264
}
237265
}

src/commands/grep/mod.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,16 @@ fn build_counter(args: &GrepCommand) -> Result<PatternCounter> {
6161
fn run_pattern_count(args: &GrepCommand, reader: BinseqReader) -> Result<()> {
6262
let counter = build_counter(args)?;
6363
let proc = PatternCountProcessor::new(counter, args.grep.range);
64-
reader.process_parallel(proc.clone(), args.output.threads())?;
64+
if let Some(mut span) = args.input.span {
65+
let num_records = reader.num_records()?;
66+
reader.process_parallel_range(
67+
proc.clone(),
68+
args.output.threads(),
69+
span.get_range(num_records)?,
70+
)?
71+
} else {
72+
reader.process_parallel(proc.clone(), args.output.threads())?;
73+
}
6574
proc.pprint_pattern_counts()?;
6675
Ok(())
6776
}
@@ -110,20 +119,32 @@ fn run_grep(
110119
format: FileFormat,
111120
mate: Option<Mate>,
112121
) -> Result<()> {
122+
let count = args.grep.count || args.grep.frac;
113123
let matcher = build_matcher(args)?;
114124
let proc = FilterProcessor::new(
115125
matcher,
116126
args.grep.and_logic(),
117127
args.grep.invert,
118-
args.grep.count,
128+
count,
129+
args.grep.frac,
119130
args.grep.range,
120131
writer,
121132
format,
122133
mate,
123134
args.should_color(),
124135
);
125-
reader.process_parallel(proc.clone(), args.output.threads())?;
126-
if args.grep.count {
136+
137+
if let Some(mut span) = args.input.span {
138+
let num_records = reader.num_records()?;
139+
reader.process_parallel_range(
140+
proc.clone(),
141+
args.output.threads(),
142+
span.get_range(num_records)?,
143+
)?;
144+
} else {
145+
reader.process_parallel(proc.clone(), args.output.threads())?;
146+
}
147+
if count {
127148
proc.pprint_counts();
128149
}
129150

src/commands/pipe/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use std::thread;
66

77
use anyhow::Result;
88
use binseq::BinseqReader;
9-
use log::info;
9+
use log::{info, warn};
1010

1111
use crate::cli::{FileFormat, PipeCommand};
1212
use processor::PipeProcessor;
@@ -23,6 +23,10 @@ pub enum RecordPair {
2323
}
2424

2525
pub fn run(args: &PipeCommand) -> Result<()> {
26+
if args.input.span.is_some() {
27+
warn!("Span is ignored when using pipe subcommand");
28+
}
29+
2630
let format = args.format()?;
2731
let reader = BinseqReader::new(args.input.path())?;
2832
let num_records = reader.num_records()?;

0 commit comments

Comments
 (0)