monitrs_core/diagnostics/
finding.rs1use core::time::Duration;
10
11use crate::model::{Confidence, Measurement, Severity, SystemSnapshot};
12use crate::units::{ByteUnits, format_duration};
13
14use super::HistoryWindow;
15
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize))]
23pub struct TimeWindow {
24 pub span: Duration,
26 pub samples: usize,
31}
32
33impl TimeWindow {
34 pub const CURRENT_SAMPLE: Self = Self {
36 span: Duration::ZERO,
37 samples: 1,
38 };
39
40 #[must_use]
42 pub const fn new(span: Duration, samples: usize) -> Self {
43 Self { span, samples }
44 }
45
46 #[must_use]
53 pub const fn moving_average(span: Duration) -> Self {
54 Self { span, samples: 1 }
55 }
56
57 #[must_use]
59 pub const fn is_current_sample(&self) -> bool {
60 self.samples <= 1 && self.span.is_zero()
61 }
62
63 #[must_use]
65 pub fn render(&self) -> String {
66 if self.is_current_sample() {
67 return "current sample".to_owned();
68 }
69 if self.samples <= 1 {
70 return format!("last {}", format_duration(self.span));
71 }
72 format!(
73 "{} samples over {}",
74 self.samples,
75 format_duration(self.span)
76 )
77 }
78}
79
80#[derive(Clone, Copy, Debug, PartialEq)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize))]
83pub struct Evidence {
84 pub measurement: Measurement,
86 pub window: TimeWindow,
88}
89
90impl Evidence {
91 #[must_use]
93 pub const fn new(measurement: Measurement, window: TimeWindow) -> Self {
94 Self {
95 measurement,
96 window,
97 }
98 }
99
100 #[must_use]
102 pub const fn current(measurement: Measurement) -> Self {
103 Self::new(measurement, TimeWindow::CURRENT_SAMPLE)
104 }
105
106 #[must_use]
111 pub fn render(&self, units: ByteUnits) -> String {
112 if self.window.is_current_sample() {
113 return self.measurement.render(units);
114 }
115 format!(
116 "{} ({})",
117 self.measurement.render(units),
118 self.window.render()
119 )
120 }
121}
122
123#[derive(Clone, Debug, PartialEq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize))]
126pub struct Finding {
127 pub rule_id: &'static str,
133 pub severity: Severity,
135 pub title: String,
137 pub summary: String,
143 pub evidence: Vec<Evidence>,
145 pub confidence: Confidence,
147}
148
149impl Finding {
150 #[must_use]
152 pub fn new(
153 rule_id: &'static str,
154 severity: Severity,
155 title: impl Into<String>,
156 summary: impl Into<String>,
157 confidence: Confidence,
158 ) -> Self {
159 Self {
160 rule_id,
161 severity,
162 title: title.into(),
163 summary: summary.into(),
164 evidence: Vec::new(),
165 confidence,
166 }
167 }
168
169 #[must_use]
171 pub fn with_evidence(mut self, evidence: Vec<Evidence>) -> Self {
172 self.evidence = evidence;
173 self
174 }
175
176 #[must_use]
178 pub const fn symbol(&self) -> char {
179 self.severity.symbol()
180 }
181
182 #[must_use]
184 pub fn headline(&self) -> String {
185 format!("{}: {}", self.severity.label().to_uppercase(), self.title)
186 }
187
188 #[must_use]
190 pub fn render_evidence(&self, units: ByteUnits) -> String {
191 self.evidence
192 .iter()
193 .map(|item| item.render(units))
194 .collect::<Vec<_>>()
195 .join("; ")
196 }
197
198 #[must_use]
200 pub fn render_confidence(&self) -> String {
201 format!("confidence: {}", self.confidence.label())
202 }
203}
204
205pub trait DiagnosticRule: Send + Sync {
215 fn id(&self) -> &'static str;
217
218 fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding>;
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::model::MeasuredValue;
230 use crate::units::Percent;
231
232 fn percent(value: f32) -> Percent {
233 Percent::new(value).expect("valid percent")
234 }
235
236 #[test]
237 fn a_single_reading_renders_without_a_window_suffix() {
238 let evidence = Evidence::current(Measurement::new(
239 "cpu busy",
240 MeasuredValue::Percent(percent(91.0)),
241 ));
242 assert_eq!(evidence.render(ByteUnits::Iec), "cpu busy 91%");
243 assert!(evidence.window.is_current_sample());
244 }
245
246 #[test]
247 fn evidence_over_a_window_names_the_window_it_covers() {
248 let evidence = Evidence::new(
249 Measurement::new("samples above threshold", MeasuredValue::Count(12)),
250 TimeWindow::new(Duration::from_secs(14), 15),
251 );
252 assert_eq!(
253 evidence.render(ByteUnits::Iec),
254 "samples above threshold 12 (15 samples over 14s)"
255 );
256 }
257
258 #[test]
259 fn a_moving_average_reports_the_span_it_summarizes_not_one_sample() {
260 let window = TimeWindow::moving_average(Duration::from_secs(10));
261 assert_eq!(window.samples, 1);
262 assert_eq!(window.render(), "last 10s");
263 assert!(!window.is_current_sample());
264 }
265
266 #[test]
267 fn byte_evidence_is_rendered_in_the_callers_unit_family() {
268 let evidence = Evidence::current(Measurement::new(
269 "available",
270 MeasuredValue::Bytes(4 * 1024 * 1024 * 1024),
271 ));
272 assert_eq!(evidence.render(ByteUnits::Iec), "available 4.0 GiB");
273 assert_eq!(evidence.render(ByteUnits::Si), "available 4.3 GB");
274 }
275
276 #[test]
277 fn a_finding_renders_the_three_lines_from_the_specification_example() {
278 let finding = Finding::new(
279 "cpu.sustained_saturation",
280 Severity::Watch,
281 "Sustained CPU saturation",
282 "CPU busy at or above 90% in 12 of the last 15 samples.",
283 Confidence::Medium,
284 )
285 .with_evidence(vec![
286 Evidence::new(
287 Measurement::new("cpu busy", MeasuredValue::Percent(percent(91.0))),
288 TimeWindow::new(Duration::from_secs(14), 15),
289 ),
290 Evidence::current(Measurement::new("load1", MeasuredValue::Load(11.4))),
291 ]);
292
293 assert_eq!(finding.headline(), "WATCH: Sustained CPU saturation");
294 assert_eq!(
295 finding.render_evidence(ByteUnits::Iec),
296 "cpu busy 91% (15 samples over 14s); load1 11.40"
297 );
298 assert_eq!(finding.render_confidence(), "confidence: medium");
299 assert_eq!(finding.symbol(), '!', "§5.2 requires a non-color cue");
300 }
301
302 #[test]
303 fn a_finding_without_evidence_renders_an_empty_evidence_line_rather_than_panicking() {
304 let finding = Finding::new(
305 "test.rule",
306 Severity::Info,
307 "Title",
308 "Summary",
309 Confidence::Low,
310 );
311 assert_eq!(finding.render_evidence(ByteUnits::Iec), "");
312 assert_eq!(finding.headline(), "INFO: Title");
313 }
314}