rsomics-seq-grep 0.1.0

Filter FASTA/FASTQ records by ID/name/sequence — seqkit grep port
Documentation
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 FASTA/FASTQ, plain or gzip. `-` reads stdin.
    input: String,

    /// Output file (`-` for stdout).
    #[arg(short = 'o', long, default_value = "-")]
    output: String,

    /// Search pattern (comma-separated or repeated).
    #[arg(short = 'p', long = "pattern", value_delimiter = ',')]
    patterns: Vec<String>,

    /// Pattern file, one pattern per line. Overrides `-p` entirely when given.
    #[arg(short = 'f', long = "pattern-file")]
    pattern_file: Option<PathBuf>,

    /// Match by full name (header line) instead of just the ID.
    #[arg(short = 'n', long = "by-name")]
    by_name: bool,

    /// Treat patterns as regular expressions (unanchored, partial match).
    #[arg(short = 'r', long = "use-regexp")]
    use_regexp: bool,

    /// Ignore case.
    #[arg(short = 'i', long = "ignore-case")]
    ignore_case: bool,

    /// Search the sequence instead of ID/name (substring match, both strands by default).
    #[arg(short = 's', long = "by-seq")]
    by_seq: bool,

    /// Pattern is an IUPAC degenerate nucleotide motif. Implies `-s`.
    #[arg(short = 'd', long = "degenerate")]
    degenerate: bool,

    /// Keep non-matching records instead of matching ones.
    #[arg(long = "invert-match")]
    invert_match: bool,

    /// Restrict sequence search to a 1-based region, e.g. `1:12` or `-12:-1`. Implies `-s`.
    #[arg(short = 'R', long = "region", allow_hyphen_values = true)]
    region: Option<String>,

    /// Search only the positive strand (default: both strands for `-s`).
    #[arg(short = 'P', long = "only-positive-strand")]
    only_positive_strand: bool,

    /// FASTA output line width (0 = no wrap). FASTQ is always unwrapped.
    #[arg(short = 'w', long = "line-width", default_value_t = 60)]
    line_width: usize,

    /// Print only the count of matching records (respects `-v`).
    #[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(),
            ));
        }

        // A pattern file replaces `-p` outright — matches seqkit grep, which
        // never merges the two sources.
        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(())
    }

    // The default `run` discards the body's return value, so `--json` would
    // emit `result: null`. Override to carry the populated GrepReport into
    // the envelope while leaving the non-json path untouched.
    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();
    }
}