Skip to main content

protocol_droid/domain/ir/
loglevel.rs

1//! `LogLevel` — the severity ladder a `notifications/message` record carries.
2//!
3//! # Why this is a type and not a string
4//!
5//! The spec names eight levels and orders them, and two things in this crate
6//! want that ordering: a server deciding whether a record is worth emitting,
7//! and a client filtering what it shows. Both are comparisons, and a comparison
8//! over `String` is either a lexical one — which happens to match the spec's
9//! order and will not survive a rename — or a hand-written match that has to
10//! be updated when a level is added.
11//!
12//! The order here follows the spec's, lowest first, so `Level::Debug <= x` is
13//! the "is this at or above `x`" test a filter wants, and derives `Ord` rather
14//! than hand-writing `PartialOrd` — see the note in [`Level`] on why those two
15//! must agree.
16//!
17//! # Why `Ord` is derived too
18//!
19//! `PartialOrd` has a fast path that assumes a hand-written implementation
20//! agrees with a derived `Ord`. An ordered type here derives both from the same
21//! declaration order for exactly that reason: an inconsistent pair silently
22//! changes comparison results rather than failing to compile.
23
24use core::fmt;
25use core::str::FromStr;
26
27use serde::{Deserialize, Serialize};
28
29/// A severity level on the log ladder.
30///
31/// Ordered lowest-severity first, matching the spec's own ordering: `debug`
32/// through `emergency`. Ordering is what a client filter needs, and the spec
33/// defines the order, so there is nothing for this crate to choose.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36#[non_exhaustive]
37pub enum Level {
38    /// Fine-grained detail, usually disabled.
39    Debug,
40    /// Ordinary operational detail.
41    Info,
42    /// Normal but significant events.
43    Notice,
44    /// A condition that is not an error but deserves attention.
45    Warning,
46    /// A failure the peer should know about.
47    Error,
48    /// A failure requiring immediate action.
49    Critical,
50    /// Action must be taken immediately.
51    Alert,
52    /// The system is unusable.
53    Emergency,
54}
55
56impl Level {
57    /// Every level, lowest first — the order [`Ord`] uses.
58    pub const ALL: [Self; 8] = [
59        Self::Debug,
60        Self::Info,
61        Self::Notice,
62        Self::Warning,
63        Self::Error,
64        Self::Critical,
65        Self::Alert,
66        Self::Emergency,
67    ];
68
69    /// The level's wire spelling, which is the variant name lowercased.
70    ///
71    /// The spelling is derived rather than stored so the two cannot drift, and
72    /// it is the same string [`Self::from_str`] accepts — the round trip is
73    /// the contract a codec needs, and a stored copy would be a second answer
74    /// to keep in step.
75    #[must_use]
76    pub const fn as_str(self) -> &'static str {
77        match self {
78            Self::Debug => "debug",
79            Self::Info => "info",
80            Self::Notice => "notice",
81            Self::Warning => "warning",
82            Self::Error => "error",
83            Self::Critical => "critical",
84            Self::Alert => "alert",
85            Self::Emergency => "emergency",
86        }
87    }
88
89    /// Parses a level from its wire spelling.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`LevelError`] for any other string, including the
94    /// right one in the wrong case. A level is a closed set the spec enumerates,
95    /// and accepting `"Info"` would mean emitting something no conforming peer
96    /// has agreed to recognise.
97    pub fn parse(text: &str) -> Result<Self, LevelError> {
98        Self::ALL
99            .into_iter()
100            .find(|level| level.as_str() == text)
101            .ok_or_else(|| LevelError(text.to_owned()))
102    }
103}
104
105/// A level name that is not one the spec defines.
106#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
107#[error("`{0}` is not a logging level")]
108pub struct LevelError(pub String);
109
110impl FromStr for Level {
111    type Err = LevelError;
112
113    fn from_str(text: &str) -> Result<Self, Self::Err> {
114        Self::parse(text)
115    }
116}
117
118impl fmt::Display for Level {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        formatter.write_str(self.as_str())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::{Level, LevelError};
127
128    #[test]
129    fn ordering_runs_lowest_severity_first() {
130        // The spec's ladder, asserted as an ordering rather than a list, so a
131        // variant added out of order fails here instead of silently changing
132        // what a client filter lets through.
133        let ladder = [
134            Level::Debug,
135            Level::Info,
136            Level::Notice,
137            Level::Warning,
138            Level::Error,
139            Level::Critical,
140            Level::Alert,
141            Level::Emergency,
142        ];
143        for pair in ladder.windows(2) {
144            let [lower, higher] = pair else {
145                return;
146            };
147            assert!(lower < higher, "{lower} should order below {higher}");
148        }
149    }
150
151    #[test]
152    fn derived_order_agrees_with_derived_partial_order() {
153        // `PartialOrd`'s fast path assumes a hand-written implementation agrees
154        // with a derived `Ord`. Both are derived here, so this proves the
155        // assumption holds across every pair rather than taking it on trust.
156        for left in Level::ALL {
157            for right in Level::ALL {
158                assert_eq!(
159                    left < right,
160                    left.cmp(&right) == core::cmp::Ordering::Less,
161                    "{left} vs {right}: `<` and `Ord` disagree"
162                );
163            }
164        }
165    }
166
167    #[test]
168    fn every_level_round_trips_through_its_spelling() {
169        for level in Level::ALL {
170            let text = level.as_str();
171            assert_eq!(Level::parse(text), Ok(level));
172        }
173    }
174
175    #[test]
176    fn an_unknown_spelling_is_refused_rather_than_guessed() {
177        // Case is part of the answer: `Info` is not `info`, and accepting it
178        // would emit a level no conforming peer recognises.
179        assert!(matches!(Level::parse("Info"), Err(LevelError(_))));
180        assert!(matches!(Level::parse("verbose"), Err(LevelError(_))));
181    }
182
183    #[test]
184    fn all_covers_every_variant_in_order() {
185        assert_eq!(Level::ALL.len(), 8);
186        assert_eq!(Level::ALL.first(), Some(&Level::Debug));
187        assert_eq!(Level::ALL.last(), Some(&Level::Emergency));
188    }
189}