use std::path::PathBuf;
use clap::Parser;
use rsomics_common::{CommonFlags, Result, RsomicsError, Tool, ToolMeta};
use rsomics_help::{Example, FlagSpec, HelpSpec, Origin, Section};
use serde::Serialize;
use rsomics_seq_grep::{GrepOptions, Region, load_pattern_file};
pub const META: ToolMeta = ToolMeta {
name: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION"),
};
const TAGLINE: &str = "Filter FASTA/FASTQ records by ID/name/sequence — seqkit grep port.";
#[derive(Parser, Debug)]
#[command(
name = "rsomics-seq-grep",
version,
about,
long_about = None,
disable_help_flag = true
)]
pub struct Cli {
input: String,
#[arg(short = 'o', long, default_value = "-")]
output: String,
#[arg(short = 'p', long = "pattern", value_delimiter = ',')]
patterns: Vec<String>,
#[arg(short = 'f', long = "pattern-file")]
pattern_file: Option<PathBuf>,
#[arg(short = 'n', long = "by-name")]
by_name: bool,
#[arg(short = 'r', long = "use-regexp")]
use_regexp: bool,
#[arg(short = 'i', long = "ignore-case")]
ignore_case: bool,
#[arg(short = 's', long = "by-seq")]
by_seq: bool,
#[arg(short = 'd', long = "degenerate")]
degenerate: bool,
#[arg(long = "invert-match")]
invert_match: bool,
#[arg(short = 'R', long = "region", allow_hyphen_values = true)]
region: Option<String>,
#[arg(short = 'P', long = "only-positive-strand")]
only_positive_strand: bool,
#[arg(short = 'w', long = "line-width", default_value_t = 60)]
line_width: usize,
#[arg(short = 'C', long = "count")]
count: bool,
#[command(flatten)]
pub common: CommonFlags,
}
#[derive(Serialize)]
pub struct GrepReport {
pub input: String,
pub output: String,
pub pattern_count: usize,
pub total_records: u64,
pub matched: u64,
}
impl Cli {
pub fn execute(&self) -> Result<GrepReport> {
if self.use_regexp && self.degenerate {
return Err(RsomicsError::InvalidInput(
"cannot give both -d/--degenerate and -r/--use-regexp".into(),
));
}
let has_patterns = !self.patterns.is_empty() || self.pattern_file.is_some();
if !has_patterns {
if self.region.is_some() {
return Err(RsomicsError::InvalidInput(
"you shouldn't search an empty pattern in a region".into(),
));
}
return Err(RsomicsError::InvalidInput(
"one of -p/--pattern or -f/--pattern-file is required".into(),
));
}
let patterns = match &self.pattern_file {
Some(pf) => load_pattern_file(pf)?,
None => self.patterns.clone(),
};
let pattern_count = patterns.len();
let region = self.region.as_deref().map(Region::parse).transpose()?;
let opts = GrepOptions {
patterns,
by_name: self.by_name,
use_regexp: self.use_regexp,
ignore_case: self.ignore_case,
by_seq: self.by_seq,
degenerate: self.degenerate,
invert_match: self.invert_match,
region,
only_positive: self.only_positive_strand,
line_width: self.line_width,
count_only: self.count,
};
let mut out: Box<dyn std::io::Write> = if self.output == "-" && self.common.json {
Box::new(std::io::sink())
} else if self.output == "-" {
Box::new(std::io::stdout().lock())
} else {
Box::new(std::fs::File::create(&self.output).map_err(RsomicsError::Io)?)
};
let stats = rsomics_seq_grep::grep(&self.input, &opts, &mut out)?;
Ok(GrepReport {
input: self.input.clone(),
output: self.output.clone(),
pattern_count,
total_records: stats.total_records,
matched: stats.matched,
})
}
}
impl Tool for Cli {
fn meta() -> ToolMeta {
META
}
fn common(&self) -> &CommonFlags {
&self.common
}
fn execute(self) -> Result<()> {
Cli::execute(&self)?;
Ok(())
}
fn run(self) -> std::process::ExitCode {
let common = self.common().clone();
rsomics_common::run(&common, Self::meta(), move || Cli::execute(&self))
}
}
pub static HELP: HelpSpec = HelpSpec {
name: META.name,
version: META.version,
tagline: TAGLINE,
origin: Some(Origin {
upstream: "seqkit grep",
upstream_license: "MIT",
our_license: "MIT OR Apache-2.0",
paper_doi: Some("10.1002/imt2.191"),
}),
usage_lines: &[
"[OPTIONS] -p <pattern> <input.fa|input.fq|->",
"[OPTIONS] -f <patterns.txt> <input>",
],
sections: &[Section {
title: "OPTIONS",
flags: &[
FlagSpec {
short: Some('p'),
long: "pattern",
aliases: &[],
value: Some("<pattern>"),
type_hint: Some("String"),
required: false,
default: None,
description: "Search pattern (comma-separated or repeated).",
why_default: None,
},
FlagSpec {
short: Some('f'),
long: "pattern-file",
aliases: &[],
value: Some("<file>"),
type_hint: Some("Path"),
required: false,
default: None,
description: "One pattern per line. Overrides -p entirely when given.",
why_default: None,
},
FlagSpec {
short: Some('n'),
long: "by-name",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Match full name (header) instead of just the ID.",
why_default: None,
},
FlagSpec {
short: Some('r'),
long: "use-regexp",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Patterns are unanchored regular expressions.",
why_default: None,
},
FlagSpec {
short: Some('i'),
long: "ignore-case",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Case-insensitive matching.",
why_default: None,
},
FlagSpec {
short: Some('s'),
long: "by-seq",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Search the sequence (both strands by default).",
why_default: None,
},
FlagSpec {
short: Some('d'),
long: "degenerate",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "IUPAC degenerate nucleotide motif. Implies -s.",
why_default: None,
},
FlagSpec {
short: None,
long: "invert-match",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Keep non-matching records. (No short flag: -v is CommonFlags --verbose.)",
why_default: None,
},
FlagSpec {
short: Some('R'),
long: "region",
aliases: &[],
value: Some("<start:end>"),
type_hint: Some("String"),
required: false,
default: None,
description: "Restrict -s search to a 1-based region. Implies -s.",
why_default: None,
},
FlagSpec {
short: Some('P'),
long: "only-positive-strand",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Search only the positive strand.",
why_default: None,
},
FlagSpec {
short: Some('w'),
long: "line-width",
aliases: &[],
value: Some("<n>"),
type_hint: Some("usize"),
required: false,
default: Some("60"),
description: "FASTA output line width (0 = no wrap). FASTQ is always unwrapped.",
why_default: Some("matches seqkit's default wrap width"),
},
FlagSpec {
short: Some('C'),
long: "count",
aliases: &[],
value: None,
type_hint: None,
required: false,
default: None,
description: "Print only the match count.",
why_default: None,
},
],
}],
examples: &[
Example {
description: "Keep records by exact ID",
command: "rsomics-seq-grep -p seq1,seq2 in.fa",
},
Example {
description: "Match full header (ID + description)",
command: "rsomics-seq-grep -n -p \"seq1 description\" in.fa",
},
Example {
description: "10k-ID allow-list from a file",
command: "rsomics-seq-grep -f ids.txt in.fq.gz",
},
Example {
description: "Find a restriction motif on either strand",
command: "rsomics-seq-grep -s -d -p GAATTC in.fa",
},
Example {
description: "Invert: drop matching records",
command: "rsomics-seq-grep -f ids.txt --invert-match in.fa",
},
],
json_result_schema_doc: None,
};
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_debug_assert() {
Cli::command().debug_assert();
}
}