use std::collections::VecDeque;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Prompt {
pub key: String,
pub summary: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AttentionEvidence {
pub coalesced: usize,
pub interruptions_spent: u32,
pub reason: &'static str,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AttentionDecision {
Present(Prompt, AttentionEvidence),
ContinueManually(AttentionEvidence),
Silent(AttentionEvidence),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct AttentionPolicy {
pub quiet_start_hour: u8,
pub quiet_end_hour: u8,
pub max_interruptions: u32,
}
#[derive(Debug)]
pub struct AttentionProjector {
policy: AttentionPolicy,
active: Option<Prompt>,
pending: VecDeque<Prompt>,
interruptions: u32,
}
impl AttentionProjector {
pub fn new(policy: AttentionPolicy) -> Self {
Self {
policy,
active: None,
pending: VecDeque::new(),
interruptions: 0,
}
}
pub fn offer(&mut self, prompt: Prompt, hour: u8, online: bool) -> AttentionDecision {
if !online {
return self.silent("offline-or-dropped");
}
if self.is_quiet(hour) {
self.coalesce(prompt);
return self.silent("quiet-hours");
}
if self.interruptions >= self.policy.max_interruptions {
self.coalesce(prompt);
return AttentionDecision::ContinueManually(self.evidence("interruption-budget-spent"));
}
if self.active.is_some() {
if self
.active
.as_ref()
.is_some_and(|active| active.key == prompt.key)
{
self.active.as_mut().expect("active checked above").summary = prompt.summary;
} else {
self.coalesce(prompt);
}
return AttentionDecision::Present(
self.active.clone().expect("active checked above"),
self.evidence("one-active-prompt"),
);
}
self.interruptions += 1;
self.active = Some(prompt.clone());
AttentionDecision::Present(prompt, self.evidence("admitted"))
}
pub fn acknowledge(&mut self) {
self.active = None;
}
fn coalesce(&mut self, prompt: Prompt) {
if let Some(existing) = self.pending.iter_mut().find(|p| p.key == prompt.key) {
existing.summary = prompt.summary;
} else {
self.pending.push_back(prompt);
}
}
fn is_quiet(&self, hour: u8) -> bool {
let start = self.policy.quiet_start_hour;
let end = self.policy.quiet_end_hour;
if start <= end {
hour >= start && hour < end
} else {
hour >= start || hour < end
}
}
fn evidence(&self, reason: &'static str) -> AttentionEvidence {
AttentionEvidence {
coalesced: self.pending.len(),
interruptions_spent: self.interruptions,
reason,
}
}
fn silent(&self, reason: &'static str) -> AttentionDecision {
AttentionDecision::Silent(self.evidence(reason))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn projector(max: u32) -> AttentionProjector {
AttentionProjector::new(AttentionPolicy {
quiet_start_hour: 22,
quiet_end_hour: 7,
max_interruptions: max,
})
}
#[test]
fn burst_never_exceeds_one_body_level_prompt() {
let mut p = projector(2);
for n in 0..50 {
let decision = p.offer(
Prompt {
key: format!("job-{}", n % 3),
summary: format!("update-{n}"),
},
12,
true,
);
let AttentionDecision::Present(_, evidence) = decision else {
panic!("active prompt must remain visible")
};
assert!(evidence.interruptions_spent <= 1);
}
}
#[test]
fn silence_offline_quiet_and_manual_continuation_are_normal() {
let prompt = Prompt {
key: "mission".into(),
summary: "ready".into(),
};
let mut p = projector(1);
assert!(matches!(
p.offer(prompt.clone(), 12, false),
AttentionDecision::Silent(_)
));
assert!(matches!(
p.offer(prompt.clone(), 23, true),
AttentionDecision::Silent(_)
));
assert!(matches!(
p.offer(prompt.clone(), 12, true),
AttentionDecision::Present(_, _)
));
p.acknowledge();
assert!(matches!(
p.offer(prompt, 12, true),
AttentionDecision::ContinueManually(_)
));
}
}