use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
pub const DEFAULT_MIN_VISITS: u32 = 5;
pub const DEFAULT_MIN_ELAPSED: Duration = Duration::from_secs(10 * 60);
pub const DEFAULT_MIN_DEPTH: u32 = 2;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct WarmupPolicy {
pub min_visits: u32,
pub min_depth: u32,
pub min_elapsed_secs: u64,
}
impl Default for WarmupPolicy {
fn default() -> Self {
Self {
min_visits: DEFAULT_MIN_VISITS,
min_depth: DEFAULT_MIN_DEPTH,
min_elapsed_secs: DEFAULT_MIN_ELAPSED.as_secs(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "phase", rename_all = "snake_case")]
pub enum WarmupPhase {
#[default]
Cold,
Warming {
urls_visited: u32,
max_depth_reached: u32,
elapsed_secs: u64,
},
Warm,
}
#[derive(Debug, Clone)]
pub struct SessionWarmup {
policy: WarmupPolicy,
started_at: Option<Instant>,
urls_visited: u32,
max_depth_reached: u32,
forced_warm: bool,
}
impl Default for SessionWarmup {
fn default() -> Self {
Self::new(WarmupPolicy::default())
}
}
impl SessionWarmup {
pub fn new(policy: WarmupPolicy) -> Self {
Self {
policy,
started_at: None,
urls_visited: 0,
max_depth_reached: 0,
forced_warm: false,
}
}
pub fn policy(&self) -> WarmupPolicy {
self.policy
}
pub fn record_visit(&mut self, depth: u32) {
if self.started_at.is_none() {
self.started_at = Some(Instant::now());
}
self.urls_visited = self.urls_visited.saturating_add(1);
if depth > self.max_depth_reached {
self.max_depth_reached = depth;
}
}
pub fn force_warm(&mut self) {
self.forced_warm = true;
}
fn elapsed(&self) -> Duration {
self.started_at
.map(|t| t.elapsed())
.unwrap_or(Duration::ZERO)
}
pub fn is_warm(&self) -> bool {
if self.forced_warm {
return true;
}
self.urls_visited >= self.policy.min_visits
&& self.max_depth_reached >= self.policy.min_depth
&& self.elapsed().as_secs() >= self.policy.min_elapsed_secs
}
pub fn phase(&self) -> WarmupPhase {
if self.is_warm() {
return WarmupPhase::Warm;
}
if self.started_at.is_none() {
return WarmupPhase::Cold;
}
WarmupPhase::Warming {
urls_visited: self.urls_visited,
max_depth_reached: self.max_depth_reached,
elapsed_secs: self.elapsed().as_secs(),
}
}
pub fn gate_login(&self) -> Result<(), &'static str> {
if self.is_warm() {
Ok(())
} else if self.started_at.is_none() {
Err("warmup:cold")
} else {
Err("warmup:insufficient")
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cold_session_blocks_login() {
let w = SessionWarmup::default();
assert!(matches!(w.phase(), WarmupPhase::Cold));
assert_eq!(w.gate_login(), Err("warmup:cold"));
}
#[test]
fn warming_session_blocks_login() {
let mut w = SessionWarmup::default();
w.record_visit(1);
w.record_visit(2);
assert!(matches!(w.phase(), WarmupPhase::Warming { .. }));
assert_eq!(w.gate_login(), Err("warmup:insufficient"));
}
#[test]
fn force_warm_opens_gate() {
let mut w = SessionWarmup::default();
w.force_warm();
assert!(w.is_warm());
assert_eq!(w.gate_login(), Ok(()));
}
#[test]
fn depth_requirement_matters() {
let policy = WarmupPolicy {
min_visits: 3,
min_depth: 2,
min_elapsed_secs: 0,
};
let mut w = SessionWarmup::new(policy);
w.record_visit(1);
w.record_visit(1);
w.record_visit(1);
assert!(!w.is_warm());
w.record_visit(2);
assert!(w.is_warm());
}
}