1use std::collections::{BTreeMap, BTreeSet};
26
27use crate::ask::{Question, QuestionStatus};
28use crate::queue::{Task, TaskStatus};
29
30const CHAIN_DEPTH: usize = 4;
32
33#[derive(Debug, Clone, Default)]
35pub struct Inventory {
36 tasks: BTreeMap<String, Task>,
37 questions: BTreeMap<String, QuestionStatus>,
38 ids: Vec<String>,
39 complete: bool,
40}
41
42enum Dep<'a> {
43 Task(&'a Task),
44 Question(QuestionStatus),
45 Missing,
46 Unknown,
47}
48
49type Roots = Option<BTreeSet<String>>;
50
51fn short(id: &str) -> &str {
53 id.split('-').next_back().unwrap_or(id)
54}
55
56impl Inventory {
57 pub fn new(tasks: Vec<Task>, questions: &[Question]) -> Self {
60 let ids = tasks
61 .iter()
62 .map(|t| t.id.clone())
63 .chain(questions.iter().map(|q| q.id.clone()))
64 .collect();
65 Self {
66 ids,
67 complete: true,
68 tasks: tasks.into_iter().map(|t| (t.id.clone(), t)).collect(),
69 questions: questions.iter().map(|q| (q.id.clone(), q.status)).collect(),
70 }
71 }
72
73 pub fn load(queue: &crate::queue::Queue, questions: &crate::ask::Questions) -> Self {
76 Self::load_with_tasks(queue, questions, queue.list())
77 }
78
79 pub fn load_with_tasks(
82 queue: &crate::queue::Queue,
83 questions: &crate::ask::Questions,
84 tasks: Vec<Task>,
85 ) -> Self {
86 let mut inventory = Self::new(tasks, &questions.list());
87 let ids = (|| -> anyhow::Result<Vec<String>> {
88 let mut ids = crate::queue::record_ids(queue.root(), "json")?;
89 ids.extend(crate::queue::record_ids(questions.root(), "json")?);
90 ids.extend(crate::queue::record_ids(queue.root(), "removed")?);
91 ids.sort();
92 ids.dedup();
93 Ok(ids)
94 })();
95 match ids {
96 Ok(ids) => inventory.ids = ids,
97 Err(_) => inventory.complete = false,
98 }
99 inventory
100 }
101
102 pub fn load_for(
106 queue: &crate::queue::Queue,
107 questions: &crate::ask::Questions,
108 task: &Task,
109 ) -> Self {
110 Self::load_for_tasks(queue, questions, std::slice::from_ref(task))
111 }
112
113 pub fn load_for_tasks(
115 queue: &crate::queue::Queue,
116 questions: &crate::ask::Questions,
117 tasks: &[Task],
118 ) -> Self {
119 let mut inventory = Self::new(tasks.to_vec(), &[]);
120 if tasks.iter().all(|task| task.blocked_by.is_empty()) {
121 return inventory;
122 }
123 let ids = (|| -> anyhow::Result<Vec<String>> {
124 let mut ids = crate::queue::record_ids(queue.root(), "json")?;
125 ids.extend(crate::queue::record_ids(questions.root(), "json")?);
126 ids.extend(crate::queue::record_ids(queue.root(), "removed")?);
127 ids.sort();
128 ids.dedup();
129 Ok(ids)
130 })();
131 match ids {
132 Ok(ids) => inventory.ids = ids,
133 Err(_) => inventory.complete = false,
134 }
135 let mut pending: Vec<_> = tasks
136 .iter()
137 .flat_map(|task| task.blocked_by.iter().cloned())
138 .collect();
139 let mut seen = BTreeSet::new();
140 while let Some(reference) = pending.pop() {
141 let full =
142 if queue.path_of(&reference).is_file() || questions.path_of(&reference).is_file() {
143 reference
144 } else if inventory.complete {
145 match crate::queue::resolve_reference(
146 &reference,
147 inventory.ids.iter().map(String::as_str),
148 ) {
149 crate::queue::Reference::Found(full) => full,
150 _ => continue,
151 }
152 } else {
153 continue;
154 };
155 if !seen.insert(full.clone()) {
156 continue;
157 }
158 if queue.path_of(&full).is_file()
159 && let Ok(t) = queue.get(&full)
160 {
161 pending.extend(t.blocked_by.iter().cloned());
162 inventory.tasks.insert(full.clone(), t);
163 }
164 if questions.path_of(&full).is_file()
165 && let Ok(q) = questions.get(&full)
166 {
167 inventory.questions.insert(full, q.status);
168 }
169 }
170 inventory
171 }
172
173 fn dep(&self, id: &str) -> Dep<'_> {
174 if let Some(t) = self.tasks.get(id) {
177 return Dep::Task(t);
178 }
179 if let Some(s) = self.questions.get(id) {
180 return Dep::Question(*s);
181 }
182 if !self.complete {
183 return Dep::Unknown;
184 }
185 match crate::queue::resolve_reference(id, self.ids.iter().map(String::as_str)) {
186 crate::queue::Reference::Found(full) => {
187 if let Some(t) = self.tasks.get(&full) {
188 Dep::Task(t)
189 } else if let Some(s) = self.questions.get(&full) {
190 Dep::Question(*s)
191 } else {
192 Dep::Unknown
193 }
194 }
195 crate::queue::Reference::Missing => Dep::Missing,
196 crate::queue::Reference::Ambiguous => Dep::Unknown,
197 }
198 }
199
200 fn resolved(&self, id: &str) -> bool {
202 match self.dep(id) {
203 Dep::Task(t) => t.status == TaskStatus::Done,
204 Dep::Question(s) => s == QuestionStatus::Answered,
205 Dep::Missing | Dep::Unknown => false,
206 }
207 }
208
209 fn walk(
210 &self,
211 id: &str,
212 path: &mut Vec<String>,
213 memo: &mut BTreeMap<String, Roots>,
214 ) -> (Roots, bool) {
215 if let Some(v) = memo.get(id) {
216 return (v.clone(), false);
217 }
218 let Some(task) = self.tasks.get(id) else {
219 return (None, false);
220 };
221 path.push(id.to_owned());
222 let mut roots = BTreeSet::new();
223 let mut moving = false;
224 let mut cyclic = false;
225 for b in &task.blocked_by {
226 match self.dep(b) {
227 Dep::Task(t) => match t.status {
228 TaskStatus::Done => {}
229 TaskStatus::Held => {
230 roots.insert(t.id.clone());
231 }
232 TaskStatus::Blocked => {
233 if let Some(pos) = path.iter().position(|p| p == &t.id) {
234 if let Some(min) = path[pos..].iter().min() {
237 roots.insert(min.clone());
238 }
239 cyclic = true;
240 } else {
241 let (sub, c) = self.walk(&t.id, path, memo);
242 cyclic |= c;
243 match sub {
244 Some(r) => roots.extend(r),
245 None => moving = true,
246 }
247 }
248 }
249 TaskStatus::Queued
250 | TaskStatus::Running
251 | TaskStatus::Failed
252 | TaskStatus::Parked => {
253 moving = true;
254 }
255 },
256 Dep::Question(QuestionStatus::Answered) => {}
257 Dep::Question(_) | Dep::Missing | Dep::Unknown => moving = true,
258 }
259 }
260 path.pop();
261 let verdict = (!moving && !roots.is_empty()).then_some(roots);
262 if !cyclic {
263 memo.insert(id.to_owned(), verdict.clone());
264 }
265 (verdict, cyclic)
266 }
267
268 pub fn stuck(&self) -> BTreeMap<String, BTreeSet<String>> {
270 let mut memo = BTreeMap::new();
271 let mut out = BTreeMap::new();
272 for (id, t) in &self.tasks {
273 if t.status != TaskStatus::Blocked {
274 continue;
275 }
276 let (verdict, _) = self.walk(id, &mut Vec::new(), &mut memo);
277 if let Some(roots) = verdict {
278 out.insert(id.clone(), roots);
279 }
280 }
281 out
282 }
283
284 pub fn stuck_roots(&self, task: &Task) -> BTreeSet<String> {
286 if task.status != TaskStatus::Blocked {
287 return BTreeSet::new();
288 }
289 self.walk(&task.id, &mut Vec::new(), &mut BTreeMap::new())
290 .0
291 .unwrap_or_default()
292 }
293
294 pub fn waits_on(&self, task: &Task) -> Vec<String> {
297 if task.status != TaskStatus::Blocked {
298 return Vec::new();
299 }
300 task.blocked_by
301 .iter()
302 .map(|b| {
303 let chain = self.chain(b, &mut vec![task.id.clone()]);
304 format!("{} ({chain})", short(b))
305 })
306 .collect()
307 }
308
309 fn chain(&self, id: &str, seen: &mut Vec<String>) -> String {
310 match self.dep(id) {
311 Dep::Missing => "missing".to_owned(),
312 Dep::Unknown => "unavailable or ambiguous".to_owned(),
313 Dep::Question(s) => format!("question {}", s.as_str()),
314 Dep::Task(t) => {
315 let mut out = t.status.as_str().to_owned();
316 if t.status != TaskStatus::Blocked {
317 return out;
318 }
319 if seen.iter().any(|s| s == &t.id) {
320 return format!("{out}, cycle");
321 }
322 if seen.len() > CHAIN_DEPTH {
323 return format!("{out} → …");
324 }
325 seen.push(t.id.clone());
326 if let Some(next) = t.blocked_by.iter().find(|b| !self.resolved(b)) {
327 out.push_str(&format!(" → {} {}", short(next), self.chain(next, seen)));
328 }
329 out
330 }
331 }
332 }
333
334 pub fn task(&self, id: &str) -> Option<&Task> {
336 match self.dep(id) {
337 Dep::Task(t) => Some(t),
338 _ => None,
339 }
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use crate::queue::Source;
347 use std::path::PathBuf;
348
349 fn t(id: &str, status: TaskStatus, blocked_by: &[&str]) -> Task {
350 let mut t = Task::new(
351 id.to_owned(),
352 "x".to_owned(),
353 PathBuf::from("/repo"),
354 Source::Human,
355 );
356 t.id = format!("2026-{id}");
357 t.status = status;
358 t.blocked_by = blocked_by.iter().map(|b| format!("2026-{b}")).collect();
359 t
360 }
361
362 fn inv(tasks: Vec<Task>) -> Inventory {
363 Inventory::new(tasks, &[])
364 }
365
366 #[test]
367 fn responsiveness_dependency_closure_matches_the_full_inventory() {
368 let dir = tempfile::tempdir().unwrap();
369 let queue = crate::queue::Queue::at(dir.path().join("queue"));
370 let questions = crate::ask::Questions::at(dir.path().join("questions"));
371 let mut held = t("held", TaskStatus::Held, &[]);
372 let mut middle = t("middle", TaskStatus::Blocked, &["held"]);
373 let mut waiting = t("waiting", TaskStatus::Blocked, &["middle"]);
374 for task in [&mut held, &mut middle, &mut waiting] {
375 queue.put(task).unwrap();
376 }
377 for n in 0..150 {
378 let mut unrelated = t(&format!("noise{n}"), TaskStatus::Done, &[]);
379 unrelated.instruction = "history ".repeat(4096);
380 queue.put(&mut unrelated).unwrap();
381 }
382 for reference in ["2026-middle", "middle", "missing", "collision"] {
383 waiting.blocked_by = vec![reference.into()];
384 queue.put(&mut waiting).unwrap();
385 std::fs::write(queue.root().join("a-collision.json"), "corrupt").unwrap();
386 std::fs::write(queue.root().join("b-collision.removed"), "removed").unwrap();
387 let full = Inventory::load(&queue, &questions);
388 let small = Inventory::load_for(&queue, &questions, &waiting);
389 assert_eq!(small.waits_on(&waiting), full.waits_on(&waiting));
390 assert_eq!(small.stuck_roots(&waiting), full.stuck_roots(&waiting));
391 assert!(small.tasks.len() <= 3);
392 }
393 held.status = TaskStatus::Blocked;
395 held.blocked_by = vec![middle.id.clone()];
396 queue.put(&mut held).unwrap();
397 waiting.blocked_by = vec![middle.id.clone()];
398 queue.put(&mut waiting).unwrap();
399 let full = Inventory::load(&queue, &questions);
400 let small = Inventory::load_for(&queue, &questions, &waiting);
401 assert_eq!(small.stuck_roots(&waiting), full.stuck_roots(&waiting));
402 }
403
404 #[test]
405 fn dependency_resolution_partial_scan_keeps_readable_exact_records() {
406 let dir = tempfile::tempdir().unwrap();
407 let queue = crate::queue::Queue::at(dir.path().join("queue"));
408 let questions = crate::ask::Questions::at(dir.path().join("questions"));
409 let mut running = t("feb2", TaskStatus::Running, &[]);
410 let mut middle = t("ca5b", TaskStatus::Blocked, &["feb2"]);
411 let mut waiting = t("ee37", TaskStatus::Blocked, &["ca5b"]);
412 let mut held = t("held", TaskStatus::Held, &[]);
413 let mut short_wait = t("short", TaskStatus::Blocked, &[]);
414 short_wait.blocked_by = vec!["held".into()];
415 for task in [
416 &mut running,
417 &mut middle,
418 &mut waiting,
419 &mut held,
420 &mut short_wait,
421 ] {
422 queue.put(task).unwrap();
423 }
424 std::fs::write(questions.root(), "not a directory").unwrap();
426 let inventory = Inventory::load(&queue, &questions);
427 assert!(!inventory.complete);
428 assert!(inventory.ids.contains(&running.id));
429 assert_eq!(
430 inventory.task(&running.id).unwrap().status,
431 TaskStatus::Running
432 );
433 assert_eq!(
434 inventory.waits_on(&waiting),
435 ["ca5b (blocked → feb2 running)"]
436 );
437 assert_eq!(
438 inventory.waits_on(&short_wait),
439 ["held (unavailable or ambiguous)"]
440 );
441 assert!(
442 inventory.stuck().is_empty(),
443 "partial scans cannot prove short-id uniqueness"
444 );
445 assert!(matches!(inventory.dep("not-seen"), Dep::Unknown));
446 }
447
448 #[test]
449 fn a_chain_reads_at_a_glance_and_its_root_is_the_held_task() {
450 let i = inv(vec![
451 t("9db7", TaskStatus::Held, &[]),
452 t("4135", TaskStatus::Blocked, &["9db7"]),
453 t("6081", TaskStatus::Blocked, &["4135"]),
454 ]);
455 let six = i.task("2026-6081").unwrap();
456 assert_eq!(i.waits_on(six), ["4135 (blocked → 9db7 held)"]);
457 let four = i.task("2026-4135").unwrap();
458 assert_eq!(i.waits_on(four), ["9db7 (held)"]);
459 let stuck = i.stuck();
460 assert_eq!(stuck.len(), 2);
461 assert!(stuck.values().all(|r| r.iter().eq(["2026-9db7"].iter())));
462 }
463
464 #[test]
465 fn a_dependency_that_can_still_run_is_not_stuck() {
466 let i = inv(vec![
467 t("aaaa", TaskStatus::Queued, &[]),
468 t("bbbb", TaskStatus::Held, &[]),
469 t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
470 t("dddd", TaskStatus::Blocked, &["missing1"]),
471 t("eeee", TaskStatus::Blocked, &["cccc"]),
472 ]);
473 assert!(i.stuck().is_empty(), "{:?}", i.stuck());
474 }
475
476 #[test]
477 fn a_done_dependency_does_not_hide_a_held_one() {
478 let i = inv(vec![
479 t("aaaa", TaskStatus::Done, &[]),
480 t("bbbb", TaskStatus::Held, &[]),
481 t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
482 ]);
483 assert_eq!(i.stuck().len(), 1);
484 }
485
486 #[test]
487 fn a_cycle_terminates_and_names_its_smallest_id_from_every_entry() {
488 let i = inv(vec![
489 t("bbbb", TaskStatus::Blocked, &["cccc"]),
490 t("cccc", TaskStatus::Blocked, &["aaaa"]),
491 t("aaaa", TaskStatus::Blocked, &["bbbb"]),
492 t("dddd", TaskStatus::Blocked, &["cccc"]),
493 ]);
494 let stuck = i.stuck();
495 assert_eq!(stuck.len(), 4);
496 for roots in stuck.values() {
497 assert!(roots.iter().eq(["2026-aaaa"].iter()), "{roots:?}");
498 }
499 let d = i.task("2026-dddd").unwrap();
500 assert!(i.waits_on(d)[0].contains("cycle"), "{:?}", i.waits_on(d));
501 }
502
503 #[test]
504 fn a_self_dependency_is_a_stuck_cycle_of_one() {
505 let i = inv(vec![t("aaaa", TaskStatus::Blocked, &["aaaa"])]);
506 assert_eq!(i.stuck().len(), 1);
507 }
508
509 #[test]
510 fn an_open_question_keeps_a_task_alive_and_an_answered_one_resolves() {
511 let mut open = Question::new(
512 "r".into(),
513 "n".into(),
514 "s".into(),
515 "?".into(),
516 String::new(),
517 vec![],
518 );
519 open.id = "2026-qqqq".into();
520 let mut answered = open.clone();
521 answered.id = "2026-rrrr".into();
522 answered.status = QuestionStatus::Answered;
523 let tasks = vec![
524 t("bbbb", TaskStatus::Held, &[]),
525 t("cccc", TaskStatus::Blocked, &["bbbb", "qqqq"]),
526 t("dddd", TaskStatus::Blocked, &["bbbb", "rrrr"]),
527 ];
528 let i = Inventory::new(tasks, &[open, answered]);
529 let stuck = i.stuck();
530 assert!(!stuck.contains_key("2026-cccc"));
531 assert!(stuck.contains_key("2026-dddd"));
532 let c = i.task("2026-cccc").unwrap();
533 assert_eq!(i.waits_on(c)[1], "qqqq (question open)");
534 }
535}