1use serde::Serialize;
2
3#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
5#[serde(rename_all = "lowercase")]
6pub enum Severity {
7 Error,
9 Warning,
11 Info,
13}
14
15#[derive(Debug, Serialize, Clone, PartialEq)]
17#[serde(rename_all = "lowercase")]
18pub enum Escalation {
19 Notice,
21 Attention,
23 Intervene,
25}
26
27impl Escalation {
28 pub fn label(&self) -> &'static str {
30 match self {
31 Escalation::Notice => "NOTICE",
32 Escalation::Attention => "ATTENTION",
33 Escalation::Intervene => "INTERVENE",
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq)]
40pub enum AnomalyRule {
41 LowOutput,
43 LongRunning,
45 TransientReadError,
47 DuplicateRunner,
49 OverlappingCycles,
51 OverlappingSteps,
53 MissingStepEnd,
55 EmptyCycle,
57 OrphanCommand,
59 NonzeroExit,
61 UnexpandedTemplateVar,
63 DegenerateLoop,
65 SandboxDenied,
67 IncarnationBoundary,
69}
70
71impl AnomalyRule {
72 pub fn canonical_name(&self) -> &'static str {
74 match self {
75 AnomalyRule::LowOutput => "low_output",
76 AnomalyRule::LongRunning => "long_running",
77 AnomalyRule::TransientReadError => "transient_read_error",
78 AnomalyRule::DuplicateRunner => "duplicate_runner",
79 AnomalyRule::OverlappingCycles => "overlapping_cycles",
80 AnomalyRule::OverlappingSteps => "overlapping_steps",
81 AnomalyRule::MissingStepEnd => "missing_step_end",
82 AnomalyRule::EmptyCycle => "empty_cycle",
83 AnomalyRule::OrphanCommand => "orphan_command",
84 AnomalyRule::NonzeroExit => "nonzero_exit",
85 AnomalyRule::UnexpandedTemplateVar => "unexpanded_template_var",
86 AnomalyRule::DegenerateLoop => "degenerate_loop",
87 AnomalyRule::SandboxDenied => "sandbox_denied",
88 AnomalyRule::IncarnationBoundary => "incarnation_boundary",
89 }
90 }
91
92 pub fn default_severity(&self) -> Severity {
94 match self {
95 AnomalyRule::DuplicateRunner
96 | AnomalyRule::OverlappingCycles
97 | AnomalyRule::OverlappingSteps
98 | AnomalyRule::DegenerateLoop
99 | AnomalyRule::SandboxDenied => Severity::Error,
100
101 AnomalyRule::LowOutput
102 | AnomalyRule::MissingStepEnd
103 | AnomalyRule::OrphanCommand
104 | AnomalyRule::NonzeroExit
105 | AnomalyRule::UnexpandedTemplateVar
106 | AnomalyRule::TransientReadError
107 | AnomalyRule::IncarnationBoundary => Severity::Warning,
108
109 AnomalyRule::LongRunning | AnomalyRule::EmptyCycle => Severity::Info,
110 }
111 }
112
113 pub fn escalation(&self) -> Escalation {
115 match self {
116 AnomalyRule::LowOutput
117 | AnomalyRule::DuplicateRunner
118 | AnomalyRule::OverlappingCycles
119 | AnomalyRule::OverlappingSteps
120 | AnomalyRule::DegenerateLoop
121 | AnomalyRule::SandboxDenied => Escalation::Intervene,
122
123 AnomalyRule::NonzeroExit
124 | AnomalyRule::OrphanCommand
125 | AnomalyRule::MissingStepEnd
126 | AnomalyRule::UnexpandedTemplateVar
127 | AnomalyRule::TransientReadError
128 | AnomalyRule::IncarnationBoundary => Escalation::Attention,
129
130 AnomalyRule::LongRunning | AnomalyRule::EmptyCycle => Escalation::Notice,
131 }
132 }
133
134 pub fn display_tag(&self) -> &'static str {
136 match self {
137 AnomalyRule::LowOutput => "LOW_OUTPUT",
138 AnomalyRule::LongRunning => "LONG_RUNNING",
139 AnomalyRule::TransientReadError => "TRANSIENT_READ_ERROR",
140 AnomalyRule::DuplicateRunner => "DUPLICATE_RUNNER",
141 AnomalyRule::OverlappingCycles => "OVERLAPPING_CYCLES",
142 AnomalyRule::OverlappingSteps => "OVERLAPPING_STEPS",
143 AnomalyRule::MissingStepEnd => "MISSING_STEP_END",
144 AnomalyRule::EmptyCycle => "EMPTY_CYCLE",
145 AnomalyRule::OrphanCommand => "ORPHAN_COMMAND",
146 AnomalyRule::NonzeroExit => "NONZERO_EXIT",
147 AnomalyRule::UnexpandedTemplateVar => "UNEXPANDED_TEMPLATE_VAR",
148 AnomalyRule::DegenerateLoop => "DEGENERATE_LOOP",
149 AnomalyRule::SandboxDenied => "SANDBOX_DENIED",
150 AnomalyRule::IncarnationBoundary => "INCARNATION_BOUNDARY",
151 }
152 }
153
154 pub fn from_canonical(name: &str) -> Option<AnomalyRule> {
156 match name {
157 "low_output" => Some(AnomalyRule::LowOutput),
158 "long_running" => Some(AnomalyRule::LongRunning),
159 "transient_read_error" => Some(AnomalyRule::TransientReadError),
160 "duplicate_runner" => Some(AnomalyRule::DuplicateRunner),
161 "overlapping_cycles" => Some(AnomalyRule::OverlappingCycles),
162 "overlapping_steps" => Some(AnomalyRule::OverlappingSteps),
163 "missing_step_end" => Some(AnomalyRule::MissingStepEnd),
164 "empty_cycle" => Some(AnomalyRule::EmptyCycle),
165 "orphan_command" => Some(AnomalyRule::OrphanCommand),
166 "nonzero_exit" => Some(AnomalyRule::NonzeroExit),
167 "unexpanded_template_var" => Some(AnomalyRule::UnexpandedTemplateVar),
168 "degenerate_loop" => Some(AnomalyRule::DegenerateLoop),
169 "sandbox_denied" => Some(AnomalyRule::SandboxDenied),
170 "incarnation_boundary" => Some(AnomalyRule::IncarnationBoundary),
171 _ => None,
172 }
173 }
174}
175
176#[derive(Debug, Serialize, Clone)]
178pub struct Anomaly {
179 pub rule: String,
181 pub severity: Severity,
183 pub escalation: Escalation,
185 pub message: String,
187 pub at: Option<String>,
189}
190
191impl Anomaly {
192 pub fn new(rule: AnomalyRule, message: String, at: Option<String>) -> Self {
194 Anomaly {
195 severity: rule.default_severity(),
196 escalation: rule.escalation(),
197 rule: rule.canonical_name().to_string(),
198 message,
199 at,
200 }
201 }
202}
203
204#[cfg(test)]
207mod tests {
208 use super::*;
209
210 const ALL_RULES: &[AnomalyRule] = &[
211 AnomalyRule::LowOutput,
212 AnomalyRule::LongRunning,
213 AnomalyRule::TransientReadError,
214 AnomalyRule::DuplicateRunner,
215 AnomalyRule::OverlappingCycles,
216 AnomalyRule::OverlappingSteps,
217 AnomalyRule::MissingStepEnd,
218 AnomalyRule::EmptyCycle,
219 AnomalyRule::OrphanCommand,
220 AnomalyRule::NonzeroExit,
221 AnomalyRule::UnexpandedTemplateVar,
222 AnomalyRule::DegenerateLoop,
223 AnomalyRule::SandboxDenied,
224 AnomalyRule::IncarnationBoundary,
225 ];
226
227 #[test]
228 fn canonical_name_roundtrip() {
229 for rule in ALL_RULES {
230 let name = rule.canonical_name();
231 let parsed = AnomalyRule::from_canonical(name);
232 assert_eq!(
233 parsed.as_ref(),
234 Some(rule),
235 "roundtrip failed for {:?}",
236 rule
237 );
238 }
239 }
240
241 #[test]
242 fn severity_mapping() {
243 assert_eq!(
244 AnomalyRule::DuplicateRunner.default_severity(),
245 Severity::Error
246 );
247 assert_eq!(
248 AnomalyRule::OverlappingCycles.default_severity(),
249 Severity::Error
250 );
251 assert_eq!(
252 AnomalyRule::OverlappingSteps.default_severity(),
253 Severity::Error
254 );
255 assert_eq!(
256 AnomalyRule::DegenerateLoop.default_severity(),
257 Severity::Error
258 );
259 assert_eq!(
260 AnomalyRule::SandboxDenied.default_severity(),
261 Severity::Error
262 );
263
264 assert_eq!(AnomalyRule::LowOutput.default_severity(), Severity::Warning);
265 assert_eq!(
266 AnomalyRule::NonzeroExit.default_severity(),
267 Severity::Warning
268 );
269 assert_eq!(
270 AnomalyRule::MissingStepEnd.default_severity(),
271 Severity::Warning
272 );
273 assert_eq!(
274 AnomalyRule::OrphanCommand.default_severity(),
275 Severity::Warning
276 );
277 assert_eq!(
278 AnomalyRule::UnexpandedTemplateVar.default_severity(),
279 Severity::Warning
280 );
281 assert_eq!(
282 AnomalyRule::TransientReadError.default_severity(),
283 Severity::Warning
284 );
285
286 assert_eq!(AnomalyRule::LongRunning.default_severity(), Severity::Info);
287 assert_eq!(AnomalyRule::EmptyCycle.default_severity(), Severity::Info);
288 }
289
290 #[test]
291 fn escalation_mapping() {
292 assert_eq!(AnomalyRule::LowOutput.escalation(), Escalation::Intervene);
293 assert_eq!(
294 AnomalyRule::DuplicateRunner.escalation(),
295 Escalation::Intervene
296 );
297 assert_eq!(
298 AnomalyRule::OverlappingCycles.escalation(),
299 Escalation::Intervene
300 );
301 assert_eq!(
302 AnomalyRule::OverlappingSteps.escalation(),
303 Escalation::Intervene
304 );
305 assert_eq!(
306 AnomalyRule::DegenerateLoop.escalation(),
307 Escalation::Intervene
308 );
309 assert_eq!(
310 AnomalyRule::SandboxDenied.escalation(),
311 Escalation::Intervene
312 );
313
314 assert_eq!(AnomalyRule::NonzeroExit.escalation(), Escalation::Attention);
315 assert_eq!(
316 AnomalyRule::OrphanCommand.escalation(),
317 Escalation::Attention
318 );
319 assert_eq!(
320 AnomalyRule::MissingStepEnd.escalation(),
321 Escalation::Attention
322 );
323 assert_eq!(
324 AnomalyRule::UnexpandedTemplateVar.escalation(),
325 Escalation::Attention
326 );
327 assert_eq!(
328 AnomalyRule::TransientReadError.escalation(),
329 Escalation::Attention
330 );
331
332 assert_eq!(AnomalyRule::LongRunning.escalation(), Escalation::Notice);
333 assert_eq!(AnomalyRule::EmptyCycle.escalation(), Escalation::Notice);
334 }
335
336 #[test]
337 fn display_tag_non_empty() {
338 for rule in ALL_RULES {
339 assert!(!rule.display_tag().is_empty(), "empty tag for {:?}", rule);
340 }
341 }
342
343 #[test]
344 fn anomaly_new_sets_defaults() {
345 let a = Anomaly::new(
346 AnomalyRule::LowOutput,
347 "test message".to_string(),
348 Some("2025-01-01".to_string()),
349 );
350 assert_eq!(a.rule, "low_output");
351 assert_eq!(a.severity, Severity::Warning);
352 assert_eq!(a.escalation, Escalation::Intervene);
353 assert_eq!(a.message, "test message");
354 assert_eq!(a.at.as_deref(), Some("2025-01-01"));
355 }
356
357 #[test]
358 fn anomaly_serialization_includes_escalation() {
359 let a = Anomaly::new(AnomalyRule::DuplicateRunner, "dup".to_string(), None);
360 let json = serde_json::to_value(&a).expect("anomaly should serialize");
361 assert_eq!(json["escalation"], "intervene");
362 assert_eq!(json["severity"], "error");
363 assert_eq!(json["rule"], "duplicate_runner");
364 }
365
366 #[test]
367 fn from_canonical_returns_none_for_unknown() {
368 assert_eq!(AnomalyRule::from_canonical("bogus_rule"), None);
369 }
370}