Skip to main content

rsomics_seq_grep/
region.rs

1use rsomics_common::{Result, RsomicsError};
2
3/// 1-based, inclusive region spec used by `-R/--region` (negatives count from the end).
4#[derive(Clone, Copy)]
5pub struct Region {
6    pub start: i64,
7    pub end: i64,
8}
9
10impl Region {
11    /// Parse `"start:end"`, e.g. `1:12` or `-12:-1`.
12    pub fn parse(s: &str) -> Result<Self> {
13        let (left, right) = s.split_once(':').ok_or_else(|| {
14            RsomicsError::InvalidInput(format!("invalid region `{s}` — expect start:end"))
15        })?;
16
17        let start: i64 = left
18            .parse()
19            .map_err(|_| RsomicsError::InvalidInput(format!("invalid region start `{left}`")))?;
20        let end: i64 = right
21            .parse()
22            .map_err(|_| RsomicsError::InvalidInput(format!("invalid region end `{right}`")))?;
23
24        if start == 0 || end == 0 {
25            return Err(RsomicsError::InvalidInput(
26                "both start and end should not be 0".to_string(),
27            ));
28        }
29        if start < 0 && end > 0 {
30            return Err(RsomicsError::InvalidInput(
31                "when start < 0, end should not > 0".to_string(),
32            ));
33        }
34
35        Ok(Self { start, end })
36    }
37
38    /// Resolve against a sequence of `seq_len` bases, returning `(start0, end0_excl)`.
39    /// Returns `(0, 0)` when the region misses the sequence entirely.
40    #[must_use]
41    pub fn resolve(&self, seq_len: usize) -> (usize, usize) {
42        #[allow(clippy::cast_possible_wrap)]
43        let len = seq_len as i64;
44
45        let start0 = if self.start > 0 {
46            self.start - 1
47        } else {
48            (len + self.start).max(0)
49        };
50        let end0_incl = if self.end > 0 {
51            self.end.min(len) - 1
52        } else {
53            len + self.end
54        };
55
56        if end0_incl < 0 || start0 > end0_incl {
57            return (0, 0);
58        }
59
60        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
61        let s = start0.clamp(0, len) as usize;
62        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
63        let e = (end0_incl + 1).clamp(0, len) as usize;
64        (s, e)
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn first_twelve() {
74        let r = Region::parse("1:12").unwrap();
75        assert_eq!(r.resolve(20), (0, 12));
76    }
77
78    #[test]
79    fn last_one() {
80        let r = Region::parse("-1:-1").unwrap();
81        assert_eq!(r.resolve(10), (9, 10));
82    }
83
84    #[test]
85    fn out_of_bounds_is_empty() {
86        let r = Region::parse("100:200").unwrap();
87        assert_eq!(r.resolve(10), (0, 0));
88    }
89
90    #[test]
91    fn rejects_zero() {
92        assert!(Region::parse("0:5").is_err());
93    }
94
95    #[test]
96    fn rejects_mixed_sign() {
97        assert!(Region::parse("-5:5").is_err());
98    }
99}