use core::time::Duration;
use crate::model::{Confidence, Measurement, Severity, SystemSnapshot};
use crate::units::{ByteUnits, format_duration};
use super::HistoryWindow;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TimeWindow {
pub span: Duration,
pub samples: usize,
}
impl TimeWindow {
pub const CURRENT_SAMPLE: Self = Self {
span: Duration::ZERO,
samples: 1,
};
#[must_use]
pub const fn new(span: Duration, samples: usize) -> Self {
Self { span, samples }
}
#[must_use]
pub const fn moving_average(span: Duration) -> Self {
Self { span, samples: 1 }
}
#[must_use]
pub const fn is_current_sample(&self) -> bool {
self.samples <= 1 && self.span.is_zero()
}
#[must_use]
pub fn render(&self) -> String {
if self.is_current_sample() {
return "current sample".to_owned();
}
if self.samples <= 1 {
return format!("last {}", format_duration(self.span));
}
format!(
"{} samples over {}",
self.samples,
format_duration(self.span)
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Evidence {
pub measurement: Measurement,
pub window: TimeWindow,
}
impl Evidence {
#[must_use]
pub const fn new(measurement: Measurement, window: TimeWindow) -> Self {
Self {
measurement,
window,
}
}
#[must_use]
pub const fn current(measurement: Measurement) -> Self {
Self::new(measurement, TimeWindow::CURRENT_SAMPLE)
}
#[must_use]
pub fn render(&self, units: ByteUnits) -> String {
if self.window.is_current_sample() {
return self.measurement.render(units);
}
format!(
"{} ({})",
self.measurement.render(units),
self.window.render()
)
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Finding {
pub rule_id: &'static str,
pub severity: Severity,
pub title: String,
pub summary: String,
pub evidence: Vec<Evidence>,
pub confidence: Confidence,
}
impl Finding {
#[must_use]
pub fn new(
rule_id: &'static str,
severity: Severity,
title: impl Into<String>,
summary: impl Into<String>,
confidence: Confidence,
) -> Self {
Self {
rule_id,
severity,
title: title.into(),
summary: summary.into(),
evidence: Vec::new(),
confidence,
}
}
#[must_use]
pub fn with_evidence(mut self, evidence: Vec<Evidence>) -> Self {
self.evidence = evidence;
self
}
#[must_use]
pub const fn symbol(&self) -> char {
self.severity.symbol()
}
#[must_use]
pub fn headline(&self) -> String {
format!("{}: {}", self.severity.label().to_uppercase(), self.title)
}
#[must_use]
pub fn render_evidence(&self, units: ByteUnits) -> String {
self.evidence
.iter()
.map(|item| item.render(units))
.collect::<Vec<_>>()
.join("; ")
}
#[must_use]
pub fn render_confidence(&self) -> String {
format!("confidence: {}", self.confidence.label())
}
}
pub trait DiagnosticRule: Send + Sync {
fn id(&self) -> &'static str;
fn evaluate(&self, current: &SystemSnapshot, history: &HistoryWindow<'_>) -> Option<Finding>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::MeasuredValue;
use crate::units::Percent;
fn percent(value: f32) -> Percent {
Percent::new(value).expect("valid percent")
}
#[test]
fn a_single_reading_renders_without_a_window_suffix() {
let evidence = Evidence::current(Measurement::new(
"cpu busy",
MeasuredValue::Percent(percent(91.0)),
));
assert_eq!(evidence.render(ByteUnits::Iec), "cpu busy 91%");
assert!(evidence.window.is_current_sample());
}
#[test]
fn evidence_over_a_window_names_the_window_it_covers() {
let evidence = Evidence::new(
Measurement::new("samples above threshold", MeasuredValue::Count(12)),
TimeWindow::new(Duration::from_secs(14), 15),
);
assert_eq!(
evidence.render(ByteUnits::Iec),
"samples above threshold 12 (15 samples over 14s)"
);
}
#[test]
fn a_moving_average_reports_the_span_it_summarizes_not_one_sample() {
let window = TimeWindow::moving_average(Duration::from_secs(10));
assert_eq!(window.samples, 1);
assert_eq!(window.render(), "last 10s");
assert!(!window.is_current_sample());
}
#[test]
fn byte_evidence_is_rendered_in_the_callers_unit_family() {
let evidence = Evidence::current(Measurement::new(
"available",
MeasuredValue::Bytes(4 * 1024 * 1024 * 1024),
));
assert_eq!(evidence.render(ByteUnits::Iec), "available 4.0 GiB");
assert_eq!(evidence.render(ByteUnits::Si), "available 4.3 GB");
}
#[test]
fn a_finding_renders_the_three_lines_from_the_specification_example() {
let finding = Finding::new(
"cpu.sustained_saturation",
Severity::Watch,
"Sustained CPU saturation",
"CPU busy at or above 90% in 12 of the last 15 samples.",
Confidence::Medium,
)
.with_evidence(vec![
Evidence::new(
Measurement::new("cpu busy", MeasuredValue::Percent(percent(91.0))),
TimeWindow::new(Duration::from_secs(14), 15),
),
Evidence::current(Measurement::new("load1", MeasuredValue::Load(11.4))),
]);
assert_eq!(finding.headline(), "WATCH: Sustained CPU saturation");
assert_eq!(
finding.render_evidence(ByteUnits::Iec),
"cpu busy 91% (15 samples over 14s); load1 11.40"
);
assert_eq!(finding.render_confidence(), "confidence: medium");
assert_eq!(finding.symbol(), '!', "§5.2 requires a non-color cue");
}
#[test]
fn a_finding_without_evidence_renders_an_empty_evidence_line_rather_than_panicking() {
let finding = Finding::new(
"test.rule",
Severity::Info,
"Title",
"Summary",
Confidence::Low,
);
assert_eq!(finding.render_evidence(ByteUnits::Iec), "");
assert_eq!(finding.headline(), "INFO: Title");
}
}