From e4458c05b3a46f2cfbbd78c9df34595cc66cae2d Mon Sep 17 00:00:00 2001 From: JeremyLARDENOIS Date: Sun, 10 Aug 2025 21:26:23 +0200 Subject: [PATCH] wip --- Cargo.lock | 148 ++++++++ Cargo.toml | 1 + src/actors/driving/cron_alerts.rs | 2 +- src/adapters/driving/for_monitoring_alerts.rs | 2 +- src/app/ports/driving.rs | 2 +- src/app/services/alert_service.rs | 316 +++++++++++++++++- 6 files changed, 452 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 23e124e..b6f2c76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.20" @@ -310,6 +319,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -322,6 +342,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" @@ -330,6 +356,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -367,6 +394,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "h2" version = "0.4.12" @@ -789,6 +822,7 @@ dependencies = [ "discord_client", "dotenvy", "mockall", + "rstest", "system_monitor", ] @@ -944,6 +978,15 @@ dependencies = [ "termtree", ] +[[package]] +name = "proc-macro-crate" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -968,6 +1011,41 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "reqwest" version = "0.12.22" @@ -1024,12 +1102,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rustc-demangle" version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.8" @@ -1120,6 +1236,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "serde" version = "1.0.219" @@ -1362,6 +1484,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.5.2" @@ -1878,6 +2017,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 67e8330..edd57b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,4 +11,5 @@ clap = { version = "4.5", features = ["derive"] } [dev-dependencies] mockall = "0.13.1" +rstest = "0.26.1" diff --git a/src/actors/driving/cron_alerts.rs b/src/actors/driving/cron_alerts.rs index 58c9fc1..991646b 100644 --- a/src/actors/driving/cron_alerts.rs +++ b/src/actors/driving/cron_alerts.rs @@ -10,7 +10,7 @@ impl CronAlertsActor { Self { alert_monitor, period } } - pub fn start(&self) { + pub fn start(&mut self) { loop { if let Err(e) = self.alert_monitor.alert_on_threshold_violation() { eprintln!("Error occurred: {}", e); diff --git a/src/adapters/driving/for_monitoring_alerts.rs b/src/adapters/driving/for_monitoring_alerts.rs index 4cdae47..6c24971 100644 --- a/src/adapters/driving/for_monitoring_alerts.rs +++ b/src/adapters/driving/for_monitoring_alerts.rs @@ -12,7 +12,7 @@ impl ForMonitoringAlertsAdapter { } impl ForMonitoringAlerts for ForMonitoringAlertsAdapter { - fn alert_on_threshold_violation(&self) -> Result<(), Box> { + fn alert_on_threshold_violation(&mut self) -> Result<(), Box> { self.app.alert_on_threshold_violation() } } diff --git a/src/app/ports/driving.rs b/src/app/ports/driving.rs index b6934a9..f392af4 100644 --- a/src/app/ports/driving.rs +++ b/src/app/ports/driving.rs @@ -1,3 +1,3 @@ pub trait ForMonitoringAlerts { - fn alert_on_threshold_violation(&self) -> Result<(), Box>; + fn alert_on_threshold_violation(&mut self) -> Result<(), Box>; } diff --git a/src/app/services/alert_service.rs b/src/app/services/alert_service.rs index 204d29f..6dc70e0 100644 --- a/src/app/services/alert_service.rs +++ b/src/app/services/alert_service.rs @@ -1,11 +1,31 @@ +use std::collections::HashMap; + use crate::app::ports::driven::{ ForFormattingMessage, ForMonitoringSystem, ForSendingNotification, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AlertLevel { + Resolved = 0, // No violation + Warning = 1, + Critical = 2, +} + +impl AlertLevel { + fn from_is_critical(is_critical: bool) -> Self { + if is_critical { + AlertLevel::Critical + } else { + AlertLevel::Warning + } + } +} + pub struct AlertService { notifier: Box, monitor: Box, formatter: Box, + highest_alert_levels: HashMap, } pub fn new( @@ -17,18 +37,54 @@ pub fn new( notifier, monitor, formatter, + highest_alert_levels: HashMap::new(), } } impl AlertService { - /// Check for threshold violations and send alerts - pub fn alert_on_threshold_violation(&self) -> Result<(), Box> { + /// Check for threshold violations and send alerts on level changes (escalation, improvement, resolution) + pub fn alert_on_threshold_violation(&mut self) -> Result<(), Box> { let violations = self.monitor.check_thresholds(); - for violation in violations { - let message = self.formatter.format_violation(violation); - self.notifier.send_notification(&message)?; + + // Build a map of current violation levels + let mut current_levels: HashMap = HashMap::new(); + for violation in &violations { + let metric_name = violation.get_metric_name(); + let level = AlertLevel::from_is_critical(violation.is_critical()); + current_levels.insert(metric_name, level); } + // Check for metrics that had violations but now don't (resolution) + for (metric_name, &previous_level) in &self.highest_alert_levels { + if !current_levels.contains_key(metric_name) { + // This metric is now resolved + let message = format!("✅ {} has been resolved (was {:?})", metric_name, previous_level); + self.notifier.send_notification(&message)?; + } + } + + // Process current violations + for violation in violations { + let metric_name = violation.get_metric_name(); + let current_level = AlertLevel::from_is_critical(violation.is_critical()); + + let should_alert = match self.highest_alert_levels.get(&metric_name) { + None => true, // First time seeing this metric + Some(&previous_level) => current_level != previous_level, // Any level change + }; + + if should_alert { + let message = self.formatter.format_violation(violation); + self.notifier.send_notification(&message)?; + } + + // Update the level for this metric + self.highest_alert_levels.insert(metric_name, current_level); + } + + // Remove resolved metrics from our tracking + self.highest_alert_levels.retain(|metric_name, _| current_levels.contains_key(metric_name)); + Ok(()) } } @@ -37,30 +93,70 @@ impl AlertService { #[cfg(test)] mod tests { use std::collections::HashMap; + use rstest::rstest; use crate::app::ports::driven::{MockForFormattingMessage, MockForMonitoringSystem, MockForSendingNotification}; use super::*; - #[test] - fn test_alert_on_threshold_violation() { + #[rstest] + #[case(vec![], 0, true, "no violations")] + #[case(vec![true], 1, true, "single critical violation")] + #[case(vec![false], 1, true, "single warning violation")] + fn test_alert_on_threshold_violation_scenarios( + #[case] violations_criticality: Vec, + #[case] expected_notifications: usize, + #[case] should_succeed: bool, + #[case] _description: &str, + ) { let mut notifier = MockForSendingNotification::new(); let mut monitor = MockForMonitoringSystem::new(); let mut formatter = MockForFormattingMessage::new(); - notifier.expect_send_notification().returning(|_| Ok(())); - monitor.expect_check_thresholds().returning(|| vec![]); - monitor.expect_get_metrics().returning(|| HashMap::new()); - formatter.expect_format_violation().returning(|_| "".to_string()); - formatter.expect_format_summary().returning(|_| "".to_string()); + // Setup monitor expectations + monitor.expect_check_thresholds().returning(move || { + violations_criticality.iter().enumerate().map(|(i, &is_critical)| { + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const(format!("metric_{}", i)); + violation.expect_is_critical().return_const(is_critical); + Box::new(violation) as Box + }).collect() + }); - let service = new( + monitor.expect_get_metrics().returning(|| HashMap::new()); + + if expected_notifications > 0 { + formatter.expect_format_violation() + .times(expected_notifications) + .returning(|_| "Violation message".to_string()); + + formatter.expect_format_summary().returning(|_| "Summary".to_string()); + + notifier.expect_send_notification() + .times(expected_notifications) + .returning(move |_| { + if should_succeed { + Ok(()) + } else { + Err("Network error".into()) + } + }); + } else { + formatter.expect_format_summary().returning(|_| "Summary".to_string()); + } + + let mut service = new( Box::new(notifier), Box::new(monitor), Box::new(formatter), ); - assert!(service.alert_on_threshold_violation().is_ok()); + let result = service.alert_on_threshold_violation(); + if should_succeed { + assert!(result.is_ok()); + } else { + assert!(result.is_err()); + } } #[test] @@ -70,14 +166,17 @@ mod tests { let mut formatter = MockForFormattingMessage::new(); monitor.expect_check_thresholds().returning(|| { - vec![Box::new(crate::app::ports::driven::MockForGettingViolationData::new())] + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const("cpu_usage".to_string()); + violation.expect_is_critical().return_const(true); + vec![Box::new(violation)] }); formatter.expect_format_violation().returning(|_| "Test violation".to_string()); notifier.expect_send_notification() .returning(|_| Err("Network error".into())); - let service = new( + let mut service = new( Box::new(notifier), Box::new(monitor), Box::new(formatter), @@ -85,4 +184,189 @@ mod tests { assert!(service.alert_on_threshold_violation().is_err()); } + + #[test] + fn test_multiple_different_metrics() { + let mut notifier = MockForSendingNotification::new(); + let mut monitor = MockForMonitoringSystem::new(); + let mut formatter = MockForFormattingMessage::new(); + + // Different metrics should all be sent + monitor.expect_check_thresholds().returning(|| { + vec![ + { + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const("cpu_usage".to_string()); + violation.expect_is_critical().return_const(true); + Box::new(violation) as Box + }, + { + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const("memory_usage".to_string()); + violation.expect_is_critical().return_const(false); + Box::new(violation) as Box + } + ] + }); + + formatter.expect_format_violation() + .times(2) // Both should be sent + .returning(|_| "Violation message".to_string()); + + notifier.expect_send_notification() + .times(2) // Both should be sent + .returning(|_| Ok(())); + + let mut service = new( + Box::new(notifier), + Box::new(monitor), + Box::new(formatter), + ); + + assert!(service.alert_on_threshold_violation().is_ok()); + } + + #[test] + fn test_alert_resolution() { + let mut notifier = MockForSendingNotification::new(); + let mut monitor = MockForMonitoringSystem::new(); + let mut formatter = MockForFormattingMessage::new(); + + // First call: critical violation + // Second call: no violations (resolution) + monitor.expect_check_thresholds() + .times(2) + .returning_st({ + let mut call_count = 0; + move || { + call_count += 1; + if call_count == 1 { + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const("cpu_usage".to_string()); + violation.expect_is_critical().return_const(true); + vec![Box::new(violation)] + } else { + vec![] // No violations - resolved + } + } + }); + + formatter.expect_format_violation() + .times(1) // Only for the initial violation + .returning(|_| "CPU usage is critical".to_string()); + + // Two notifications: initial critical + resolution + notifier.expect_send_notification() + .times(2) + .withf(|msg| { + msg.contains("CPU usage is critical") || msg.contains("cpu_usage has been resolved") + }) + .returning(|_| Ok(())); + + let mut service = new( + Box::new(notifier), + Box::new(monitor), + Box::new(formatter), + ); + + // First call: critical should be sent + assert!(service.alert_on_threshold_violation().is_ok()); + + // Second call: resolution should be sent + assert!(service.alert_on_threshold_violation().is_ok()); + } + + #[rstest] + #[case(true, true, 1, "same critical level twice")] + #[case(false, false, 1, "same warning level twice")] + fn test_same_level_alerts_are_not_sent_twice( + #[case] is_critical: bool, + #[case] _second_is_critical: bool, // Same as first for these test cases + #[case] expected_notifications: usize, + #[case] _description: &str, + ) { + let mut notifier = MockForSendingNotification::new(); + let mut monitor = MockForMonitoringSystem::new(); + let mut formatter = MockForFormattingMessage::new(); + + // Monitor should return the same violation twice + monitor.expect_check_thresholds() + .times(2) + .returning(move || { + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const("cpu_usage".to_string()); + violation.expect_is_critical().return_const(is_critical); + vec![Box::new(violation)] + }); + + formatter.expect_format_violation() + .times(expected_notifications) + .returning(|_| "CPU usage violation".to_string()); + + notifier.expect_send_notification() + .times(expected_notifications) + .returning(|_| Ok(())); + + let mut service = new( + Box::new(notifier), + Box::new(monitor), + Box::new(formatter), + ); + + assert!(service.alert_on_threshold_violation().is_ok()); + + assert!(service.alert_on_threshold_violation().is_ok()); + } + + #[rstest] + #[case(false, true, 2, "escalation from warning to critical")] + #[case(true, false, 2, "improvement from critical to warning")] // Now sends alert + #[case(false, false, 1, "warning then warning again")] + #[case(true, true, 1, "critical then critical again")] + fn test_alert_level_changes( + #[case] first_is_critical: bool, + #[case] second_is_critical: bool, + #[case] expected_notifications: usize, + #[case] _description: &str, + ) { + let mut notifier = MockForSendingNotification::new(); + let mut monitor = MockForMonitoringSystem::new(); + let mut formatter = MockForFormattingMessage::new(); + + // Monitor returns different levels on consecutive calls + monitor.expect_check_thresholds() + .times(2) + .returning_st({ + let mut call_count = 0; + move || { + call_count += 1; + let mut violation = crate::app::ports::driven::MockForGettingViolationData::new(); + violation.expect_get_metric_name().return_const("cpu_usage".to_string()); + if call_count == 1 { + violation.expect_is_critical().return_const(first_is_critical); + } else { + violation.expect_is_critical().return_const(second_is_critical); + } + vec![Box::new(violation)] + } + }); + + formatter.expect_format_violation() + .times(expected_notifications) + .returning(|_| "CPU usage violation".to_string()); + + notifier.expect_send_notification() + .times(expected_notifications) + .returning(|_| Ok(())); + + let mut service = new( + Box::new(notifier), + Box::new(monitor), + Box::new(formatter), + ); + + assert!(service.alert_on_threshold_violation().is_ok()); + + assert!(service.alert_on_threshold_violation().is_ok()); + } }