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 let mut inventory = Self::new(queue.list(), &questions.list());
77 let ids = (|| -> anyhow::Result<Vec<String>> {
78 let mut ids = crate::queue::record_ids(queue.root(), "json")?;
79 ids.extend(crate::queue::record_ids(questions.root(), "json")?);
80 ids.extend(crate::queue::record_ids(queue.root(), "removed")?);
81 ids.sort();
82 ids.dedup();
83 Ok(ids)
84 })();
85 match ids {
86 Ok(ids) => inventory.ids = ids,
87 Err(_) => inventory.complete = false,
88 }
89 inventory
90 }
91
92 fn dep(&self, id: &str) -> Dep<'_> {
93 if let Some(t) = self.tasks.get(id) {
96 return Dep::Task(t);
97 }
98 if let Some(s) = self.questions.get(id) {
99 return Dep::Question(*s);
100 }
101 if !self.complete {
102 return Dep::Unknown;
103 }
104 match crate::queue::resolve_reference(id, self.ids.iter().map(String::as_str)) {
105 crate::queue::Reference::Found(full) => {
106 if let Some(t) = self.tasks.get(&full) {
107 Dep::Task(t)
108 } else if let Some(s) = self.questions.get(&full) {
109 Dep::Question(*s)
110 } else {
111 Dep::Unknown
112 }
113 }
114 crate::queue::Reference::Missing => Dep::Missing,
115 crate::queue::Reference::Ambiguous => Dep::Unknown,
116 }
117 }
118
119 fn resolved(&self, id: &str) -> bool {
121 match self.dep(id) {
122 Dep::Task(t) => t.status == TaskStatus::Done,
123 Dep::Question(s) => s == QuestionStatus::Answered,
124 Dep::Missing | Dep::Unknown => false,
125 }
126 }
127
128 fn walk(
129 &self,
130 id: &str,
131 path: &mut Vec<String>,
132 memo: &mut BTreeMap<String, Roots>,
133 ) -> (Roots, bool) {
134 if let Some(v) = memo.get(id) {
135 return (v.clone(), false);
136 }
137 let Some(task) = self.tasks.get(id) else {
138 return (None, false);
139 };
140 path.push(id.to_owned());
141 let mut roots = BTreeSet::new();
142 let mut moving = false;
143 let mut cyclic = false;
144 for b in &task.blocked_by {
145 match self.dep(b) {
146 Dep::Task(t) => match t.status {
147 TaskStatus::Done => {}
148 TaskStatus::Held => {
149 roots.insert(t.id.clone());
150 }
151 TaskStatus::Blocked => {
152 if let Some(pos) = path.iter().position(|p| p == &t.id) {
153 if let Some(min) = path[pos..].iter().min() {
156 roots.insert(min.clone());
157 }
158 cyclic = true;
159 } else {
160 let (sub, c) = self.walk(&t.id, path, memo);
161 cyclic |= c;
162 match sub {
163 Some(r) => roots.extend(r),
164 None => moving = true,
165 }
166 }
167 }
168 TaskStatus::Queued
169 | TaskStatus::Running
170 | TaskStatus::Failed
171 | TaskStatus::Parked => {
172 moving = true;
173 }
174 },
175 Dep::Question(QuestionStatus::Answered) => {}
176 Dep::Question(_) | Dep::Missing | Dep::Unknown => moving = true,
177 }
178 }
179 path.pop();
180 let verdict = (!moving && !roots.is_empty()).then_some(roots);
181 if !cyclic {
182 memo.insert(id.to_owned(), verdict.clone());
183 }
184 (verdict, cyclic)
185 }
186
187 pub fn stuck(&self) -> BTreeMap<String, BTreeSet<String>> {
189 let mut memo = BTreeMap::new();
190 let mut out = BTreeMap::new();
191 for (id, t) in &self.tasks {
192 if t.status != TaskStatus::Blocked {
193 continue;
194 }
195 let (verdict, _) = self.walk(id, &mut Vec::new(), &mut memo);
196 if let Some(roots) = verdict {
197 out.insert(id.clone(), roots);
198 }
199 }
200 out
201 }
202
203 pub fn stuck_roots(&self, task: &Task) -> BTreeSet<String> {
205 if task.status != TaskStatus::Blocked {
206 return BTreeSet::new();
207 }
208 self.walk(&task.id, &mut Vec::new(), &mut BTreeMap::new())
209 .0
210 .unwrap_or_default()
211 }
212
213 pub fn waits_on(&self, task: &Task) -> Vec<String> {
216 if task.status != TaskStatus::Blocked {
217 return Vec::new();
218 }
219 task.blocked_by
220 .iter()
221 .map(|b| {
222 let chain = self.chain(b, &mut vec![task.id.clone()]);
223 format!("{} ({chain})", short(b))
224 })
225 .collect()
226 }
227
228 fn chain(&self, id: &str, seen: &mut Vec<String>) -> String {
229 match self.dep(id) {
230 Dep::Missing => "missing".to_owned(),
231 Dep::Unknown => "unavailable or ambiguous".to_owned(),
232 Dep::Question(s) => format!("question {}", s.as_str()),
233 Dep::Task(t) => {
234 let mut out = t.status.as_str().to_owned();
235 if t.status != TaskStatus::Blocked {
236 return out;
237 }
238 if seen.iter().any(|s| s == &t.id) {
239 return format!("{out}, cycle");
240 }
241 if seen.len() > CHAIN_DEPTH {
242 return format!("{out} → …");
243 }
244 seen.push(t.id.clone());
245 if let Some(next) = t.blocked_by.iter().find(|b| !self.resolved(b)) {
246 out.push_str(&format!(" → {} {}", short(next), self.chain(next, seen)));
247 }
248 out
249 }
250 }
251 }
252
253 pub fn task(&self, id: &str) -> Option<&Task> {
255 match self.dep(id) {
256 Dep::Task(t) => Some(t),
257 _ => None,
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::queue::Source;
266 use std::path::PathBuf;
267
268 fn t(id: &str, status: TaskStatus, blocked_by: &[&str]) -> Task {
269 let mut t = Task::new(
270 id.to_owned(),
271 "x".to_owned(),
272 PathBuf::from("/repo"),
273 Source::Human,
274 );
275 t.id = format!("2026-{id}");
276 t.status = status;
277 t.blocked_by = blocked_by.iter().map(|b| format!("2026-{b}")).collect();
278 t
279 }
280
281 fn inv(tasks: Vec<Task>) -> Inventory {
282 Inventory::new(tasks, &[])
283 }
284
285 #[test]
286 fn dependency_resolution_partial_scan_keeps_readable_exact_records() {
287 let dir = tempfile::tempdir().unwrap();
288 let queue = crate::queue::Queue::at(dir.path().join("queue"));
289 let questions = crate::ask::Questions::at(dir.path().join("questions"));
290 let mut running = t("feb2", TaskStatus::Running, &[]);
291 let mut middle = t("ca5b", TaskStatus::Blocked, &["feb2"]);
292 let mut waiting = t("ee37", TaskStatus::Blocked, &["ca5b"]);
293 let mut held = t("held", TaskStatus::Held, &[]);
294 let mut short_wait = t("short", TaskStatus::Blocked, &[]);
295 short_wait.blocked_by = vec!["held".into()];
296 for task in [
297 &mut running,
298 &mut middle,
299 &mut waiting,
300 &mut held,
301 &mut short_wait,
302 ] {
303 queue.put(task).unwrap();
304 }
305 std::fs::write(questions.root(), "not a directory").unwrap();
307 let inventory = Inventory::load(&queue, &questions);
308 assert!(!inventory.complete);
309 assert!(inventory.ids.contains(&running.id));
310 assert_eq!(
311 inventory.task(&running.id).unwrap().status,
312 TaskStatus::Running
313 );
314 assert_eq!(
315 inventory.waits_on(&waiting),
316 ["ca5b (blocked → feb2 running)"]
317 );
318 assert_eq!(
319 inventory.waits_on(&short_wait),
320 ["held (unavailable or ambiguous)"]
321 );
322 assert!(
323 inventory.stuck().is_empty(),
324 "partial scans cannot prove short-id uniqueness"
325 );
326 assert!(matches!(inventory.dep("not-seen"), Dep::Unknown));
327 }
328
329 #[test]
330 fn a_chain_reads_at_a_glance_and_its_root_is_the_held_task() {
331 let i = inv(vec![
332 t("9db7", TaskStatus::Held, &[]),
333 t("4135", TaskStatus::Blocked, &["9db7"]),
334 t("6081", TaskStatus::Blocked, &["4135"]),
335 ]);
336 let six = i.task("2026-6081").unwrap();
337 assert_eq!(i.waits_on(six), ["4135 (blocked → 9db7 held)"]);
338 let four = i.task("2026-4135").unwrap();
339 assert_eq!(i.waits_on(four), ["9db7 (held)"]);
340 let stuck = i.stuck();
341 assert_eq!(stuck.len(), 2);
342 assert!(stuck.values().all(|r| r.iter().eq(["2026-9db7"].iter())));
343 }
344
345 #[test]
346 fn a_dependency_that_can_still_run_is_not_stuck() {
347 let i = inv(vec![
348 t("aaaa", TaskStatus::Queued, &[]),
349 t("bbbb", TaskStatus::Held, &[]),
350 t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
351 t("dddd", TaskStatus::Blocked, &["missing1"]),
352 t("eeee", TaskStatus::Blocked, &["cccc"]),
353 ]);
354 assert!(i.stuck().is_empty(), "{:?}", i.stuck());
355 }
356
357 #[test]
358 fn a_done_dependency_does_not_hide_a_held_one() {
359 let i = inv(vec![
360 t("aaaa", TaskStatus::Done, &[]),
361 t("bbbb", TaskStatus::Held, &[]),
362 t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
363 ]);
364 assert_eq!(i.stuck().len(), 1);
365 }
366
367 #[test]
368 fn a_cycle_terminates_and_names_its_smallest_id_from_every_entry() {
369 let i = inv(vec![
370 t("bbbb", TaskStatus::Blocked, &["cccc"]),
371 t("cccc", TaskStatus::Blocked, &["aaaa"]),
372 t("aaaa", TaskStatus::Blocked, &["bbbb"]),
373 t("dddd", TaskStatus::Blocked, &["cccc"]),
374 ]);
375 let stuck = i.stuck();
376 assert_eq!(stuck.len(), 4);
377 for roots in stuck.values() {
378 assert!(roots.iter().eq(["2026-aaaa"].iter()), "{roots:?}");
379 }
380 let d = i.task("2026-dddd").unwrap();
381 assert!(i.waits_on(d)[0].contains("cycle"), "{:?}", i.waits_on(d));
382 }
383
384 #[test]
385 fn a_self_dependency_is_a_stuck_cycle_of_one() {
386 let i = inv(vec![t("aaaa", TaskStatus::Blocked, &["aaaa"])]);
387 assert_eq!(i.stuck().len(), 1);
388 }
389
390 #[test]
391 fn an_open_question_keeps_a_task_alive_and_an_answered_one_resolves() {
392 let mut open = Question::new(
393 "r".into(),
394 "n".into(),
395 "s".into(),
396 "?".into(),
397 String::new(),
398 vec![],
399 );
400 open.id = "2026-qqqq".into();
401 let mut answered = open.clone();
402 answered.id = "2026-rrrr".into();
403 answered.status = QuestionStatus::Answered;
404 let tasks = vec![
405 t("bbbb", TaskStatus::Held, &[]),
406 t("cccc", TaskStatus::Blocked, &["bbbb", "qqqq"]),
407 t("dddd", TaskStatus::Blocked, &["bbbb", "rrrr"]),
408 ];
409 let i = Inventory::new(tasks, &[open, answered]);
410 let stuck = i.stuck();
411 assert!(!stuck.contains_key("2026-cccc"));
412 assert!(stuck.contains_key("2026-dddd"));
413 let c = i.task("2026-cccc").unwrap();
414 assert_eq!(i.waits_on(c)[1], "qqqq (question open)");
415 }
416}