rsomics-seq-grep 0.1.1

Filter FASTA/FASTQ records by ID/name/sequence — seqkit grep port
Documentation
use crate::pattern::PatternSet;
use crate::region::Region;
use crate::revcomp::revcomp;

pub struct MatchOpts {
    pub by_name: bool,
    pub by_seq: bool,
    pub only_positive: bool,
    pub ignore_case: bool,
    pub region: Option<Region>,
}

/// `by_seq` is the *effective* value (`-s` OR `-d` OR `-R` already folded
/// in by the caller) — `seqkit` auto-enables sequence search whenever any
/// of those flags implies it.
impl MatchOpts {
    #[must_use]
    pub fn effective_by_seq(explicit_by_seq: bool, degenerate: bool, region: bool) -> bool {
        explicit_by_seq || degenerate || region
    }
}

/// Decide whether one record is a hit. ID/name search never looks past the
/// plus strand — orientation only matters for sequence search, and even
/// then only the first strand that hits short-circuits the rest (matches
/// `seqkit`'s `for strand := range strands { if hit { break } }`).
#[must_use]
pub fn is_hit(id: &[u8], name: &[u8], seq: &[u8], opts: &MatchOpts, patterns: &PatternSet) -> bool {
    if !opts.by_seq {
        let target = if opts.by_name { name } else { id };
        return patterns.is_match(target, opts.ignore_case);
    }

    let strands: &[bool] = if opts.only_positive {
        &[false]
    } else {
        &[false, true]
    };

    for &minus in strands {
        let owned;
        let base: &[u8] = if minus {
            owned = revcomp(seq);
            &owned
        } else {
            seq
        };

        let target: &[u8] = match &opts.region {
            Some(region) => {
                let (s, e) = region.resolve(base.len());
                if s == e {
                    continue;
                }
                &base[s..e]
            }
            None => base,
        };

        if patterns.is_match(target, opts.ignore_case) {
            return true;
        }
    }
    false
}

/// `seqkit`'s default ID is the header up to the first space or tab,
/// whichever comes first (`fastx.DefaultIDRegexp`, `^(\S+)\s?`, applied via
/// its hand-rolled fast path rather than the regex itself). The full header
/// (`-n/--by-name`) needs no splitting — it's the raw line.
#[must_use]
pub fn split_id(name: &[u8]) -> &[u8] {
    match name.iter().position(|&b| b == b' ' || b == b'\t') {
        Some(i) => &name[..i],
        None => name,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pattern::{self, BuildOpts};

    fn exact(pats: &[&str]) -> PatternSet {
        let owned: Vec<String> = pats.iter().map(|s| (*s).to_string()).collect();
        pattern::build(
            &owned,
            &BuildOpts {
                use_regexp: false,
                degenerate: false,
                by_seq: false,
                ignore_case: false,
            },
        )
        .unwrap()
    }

    fn substr(pats: &[&str]) -> PatternSet {
        let owned: Vec<String> = pats.iter().map(|s| (*s).to_string()).collect();
        pattern::build(
            &owned,
            &BuildOpts {
                use_regexp: false,
                degenerate: false,
                by_seq: true,
                ignore_case: false,
            },
        )
        .unwrap()
    }

    #[test]
    fn split_id_stops_at_first_space() {
        assert_eq!(split_id(b"seq1 description one"), b"seq1");
        assert_eq!(split_id(b"seq1\tdescription"), b"seq1");
        assert_eq!(split_id(b"seq1"), b"seq1");
    }

    #[test]
    fn id_mode_never_checks_strand() {
        let p = exact(&["seq1"]);
        let opts = MatchOpts {
            by_name: false,
            by_seq: false,
            only_positive: false,
            ignore_case: false,
            region: None,
        };
        assert!(is_hit(b"seq1", b"seq1 desc", b"ACGT", &opts, &p));
    }

    #[test]
    fn seq_mode_checks_both_strands_by_default() {
        // "GGGGTTTT" only occurs on the minus strand of "TTTTAAAACCCCGGGG".
        let p = substr(&["GGGGTTTT"]);
        let opts = MatchOpts {
            by_name: false,
            by_seq: true,
            only_positive: false,
            ignore_case: false,
            region: None,
        };
        assert!(is_hit(b"id", b"id", b"TTTTAAAACCCCGGGG", &opts, &p));
    }

    #[test]
    fn only_positive_strand_excludes_minus_hits() {
        let p = substr(&["GGGGTTTT"]);
        let opts = MatchOpts {
            by_name: false,
            by_seq: true,
            only_positive: true,
            ignore_case: false,
            region: None,
        };
        assert!(!is_hit(b"id", b"id", b"TTTTAAAACCCCGGGG", &opts, &p));
    }

    #[test]
    fn region_restricts_search_window() {
        let p = substr(&["TTTT"]);
        let region = Region::parse("1:4").unwrap();
        let opts = MatchOpts {
            by_name: false,
            by_seq: true,
            only_positive: true,
            ignore_case: false,
            region: Some(region),
        };
        assert!(is_hit(b"id", b"id", b"TTTTCCCC", &opts, &p));
        assert!(!is_hit(b"id", b"id", b"CCCCTTTT", &opts, &p));
    }
}