rsomics-seq-grep 0.1.0

Filter FASTA/FASTQ records by ID/name/sequence — seqkit grep port
Documentation
use rsomics_common::{Result, RsomicsError};

/// 1-based, inclusive region spec used by `-R/--region` (negatives count from the end).
#[derive(Clone, Copy)]
pub struct Region {
    pub start: i64,
    pub end: i64,
}

impl Region {
    /// Parse `"start:end"`, e.g. `1:12` or `-12:-1`.
    pub fn parse(s: &str) -> Result<Self> {
        let (left, right) = s.split_once(':').ok_or_else(|| {
            RsomicsError::InvalidInput(format!("invalid region `{s}` — expect start:end"))
        })?;

        let start: i64 = left
            .parse()
            .map_err(|_| RsomicsError::InvalidInput(format!("invalid region start `{left}`")))?;
        let end: i64 = right
            .parse()
            .map_err(|_| RsomicsError::InvalidInput(format!("invalid region end `{right}`")))?;

        if start == 0 || end == 0 {
            return Err(RsomicsError::InvalidInput(
                "both start and end should not be 0".to_string(),
            ));
        }
        if start < 0 && end > 0 {
            return Err(RsomicsError::InvalidInput(
                "when start < 0, end should not > 0".to_string(),
            ));
        }

        Ok(Self { start, end })
    }

    /// Resolve against a sequence of `seq_len` bases, returning `(start0, end0_excl)`.
    /// Returns `(0, 0)` when the region misses the sequence entirely.
    #[must_use]
    pub fn resolve(&self, seq_len: usize) -> (usize, usize) {
        #[allow(clippy::cast_possible_wrap)]
        let len = seq_len as i64;

        let start0 = if self.start > 0 {
            self.start - 1
        } else {
            (len + self.start).max(0)
        };
        let end0_incl = if self.end > 0 {
            self.end.min(len) - 1
        } else {
            len + self.end
        };

        if end0_incl < 0 || start0 > end0_incl {
            return (0, 0);
        }

        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
        let s = start0.clamp(0, len) as usize;
        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
        let e = (end0_incl + 1).clamp(0, len) as usize;
        (s, e)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn first_twelve() {
        let r = Region::parse("1:12").unwrap();
        assert_eq!(r.resolve(20), (0, 12));
    }

    #[test]
    fn last_one() {
        let r = Region::parse("-1:-1").unwrap();
        assert_eq!(r.resolve(10), (9, 10));
    }

    #[test]
    fn out_of_bounds_is_empty() {
        let r = Region::parse("100:200").unwrap();
        assert_eq!(r.resolve(10), (0, 0));
    }

    #[test]
    fn rejects_zero() {
        assert!(Region::parse("0:5").is_err());
    }

    #[test]
    fn rejects_mixed_sign() {
        assert!(Region::parse("-5:5").is_err());
    }
}