use crate::frontdoor::{self, Frontdoor};
use crate::harness::HarnessStore;
use crate::learning::LearningStore;
use crate::outbox::OutboxStore;
use crate::questions::QuestionStore;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Depth {
pub waiting: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oldest: Option<String>,
}
impl Depth {
fn of<'a>(waiting: usize, stamps: impl IntoIterator<Item = &'a str>) -> Depth {
Depth {
waiting,
oldest: stamps.into_iter().min().map(str::to_string),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Backlog {
pub outbox: Option<Depth>,
pub questions: Option<Depth>,
pub frontdoor: Option<Depth>,
pub proposals: Option<Depth>,
pub candidates: Option<Depth>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Waiting {
pub total: usize,
pub unreadable: usize,
}
impl Backlog {
pub fn read() -> Backlog {
Backlog {
outbox: Self::read_outbox(),
questions: Self::read_questions(),
frontdoor: Self::read_frontdoor(),
proposals: Self::read_proposals(),
candidates: Self::read_candidates(),
}
}
fn read_outbox() -> Option<Depth> {
let store = OutboxStore::default_root()
.and_then(OutboxStore::open)
.ok()?;
let items = store.items().ok()?;
let pending: Vec<_> = items.iter().filter(|i| i.status == "pending").collect();
Some(Depth::of(
pending.len(),
pending.iter().map(|i| i.created_at.as_str()),
))
}
fn read_questions() -> Option<Depth> {
let Some(store) = QuestionStore::open_existing_default() else {
return Some(Depth::default());
};
let items = store.items().ok()?;
let open: Vec<_> = items.iter().filter(|q| q.is_open()).collect();
Some(Depth::of(
open.len(),
open.iter().map(|q| q.asked_at.as_str()),
))
}
fn read_frontdoor() -> Option<Depth> {
let records = Frontdoor::open_default().and_then(|s| s.records()).ok()?;
let open: Vec<_> = records
.iter()
.filter(|r| r.state != frontdoor::CLOSED)
.collect();
Some(Depth::of(
open.len(),
open.iter().map(|r| r.created_at.as_str()),
))
}
fn read_proposals() -> Option<Depth> {
let store = LearningStore::default_root()
.and_then(LearningStore::open)
.ok()?;
let proposals = store.proposals().ok()?;
let pending: Vec<_> = proposals.iter().filter(|p| p.status == "pending").collect();
Some(Depth::of(
pending.len(),
pending.iter().map(|p| p.created_at.as_str()),
))
}
fn read_candidates() -> Option<Depth> {
let candidates = HarnessStore::open_default().and_then(|s| s.all()).ok()?;
let staged: Vec<_> = candidates.iter().filter(|c| c.pending()).collect();
Some(Depth::of(
staged.len(),
staged.iter().map(|c| c.created_at.as_str()),
))
}
fn depths(&self) -> [&Option<Depth>; 5] {
[
&self.outbox,
&self.questions,
&self.frontdoor,
&self.proposals,
&self.candidates,
]
}
pub fn waiting(&self) -> Waiting {
let mut out = Waiting::default();
for depth in self.depths() {
match depth {
Some(d) => out.total += d.waiting,
None => out.unreadable += 1,
}
}
out
}
pub fn oldest(&self) -> Option<&str> {
self.depths()
.into_iter()
.flatten()
.filter_map(|d| d.oldest.as_deref())
.min()
}
pub fn delta(before: &Backlog, after: &Backlog) -> BacklogDelta {
let d = |a: &Option<Depth>, b: &Option<Depth>| match (a, b) {
(Some(a), Some(b)) => Some(b.waiting as i64 - a.waiting as i64),
_ => None,
};
BacklogDelta {
outbox: d(&before.outbox, &after.outbox),
questions: d(&before.questions, &after.questions),
frontdoor: d(&before.frontdoor, &after.frontdoor),
proposals: d(&before.proposals, &after.proposals),
candidates: d(&before.candidates, &after.candidates),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BacklogDelta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outbox: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub questions: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frontdoor: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proposals: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub candidates: Option<i64>,
}
impl BacklogDelta {
pub fn net(&self) -> Option<i64> {
let seen: Vec<i64> = [
self.outbox,
self.questions,
self.frontdoor,
self.proposals,
self.candidates,
]
.into_iter()
.flatten()
.collect();
(!seen.is_empty()).then(|| seen.iter().sum())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn depth(waiting: usize, oldest: Option<&str>) -> Option<Depth> {
Some(Depth {
waiting,
oldest: oldest.map(str::to_string),
})
}
#[test]
fn an_unreadable_store_is_counted_as_unread_and_never_as_empty() {
let b = Backlog {
outbox: depth(3, Some("2026-08-20T09:00:00Z")),
questions: None, frontdoor: depth(0, None),
proposals: depth(1, Some("2026-08-25T09:00:00Z")),
candidates: None, };
assert_eq!(
b.waiting(),
Waiting {
total: 4,
unreadable: 2
},
"the total is what was readable, and says so"
);
}
#[test]
fn a_store_with_nothing_waiting_has_no_oldest_age() {
let empty = Depth::of(0, Vec::<&str>::new());
assert_eq!(empty.waiting, 0);
assert_eq!(empty.oldest, None, "an absent age, never a zero one");
}
#[test]
fn the_oldest_wait_is_the_earliest_stamp_across_every_store() {
let b = Backlog {
outbox: depth(2, Some("2026-08-25T09:00:00Z")),
questions: depth(1, Some("2026-08-17T09:00:00Z")),
frontdoor: depth(0, None),
proposals: None,
candidates: depth(1, Some("2026-08-26T09:00:00Z")),
};
assert_eq!(b.oldest(), Some("2026-08-17T09:00:00Z"));
assert_eq!(Backlog::default().oldest(), None);
}
#[test]
fn a_delta_reports_what_this_run_added_rather_than_what_it_found() {
let before = Backlog {
outbox: depth(2, None),
questions: depth(1, None),
frontdoor: depth(4, None),
proposals: None,
candidates: depth(0, None),
};
let after = Backlog {
outbox: depth(11, None), questions: depth(0, None), frontdoor: depth(4, None),
proposals: depth(2, None), candidates: None, };
let d = Backlog::delta(&before, &after);
assert_eq!(d.outbox, Some(9));
assert_eq!(d.questions, Some(-1));
assert_eq!(d.frontdoor, Some(0), "readable and genuinely unchanged");
assert_eq!(d.proposals, None, "a delta against an unknown is not zero");
assert_eq!(d.candidates, None);
assert_eq!(d.net(), Some(8));
}
#[test]
fn a_net_over_nothing_readable_is_absent_rather_than_zero() {
assert_eq!(BacklogDelta::default().net(), None);
assert_eq!(
BacklogDelta {
outbox: Some(0),
..BacklogDelta::default()
}
.net(),
Some(0),
"a real zero is a different answer and stays one"
);
}
}