1use crate::frontdoor::{self, Frontdoor};
36use crate::harness::HarnessStore;
37use crate::learning::LearningStore;
38use crate::outbox::OutboxStore;
39use crate::questions::QuestionStore;
40use serde::{Deserialize, Serialize};
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Depth {
45 pub waiting: usize,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub oldest: Option<String>,
50}
51
52impl Depth {
53 fn of<'a>(waiting: usize, stamps: impl IntoIterator<Item = &'a str>) -> Depth {
54 Depth {
55 waiting,
56 oldest: stamps.into_iter().min().map(str::to_string),
57 }
58 }
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Backlog {
66 pub outbox: Option<Depth>,
67 pub questions: Option<Depth>,
68 pub frontdoor: Option<Depth>,
69 pub proposals: Option<Depth>,
70 pub candidates: Option<Depth>,
71}
72
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub struct Waiting {
76 pub total: usize,
78 pub unreadable: usize,
80}
81
82impl Backlog {
83 pub fn read() -> Backlog {
86 Backlog {
87 outbox: Self::read_outbox(),
88 questions: Self::read_questions(),
89 frontdoor: Self::read_frontdoor(),
90 proposals: Self::read_proposals(),
91 candidates: Self::read_candidates(),
92 }
93 }
94
95 fn read_outbox() -> Option<Depth> {
96 let store = OutboxStore::default_root()
97 .and_then(OutboxStore::open)
98 .ok()?;
99 let items = store.items().ok()?;
100 let pending: Vec<_> = items.iter().filter(|i| i.status == "pending").collect();
101 Some(Depth::of(
102 pending.len(),
103 pending.iter().map(|i| i.created_at.as_str()),
104 ))
105 }
106
107 fn read_questions() -> Option<Depth> {
108 let Some(store) = QuestionStore::open_existing_default() else {
110 return Some(Depth::default());
111 };
112 let items = store.items().ok()?;
113 let open: Vec<_> = items.iter().filter(|q| q.is_open()).collect();
114 Some(Depth::of(
115 open.len(),
116 open.iter().map(|q| q.asked_at.as_str()),
117 ))
118 }
119
120 fn read_frontdoor() -> Option<Depth> {
121 let records = Frontdoor::open_default().and_then(|s| s.records()).ok()?;
122 let open: Vec<_> = records
123 .iter()
124 .filter(|r| r.state != frontdoor::CLOSED)
125 .collect();
126 Some(Depth::of(
127 open.len(),
128 open.iter().map(|r| r.created_at.as_str()),
129 ))
130 }
131
132 fn read_proposals() -> Option<Depth> {
133 let store = LearningStore::default_root()
134 .and_then(LearningStore::open)
135 .ok()?;
136 let proposals = store.proposals().ok()?;
137 let pending: Vec<_> = proposals.iter().filter(|p| p.status == "pending").collect();
138 Some(Depth::of(
139 pending.len(),
140 pending.iter().map(|p| p.created_at.as_str()),
141 ))
142 }
143
144 fn read_candidates() -> Option<Depth> {
145 let candidates = HarnessStore::open_default().and_then(|s| s.all()).ok()?;
146 let staged: Vec<_> = candidates.iter().filter(|c| c.pending()).collect();
147 Some(Depth::of(
148 staged.len(),
149 staged.iter().map(|c| c.created_at.as_str()),
150 ))
151 }
152
153 fn depths(&self) -> [&Option<Depth>; 5] {
154 [
155 &self.outbox,
156 &self.questions,
157 &self.frontdoor,
158 &self.proposals,
159 &self.candidates,
160 ]
161 }
162
163 pub fn waiting(&self) -> Waiting {
165 let mut out = Waiting::default();
166 for depth in self.depths() {
167 match depth {
168 Some(d) => out.total += d.waiting,
169 None => out.unreadable += 1,
170 }
171 }
172 out
173 }
174
175 pub fn oldest(&self) -> Option<&str> {
181 self.depths()
182 .into_iter()
183 .flatten()
184 .filter_map(|d| d.oldest.as_deref())
185 .min()
186 }
187
188 pub fn delta(before: &Backlog, after: &Backlog) -> BacklogDelta {
193 let d = |a: &Option<Depth>, b: &Option<Depth>| match (a, b) {
194 (Some(a), Some(b)) => Some(b.waiting as i64 - a.waiting as i64),
195 _ => None,
196 };
197 BacklogDelta {
198 outbox: d(&before.outbox, &after.outbox),
199 questions: d(&before.questions, &after.questions),
200 frontdoor: d(&before.frontdoor, &after.frontdoor),
201 proposals: d(&before.proposals, &after.proposals),
202 candidates: d(&before.candidates, &after.candidates),
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
215pub struct BacklogDelta {
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub outbox: Option<i64>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub questions: Option<i64>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub frontdoor: Option<i64>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub proposals: Option<i64>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub candidates: Option<i64>,
226}
227
228impl BacklogDelta {
229 pub fn net(&self) -> Option<i64> {
234 let seen: Vec<i64> = [
235 self.outbox,
236 self.questions,
237 self.frontdoor,
238 self.proposals,
239 self.candidates,
240 ]
241 .into_iter()
242 .flatten()
243 .collect();
244 (!seen.is_empty()).then(|| seen.iter().sum())
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn depth(waiting: usize, oldest: Option<&str>) -> Option<Depth> {
253 Some(Depth {
254 waiting,
255 oldest: oldest.map(str::to_string),
256 })
257 }
258
259 #[test]
263 fn an_unreadable_store_is_counted_as_unread_and_never_as_empty() {
264 let b = Backlog {
265 outbox: depth(3, Some("2026-08-20T09:00:00Z")),
266 questions: None, frontdoor: depth(0, None),
268 proposals: depth(1, Some("2026-08-25T09:00:00Z")),
269 candidates: None, };
271 assert_eq!(
272 b.waiting(),
273 Waiting {
274 total: 4,
275 unreadable: 2
276 },
277 "the total is what was readable, and says so"
278 );
279 }
280
281 #[test]
282 fn a_store_with_nothing_waiting_has_no_oldest_age() {
283 let empty = Depth::of(0, Vec::<&str>::new());
284 assert_eq!(empty.waiting, 0);
285 assert_eq!(empty.oldest, None, "an absent age, never a zero one");
286 }
287
288 #[test]
291 fn the_oldest_wait_is_the_earliest_stamp_across_every_store() {
292 let b = Backlog {
293 outbox: depth(2, Some("2026-08-25T09:00:00Z")),
294 questions: depth(1, Some("2026-08-17T09:00:00Z")),
295 frontdoor: depth(0, None),
296 proposals: None,
297 candidates: depth(1, Some("2026-08-26T09:00:00Z")),
298 };
299 assert_eq!(b.oldest(), Some("2026-08-17T09:00:00Z"));
300 assert_eq!(Backlog::default().oldest(), None);
301 }
302
303 #[test]
307 fn a_delta_reports_what_this_run_added_rather_than_what_it_found() {
308 let before = Backlog {
309 outbox: depth(2, None),
310 questions: depth(1, None),
311 frontdoor: depth(4, None),
312 proposals: None,
313 candidates: depth(0, None),
314 };
315 let after = Backlog {
316 outbox: depth(11, None), questions: depth(0, None), frontdoor: depth(4, None),
319 proposals: depth(2, None), candidates: None, };
322 let d = Backlog::delta(&before, &after);
323 assert_eq!(d.outbox, Some(9));
324 assert_eq!(d.questions, Some(-1));
325 assert_eq!(d.frontdoor, Some(0), "readable and genuinely unchanged");
326 assert_eq!(d.proposals, None, "a delta against an unknown is not zero");
327 assert_eq!(d.candidates, None);
328 assert_eq!(d.net(), Some(8));
329 }
330
331 #[test]
334 fn a_net_over_nothing_readable_is_absent_rather_than_zero() {
335 assert_eq!(BacklogDelta::default().net(), None);
336 assert_eq!(
337 BacklogDelta {
338 outbox: Some(0),
339 ..BacklogDelta::default()
340 }
341 .net(),
342 Some(0),
343 "a real zero is a different answer and stays one"
344 );
345 }
346}