Skip to main content

scope/watch/
diff.rs

1//! Events between two snapshots: a breach or a warning that starts or ends.
2
3use super::{RuleStatus, Snapshot};
4
5/// A change in one cell between two ticks.
6#[derive(Debug, Clone, PartialEq)]
7pub enum Event {
8    /// A cell failed; it did not fail on the tick before.
9    BreachStarted {
10        target: String,
11        cell: String,
12        message: String,
13    },
14    /// A cell that failed passes (or is no longer a failure).
15    BreachCleared { target: String, cell: String },
16    /// A cell warned; it did not warn on the tick before.
17    WarnStarted {
18        target: String,
19        cell: String,
20        message: String,
21    },
22    /// A cell that warned no longer warns.
23    WarnCleared { target: String, cell: String },
24}
25
26impl Event {
27    /// The banner text, for example `USDC@binance: spread_pct 3.4, needs <= 3`.
28    pub fn banner(&self) -> String {
29        match self {
30            Event::BreachStarted {
31                target, message, ..
32            }
33            | Event::WarnStarted {
34                target, message, ..
35            } => format!("{target}: {message}"),
36            Event::BreachCleared { target, cell } => format!("{target}: {cell} passes again"),
37            Event::WarnCleared { target, cell } => format!("{target}: {cell} no longer warns"),
38        }
39    }
40}
41
42/// The events from `prev` to `cur`. Cells match by target and cell label;
43/// a target or cell new in `cur` compares with nothing (the first tick
44/// compares with an empty snapshot).
45pub fn diff(prev: &Snapshot, cur: &Snapshot) -> Vec<Event> {
46    let before = |target: &str, cell: &str| {
47        prev.targets
48            .iter()
49            .find(|t| t.target == target)
50            .and_then(|t| t.cells.iter().find(|c| c.label == cell))
51            .map(|c| &c.status)
52    };
53    let mut events = Vec::new();
54    for t in &cur.targets {
55        for c in &t.cells {
56            let old = before(&t.target, &c.label);
57            let was_fail = matches!(old, Some(RuleStatus::Fail(_)));
58            let was_warn = matches!(old, Some(RuleStatus::Warn(_)));
59            let target = t.target.clone();
60            let cell = c.label.clone();
61            match &c.status {
62                RuleStatus::Fail(message) if !was_fail => events.push(Event::BreachStarted {
63                    target,
64                    cell,
65                    message: message.clone(),
66                }),
67                RuleStatus::Warn(message) if !was_warn => events.push(Event::WarnStarted {
68                    target,
69                    cell,
70                    message: message.clone(),
71                }),
72                RuleStatus::Fail(_) | RuleStatus::Warn(_) => {}
73                // An error says nothing about the breach: health is unknown,
74                // so the breach is not cleared.
75                RuleStatus::Error(_) => {}
76                RuleStatus::Pass | RuleStatus::NotApplicable(_) => {
77                    if was_fail {
78                        events.push(Event::BreachCleared { target, cell });
79                    } else if was_warn {
80                        events.push(Event::WarnCleared { target, cell });
81                    }
82                }
83            }
84            // A warning that became a failure ends as a warning too.
85            if was_warn && matches!(c.status, RuleStatus::Fail(_)) {
86                events.push(Event::WarnCleared {
87                    target: t.target.clone(),
88                    cell: c.label.clone(),
89                });
90            }
91        }
92    }
93    events
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::watch::{Cell, TargetSnapshot};
100
101    fn snap(status: RuleStatus) -> Snapshot {
102        Snapshot {
103            targets: vec![TargetSnapshot::new(
104                "USDC@binance",
105                vec![Cell {
106                    label: "Spread".into(),
107                    rule: None,
108                    value: None,
109                    status,
110                    detail: None,
111                }],
112            )],
113        }
114    }
115
116    fn fail() -> RuleStatus {
117        RuleStatus::Fail("spread 3.4".into())
118    }
119
120    #[test]
121    fn first_tick_compares_with_empty() {
122        let e = diff(&Snapshot::default(), &snap(fail()));
123        assert_eq!(
124            e,
125            vec![Event::BreachStarted {
126                target: "USDC@binance".into(),
127                cell: "Spread".into(),
128                message: "spread 3.4".into()
129            }]
130        );
131        assert_eq!(e[0].banner(), "USDC@binance: spread 3.4");
132        assert!(diff(&Snapshot::default(), &snap(RuleStatus::Pass)).is_empty());
133    }
134
135    #[test]
136    fn no_event_when_nothing_changes() {
137        assert!(diff(&snap(fail()), &snap(fail())).is_empty());
138        assert!(diff(&snap(RuleStatus::Pass), &snap(RuleStatus::Pass)).is_empty());
139    }
140
141    #[test]
142    fn breach_clears_on_pass_and_on_na_but_not_on_error() {
143        assert!(matches!(
144            diff(&snap(fail()), &snap(RuleStatus::Pass))[..],
145            [Event::BreachCleared { .. }]
146        ));
147        assert!(matches!(
148            diff(&snap(fail()), &snap(RuleStatus::NotApplicable("n".into())))[..],
149            [Event::BreachCleared { .. }]
150        ));
151        assert!(diff(&snap(fail()), &snap(RuleStatus::Error("e".into()))).is_empty());
152    }
153
154    #[test]
155    fn warnings_start_clear_and_turn_into_breaches() {
156        let warn = RuleStatus::Warn("ratio".into());
157        assert!(matches!(
158            diff(&snap(RuleStatus::Pass), &snap(warn.clone()))[..],
159            [Event::WarnStarted { .. }]
160        ));
161        assert!(matches!(
162            diff(&snap(warn.clone()), &snap(RuleStatus::Pass))[..],
163            [Event::WarnCleared { .. }]
164        ));
165        assert!(matches!(
166            diff(&snap(warn), &snap(fail()))[..],
167            [Event::BreachStarted { .. }, Event::WarnCleared { .. }]
168        ));
169    }
170}