Skip to main content

k2tools_lib/report/
rank.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5
6/// The standard taxonomic ranks used in kraken2 reports.
7///
8/// Each variant maps to a single-character rank code used in kraken2 output.
9/// Non-standard intermediate ranks (e.g. "G2") are represented by pairing
10/// a `Rank` with a depth in [`TaxonomicRank`].
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum Rank {
13    /// Unclassified sequences (U)
14    Unclassified,
15    /// Root of the taxonomy tree (R)
16    Root,
17    /// Domain / superkingdom (D)
18    Domain,
19    /// Kingdom (K)
20    Kingdom,
21    /// Phylum (P)
22    Phylum,
23    /// Class (C)
24    Class,
25    /// Order (O)
26    Order,
27    /// Family (F)
28    Family,
29    /// Genus (G)
30    Genus,
31    /// Species (S)
32    Species,
33}
34
35impl Rank {
36    /// Returns the single-character code for this rank.
37    #[must_use]
38    pub fn code(self) -> char {
39        match self {
40            Self::Unclassified => 'U',
41            Self::Root => 'R',
42            Self::Domain => 'D',
43            Self::Kingdom => 'K',
44            Self::Phylum => 'P',
45            Self::Class => 'C',
46            Self::Order => 'O',
47            Self::Family => 'F',
48            Self::Genus => 'G',
49            Self::Species => 'S',
50        }
51    }
52}
53
54impl fmt::Display for Rank {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{}", self.code())
57    }
58}
59
60impl FromStr for Rank {
61    type Err = anyhow::Error;
62
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        match s {
65            "U" => Ok(Self::Unclassified),
66            "R" => Ok(Self::Root),
67            "D" => Ok(Self::Domain),
68            "K" => Ok(Self::Kingdom),
69            "P" => Ok(Self::Phylum),
70            "C" => Ok(Self::Class),
71            "O" => Ok(Self::Order),
72            "F" => Ok(Self::Family),
73            "G" => Ok(Self::Genus),
74            "S" => Ok(Self::Species),
75            _ => anyhow::bail!("invalid rank code: {s:?}"),
76        }
77    }
78}
79
80/// A taxonomic rank with an optional depth suffix for non-standard intermediate ranks.
81///
82/// Standard ranks (e.g. Genus) have `depth: None` and display as a single character ("G").
83/// Non-standard ranks (e.g. two levels below Genus) have `depth: Some(2)` and display
84/// as the rank code followed by the depth ("G2").
85///
86/// Kraken2 increments the depth suffix during DFS traversal for each non-standard rank
87/// encountered between two standard ranks.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
89pub struct TaxonomicRank {
90    rank: Rank,
91    depth: Option<u32>,
92}
93
94impl TaxonomicRank {
95    /// Returns the base rank.
96    #[must_use]
97    pub fn rank(&self) -> Rank {
98        self.rank
99    }
100
101    /// Returns the depth suffix, or `None` for standard ranks.
102    #[must_use]
103    pub fn depth(&self) -> Option<u32> {
104        self.depth
105    }
106
107    /// Returns `true` if this is a standard rank (no depth suffix).
108    #[must_use]
109    pub fn is_standard(&self) -> bool {
110        self.depth.is_none()
111    }
112}
113
114impl fmt::Display for TaxonomicRank {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "{}", self.rank.code())?;
117        if let Some(d) = self.depth {
118            write!(f, "{d}")?;
119        }
120        Ok(())
121    }
122}
123
124impl FromStr for TaxonomicRank {
125    type Err = anyhow::Error;
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        anyhow::ensure!(!s.is_empty(), "empty rank string");
129
130        let code = &s[..1];
131        let rank = Rank::from_str(code)?;
132        let suffix = &s[1..];
133
134        if suffix.is_empty() {
135            return Ok(Self { rank, depth: None });
136        }
137
138        let depth: u32 =
139            suffix.parse().map_err(|_| anyhow::anyhow!("invalid rank depth suffix: {s:?}"))?;
140        anyhow::ensure!(depth > 0, "rank depth suffix must be > 0, got: {s:?}");
141        Ok(Self { rank, depth: Some(depth) })
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_rank_round_trip_all_standard() {
151        let cases = [
152            (Rank::Unclassified, "U"),
153            (Rank::Root, "R"),
154            (Rank::Domain, "D"),
155            (Rank::Kingdom, "K"),
156            (Rank::Phylum, "P"),
157            (Rank::Class, "C"),
158            (Rank::Order, "O"),
159            (Rank::Family, "F"),
160            (Rank::Genus, "G"),
161            (Rank::Species, "S"),
162        ];
163
164        for (rank, code) in cases {
165            assert_eq!(rank.to_string(), code, "Display failed for {rank:?}");
166            assert_eq!(Rank::from_str(code).unwrap(), rank, "FromStr failed for {code}");
167            assert_eq!(rank.code(), code.chars().next().unwrap());
168        }
169    }
170
171    #[test]
172    fn test_rank_invalid() {
173        assert!(Rank::from_str("X").is_err());
174        assert!(Rank::from_str("").is_err());
175        assert!(Rank::from_str("GG").is_err());
176    }
177
178    #[test]
179    fn test_taxonomic_rank_standard_round_trip() {
180        for code in ["U", "R", "D", "K", "P", "C", "O", "F", "G", "S"] {
181            let tr = TaxonomicRank::from_str(code).unwrap();
182            assert!(tr.is_standard());
183            assert_eq!(tr.depth(), None);
184            assert_eq!(tr.to_string(), code);
185        }
186    }
187
188    #[test]
189    fn test_taxonomic_rank_non_standard_parse() {
190        let tr = TaxonomicRank::from_str("G2").unwrap();
191        assert_eq!(tr.rank(), Rank::Genus);
192        assert_eq!(tr.depth(), Some(2));
193        assert!(!tr.is_standard());
194        assert_eq!(tr.to_string(), "G2");
195    }
196
197    #[test]
198    fn test_taxonomic_rank_domain_depth() {
199        let tr = TaxonomicRank::from_str("D1").unwrap();
200        assert_eq!(tr.rank(), Rank::Domain);
201        assert_eq!(tr.depth(), Some(1));
202    }
203
204    #[test]
205    fn test_taxonomic_rank_display_non_standard() {
206        let tr = TaxonomicRank::from_str("S3").unwrap();
207        assert_eq!(tr.rank(), Rank::Species);
208        assert_eq!(tr.depth(), Some(3));
209        assert_eq!(tr.to_string(), "S3");
210    }
211
212    #[test]
213    fn test_taxonomic_rank_invalid_code() {
214        assert!(TaxonomicRank::from_str("X").is_err());
215        assert!(TaxonomicRank::from_str("X1").is_err());
216    }
217
218    #[test]
219    fn test_taxonomic_rank_zero_depth() {
220        assert!(TaxonomicRank::from_str("G0").is_err());
221    }
222
223    #[test]
224    fn test_taxonomic_rank_empty() {
225        assert!(TaxonomicRank::from_str("").is_err());
226    }
227
228    #[test]
229    fn test_taxonomic_rank_non_numeric_suffix() {
230        assert!(TaxonomicRank::from_str("Gx").is_err());
231    }
232}