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}