Skip to main content

seer_core/
grade.rs

1//! The A+…F letter scale shared by every graded report.
2//!
3//! `seer headers` and `seer audit` both turn a 0–100 score into a letter;
4//! the bands live here once so the same letter means the same thing in both.
5
6use std::fmt;
7use std::str::FromStr;
8
9use serde::{Deserialize, Serialize};
10
11/// A letter grade, declared worst (`F`) to best (`APlus`) so `Ord` compares
12/// grades directly (`min` caps a grade).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14pub enum Letter {
15    F,
16    E,
17    D,
18    C,
19    B,
20    A,
21    #[serde(rename = "A+")]
22    APlus,
23}
24
25impl Letter {
26    /// Maps a 0–100 score to its band.
27    pub fn from_score(score: u32) -> Self {
28        match score {
29            95..=u32::MAX => Letter::APlus,
30            85..=94 => Letter::A,
31            75..=84 => Letter::B,
32            65..=74 => Letter::C,
33            50..=64 => Letter::D,
34            30..=49 => Letter::E,
35            _ => Letter::F,
36        }
37    }
38
39    /// The printed letter (`"A+"` … `"F"`), identical to its serde form.
40    pub fn as_str(self) -> &'static str {
41        match self {
42            Letter::APlus => "A+",
43            Letter::A => "A",
44            Letter::B => "B",
45            Letter::C => "C",
46            Letter::D => "D",
47            Letter::E => "E",
48            Letter::F => "F",
49        }
50    }
51}
52
53impl fmt::Display for Letter {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        f.write_str(self.as_str())
56    }
57}
58
59impl FromStr for Letter {
60    type Err = String;
61
62    fn from_str(s: &str) -> Result<Self, Self::Err> {
63        match s.trim().to_ascii_uppercase().as_str() {
64            "A+" => Ok(Letter::APlus),
65            "A" => Ok(Letter::A),
66            "B" => Ok(Letter::B),
67            "C" => Ok(Letter::C),
68            "D" => Ok(Letter::D),
69            "E" => Ok(Letter::E),
70            "F" => Ok(Letter::F),
71            other => Err(format!(
72                "invalid grade '{other}' (expected A+, A, B, C, D, E or F)"
73            )),
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn score_bands_match_the_header_audit_scale() {
84        for (score, letter) in [
85            (100, Letter::APlus),
86            (95, Letter::APlus),
87            (94, Letter::A),
88            (85, Letter::A),
89            (84, Letter::B),
90            (75, Letter::B),
91            (74, Letter::C),
92            (65, Letter::C),
93            (64, Letter::D),
94            (50, Letter::D),
95            (49, Letter::E),
96            (30, Letter::E),
97            (29, Letter::F),
98            (0, Letter::F),
99        ] {
100            assert_eq!(Letter::from_score(score), letter, "score {score}");
101        }
102    }
103
104    #[test]
105    fn letters_order_worst_to_best() {
106        assert!(Letter::F < Letter::E);
107        assert!(Letter::C < Letter::B);
108        assert!(Letter::A < Letter::APlus);
109        assert_eq!(Letter::APlus.min(Letter::C), Letter::C);
110    }
111
112    #[test]
113    fn parses_and_prints_round_trip() {
114        for letter in [
115            Letter::F,
116            Letter::E,
117            Letter::D,
118            Letter::C,
119            Letter::B,
120            Letter::A,
121            Letter::APlus,
122        ] {
123            assert_eq!(letter.as_str().parse::<Letter>(), Ok(letter));
124            assert_eq!(letter.to_string(), letter.as_str());
125        }
126        assert_eq!(" a+ ".parse::<Letter>(), Ok(Letter::APlus));
127        assert!("Z".parse::<Letter>().is_err());
128    }
129
130    #[test]
131    fn serializes_as_the_printed_letter() {
132        assert_eq!(serde_json::to_string(&Letter::APlus).unwrap(), "\"A+\"");
133        assert_eq!(serde_json::to_string(&Letter::C).unwrap(), "\"C\"");
134    }
135}