use crate::monitor::inventory::MonitoredDomain;
use chrono::{DateTime, Duration, Utc};
use rand::Rng;
use std::collections::HashMap;
pub struct SchedulingEngine {
next_scan_times: HashMap<String, DateTime<Utc>>,
jitter_percent: u8,
}
impl SchedulingEngine {
pub fn new() -> Self {
Self {
next_scan_times: HashMap::new(),
jitter_percent: 10, }
}
pub fn with_jitter(mut self, jitter_percent: u8) -> Self {
self.jitter_percent = jitter_percent.min(50); self
}
pub fn get_domains_to_scan<'a>(
&mut self,
domains: &'a [MonitoredDomain],
) -> Vec<&'a MonitoredDomain> {
let now = Utc::now();
let mut due_domains = Vec::new();
for domain in domains {
let identifier = domain.identifier();
let is_due = if let Some(next_scan) = self.next_scan_times.get(&identifier) {
now >= *next_scan
} else {
true
};
if is_due {
due_domains.push(domain);
self.schedule_next_scan_internal(&identifier, domain.interval_seconds);
}
}
due_domains
}
pub fn schedule_next_scan(&mut self, hostname: &str, interval_seconds: u64) {
self.schedule_next_scan_internal(hostname, interval_seconds);
}
fn schedule_next_scan_internal(&mut self, identifier: &str, interval_seconds: u64) {
let interval_with_jitter = self.add_jitter(Duration::seconds(interval_seconds as i64));
let next_scan = Utc::now() + interval_with_jitter;
self.next_scan_times
.insert(identifier.to_string(), next_scan);
}
fn add_jitter(&self, duration: Duration) -> Duration {
let mut rng = rand::thread_rng();
let seconds = duration.num_seconds();
let jitter_range = (seconds * self.jitter_percent as i64) / 100;
let jitter = rng.gen_range(-jitter_range..=jitter_range);
Duration::seconds(seconds + jitter)
}
pub fn next_scan_time(&self, identifier: &str) -> Option<DateTime<Utc>> {
self.next_scan_times.get(identifier).copied()
}
pub fn time_until_next_scan(&self, identifier: &str) -> Option<Duration> {
self.next_scan_time(identifier)
.map(|next| next - Utc::now())
}
pub fn all_scheduled(&self) -> &HashMap<String, DateTime<Utc>> {
&self.next_scan_times
}
pub fn clear_schedule(&mut self, identifier: &str) {
self.next_scan_times.remove(identifier);
}
pub fn clear_all(&mut self) {
self.next_scan_times.clear();
}
pub fn scheduled_count(&self) -> usize {
self.next_scan_times.len()
}
pub fn schedule_immediate(&mut self, identifier: &str) {
self.next_scan_times
.insert(identifier.to_string(), Utc::now());
}
pub fn domains_due_within(&self, seconds: i64) -> Vec<String> {
let threshold = Utc::now() + Duration::seconds(seconds);
self.next_scan_times
.iter()
.filter(|&(_, time)| time <= &threshold)
.map(|(id, _)| id.clone())
.collect()
}
}
impl Default for SchedulingEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monitor::types::AlertThresholds;
fn create_test_domain(hostname: &str, interval_seconds: u64) -> MonitoredDomain {
MonitoredDomain {
hostname: hostname.to_string(),
port: 443,
enabled: true,
interval_seconds,
alert_thresholds: AlertThresholds::default(),
last_scan: None,
last_certificate: None,
}
}
#[test]
fn test_new_scheduler() {
let scheduler = SchedulingEngine::new();
assert_eq!(scheduler.jitter_percent, 10);
assert_eq!(scheduler.scheduled_count(), 0);
}
#[test]
fn test_custom_jitter() {
let scheduler = SchedulingEngine::new().with_jitter(20);
assert_eq!(scheduler.jitter_percent, 20);
}
#[test]
fn test_jitter_cap() {
let scheduler = SchedulingEngine::new().with_jitter(100);
assert_eq!(scheduler.jitter_percent, 50); }
#[test]
fn test_first_scan_immediate() {
let mut scheduler = SchedulingEngine::new();
let domains = vec![create_test_domain("example.com", 3600)];
let due_domains = scheduler.get_domains_to_scan(&domains);
assert_eq!(due_domains.len(), 1);
assert_eq!(due_domains[0].hostname, "example.com");
assert_eq!(scheduler.scheduled_count(), 1);
}
#[test]
fn test_schedule_next_scan() {
let mut scheduler = SchedulingEngine::new();
scheduler.schedule_next_scan("example.com:443", 3600);
assert_eq!(scheduler.scheduled_count(), 1);
assert!(scheduler.next_scan_time("example.com:443").is_some());
}
#[test]
fn test_time_until_next_scan() {
let mut scheduler = SchedulingEngine::new();
scheduler.schedule_next_scan("example.com:443", 3600);
let time_until = scheduler.time_until_next_scan("example.com:443");
assert!(time_until.is_some());
let duration = time_until.expect("test assertion should succeed");
assert!(duration.num_seconds() > 3200);
assert!(duration.num_seconds() < 4000);
}
#[test]
fn test_clear_schedule() {
let mut scheduler = SchedulingEngine::new();
scheduler.schedule_next_scan("example.com:443", 3600);
assert_eq!(scheduler.scheduled_count(), 1);
scheduler.clear_schedule("example.com:443");
assert_eq!(scheduler.scheduled_count(), 0);
}
#[test]
fn test_clear_all() {
let mut scheduler = SchedulingEngine::new();
scheduler.schedule_next_scan("example.com:443", 3600);
scheduler.schedule_next_scan("test.com:443", 1800);
assert_eq!(scheduler.scheduled_count(), 2);
scheduler.clear_all();
assert_eq!(scheduler.scheduled_count(), 0);
}
#[test]
fn test_schedule_immediate() {
let mut scheduler = SchedulingEngine::new();
scheduler.schedule_immediate("example.com:443");
let next_scan = scheduler
.next_scan_time("example.com:443")
.expect("test assertion should succeed");
let now = Utc::now();
assert!((next_scan - now).num_seconds().abs() < 1);
}
#[test]
fn test_domains_due_within() {
let mut scheduler = SchedulingEngine::new();
scheduler.schedule_immediate("immediate.com:443");
scheduler.schedule_next_scan("future.com:443", 36000);
let due_soon = scheduler.domains_due_within(60);
assert_eq!(due_soon.len(), 1);
assert!(due_soon.contains(&"immediate.com:443".to_string()));
}
#[test]
fn test_jitter_variation() {
let scheduler = SchedulingEngine::new();
let base_duration = Duration::seconds(3600);
let mut results = std::collections::HashSet::new();
for _ in 0..10 {
let jittered = scheduler.add_jitter(base_duration);
results.insert(jittered.num_seconds());
}
assert!(results.len() >= 2);
for &seconds in &results {
assert!(seconds >= 3240);
assert!(seconds <= 3960);
}
}
#[test]
fn test_multiple_domains_scheduling() {
let mut scheduler = SchedulingEngine::new();
let domains = vec![
create_test_domain("example1.com", 1800),
create_test_domain("example2.com", 3600),
create_test_domain("example3.com", 7200),
];
let due = scheduler.get_domains_to_scan(&domains);
assert_eq!(due.len(), 3);
assert_eq!(scheduler.scheduled_count(), 3);
let due_again = scheduler.get_domains_to_scan(&domains);
assert_eq!(due_again.len(), 0);
}
}