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}
39
40enum Dep<'a> {
41 Task(&'a Task),
42 Question(QuestionStatus),
43 Missing,
44}
45
46type Roots = Option<BTreeSet<String>>;
47
48fn short(id: &str) -> &str {
50 id.split('-').next_back().unwrap_or(id)
51}
52
53impl Inventory {
54 pub fn new(tasks: Vec<Task>, questions: &[Question]) -> Self {
57 Self {
58 tasks: tasks.into_iter().map(|t| (t.id.clone(), t)).collect(),
59 questions: questions.iter().map(|q| (q.id.clone(), q.status)).collect(),
60 }
61 }
62
63 fn dep(&self, id: &str) -> Dep<'_> {
64 if let Some(t) = self.tasks.get(id) {
65 Dep::Task(t)
66 } else if let Some(s) = self.questions.get(id) {
67 Dep::Question(*s)
68 } else {
69 Dep::Missing
70 }
71 }
72
73 fn resolved(&self, id: &str) -> bool {
75 match self.dep(id) {
76 Dep::Task(t) => t.status == TaskStatus::Done,
77 Dep::Question(s) => s == QuestionStatus::Answered,
78 Dep::Missing => false,
79 }
80 }
81
82 fn walk(
83 &self,
84 id: &str,
85 path: &mut Vec<String>,
86 memo: &mut BTreeMap<String, Roots>,
87 ) -> (Roots, bool) {
88 if let Some(v) = memo.get(id) {
89 return (v.clone(), false);
90 }
91 let Some(task) = self.tasks.get(id) else {
92 return (None, false);
93 };
94 path.push(id.to_owned());
95 let mut roots = BTreeSet::new();
96 let mut moving = false;
97 let mut cyclic = false;
98 for b in &task.blocked_by {
99 match self.dep(b) {
100 Dep::Task(t) => match t.status {
101 TaskStatus::Done => {}
102 TaskStatus::Held => {
103 roots.insert(b.clone());
104 }
105 TaskStatus::Blocked => {
106 if let Some(pos) = path.iter().position(|p| p == b) {
107 if let Some(min) = path[pos..].iter().min() {
110 roots.insert(min.clone());
111 }
112 cyclic = true;
113 } else {
114 let (sub, c) = self.walk(b, path, memo);
115 cyclic |= c;
116 match sub {
117 Some(r) => roots.extend(r),
118 None => moving = true,
119 }
120 }
121 }
122 TaskStatus::Queued
123 | TaskStatus::Running
124 | TaskStatus::Failed
125 | TaskStatus::Parked => {
126 moving = true;
127 }
128 },
129 Dep::Question(QuestionStatus::Answered) => {}
130 Dep::Question(_) | Dep::Missing => moving = true,
131 }
132 }
133 path.pop();
134 let verdict = (!moving && !roots.is_empty()).then_some(roots);
135 if !cyclic {
136 memo.insert(id.to_owned(), verdict.clone());
137 }
138 (verdict, cyclic)
139 }
140
141 pub fn stuck(&self) -> BTreeMap<String, BTreeSet<String>> {
143 let mut memo = BTreeMap::new();
144 let mut out = BTreeMap::new();
145 for (id, t) in &self.tasks {
146 if t.status != TaskStatus::Blocked {
147 continue;
148 }
149 let (verdict, _) = self.walk(id, &mut Vec::new(), &mut memo);
150 if let Some(roots) = verdict {
151 out.insert(id.clone(), roots);
152 }
153 }
154 out
155 }
156
157 pub fn stuck_roots(&self, task: &Task) -> BTreeSet<String> {
159 if task.status != TaskStatus::Blocked {
160 return BTreeSet::new();
161 }
162 self.walk(&task.id, &mut Vec::new(), &mut BTreeMap::new())
163 .0
164 .unwrap_or_default()
165 }
166
167 pub fn waits_on(&self, task: &Task) -> Vec<String> {
170 if task.status != TaskStatus::Blocked {
171 return Vec::new();
172 }
173 task.blocked_by
174 .iter()
175 .map(|b| {
176 let chain = self.chain(b, &mut vec![task.id.clone()]);
177 format!("{} ({chain})", short(b))
178 })
179 .collect()
180 }
181
182 fn chain(&self, id: &str, seen: &mut Vec<String>) -> String {
183 match self.dep(id) {
184 Dep::Missing => "missing".to_owned(),
185 Dep::Question(s) => format!("question {}", s.as_str()),
186 Dep::Task(t) => {
187 let mut out = t.status.as_str().to_owned();
188 if t.status != TaskStatus::Blocked {
189 return out;
190 }
191 if seen.iter().any(|s| s == id) {
192 return format!("{out}, cycle");
193 }
194 if seen.len() > CHAIN_DEPTH {
195 return format!("{out} → …");
196 }
197 seen.push(id.to_owned());
198 if let Some(next) = t.blocked_by.iter().find(|b| !self.resolved(b)) {
199 out.push_str(&format!(" → {} {}", short(next), self.chain(next, seen)));
200 }
201 out
202 }
203 }
204 }
205
206 pub fn task(&self, id: &str) -> Option<&Task> {
208 self.tasks.get(id)
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use crate::queue::Source;
216 use std::path::PathBuf;
217
218 fn t(id: &str, status: TaskStatus, blocked_by: &[&str]) -> Task {
219 let mut t = Task::new(
220 id.to_owned(),
221 "x".to_owned(),
222 PathBuf::from("/repo"),
223 Source::Human,
224 );
225 t.id = format!("2026-{id}");
226 t.status = status;
227 t.blocked_by = blocked_by.iter().map(|b| format!("2026-{b}")).collect();
228 t
229 }
230
231 fn inv(tasks: Vec<Task>) -> Inventory {
232 Inventory::new(tasks, &[])
233 }
234
235 #[test]
236 fn a_chain_reads_at_a_glance_and_its_root_is_the_held_task() {
237 let i = inv(vec![
238 t("9db7", TaskStatus::Held, &[]),
239 t("4135", TaskStatus::Blocked, &["9db7"]),
240 t("6081", TaskStatus::Blocked, &["4135"]),
241 ]);
242 let six = i.task("2026-6081").unwrap();
243 assert_eq!(i.waits_on(six), ["4135 (blocked → 9db7 held)"]);
244 let four = i.task("2026-4135").unwrap();
245 assert_eq!(i.waits_on(four), ["9db7 (held)"]);
246 let stuck = i.stuck();
247 assert_eq!(stuck.len(), 2);
248 assert!(stuck.values().all(|r| r.iter().eq(["2026-9db7"].iter())));
249 }
250
251 #[test]
252 fn a_dependency_that_can_still_run_is_not_stuck() {
253 let i = inv(vec![
254 t("aaaa", TaskStatus::Queued, &[]),
255 t("bbbb", TaskStatus::Held, &[]),
256 t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
257 t("dddd", TaskStatus::Blocked, &["missing1"]),
258 t("eeee", TaskStatus::Blocked, &["cccc"]),
259 ]);
260 assert!(i.stuck().is_empty(), "{:?}", i.stuck());
261 }
262
263 #[test]
264 fn a_done_dependency_does_not_hide_a_held_one() {
265 let i = inv(vec![
266 t("aaaa", TaskStatus::Done, &[]),
267 t("bbbb", TaskStatus::Held, &[]),
268 t("cccc", TaskStatus::Blocked, &["aaaa", "bbbb"]),
269 ]);
270 assert_eq!(i.stuck().len(), 1);
271 }
272
273 #[test]
274 fn a_cycle_terminates_and_names_its_smallest_id_from_every_entry() {
275 let i = inv(vec![
276 t("bbbb", TaskStatus::Blocked, &["cccc"]),
277 t("cccc", TaskStatus::Blocked, &["aaaa"]),
278 t("aaaa", TaskStatus::Blocked, &["bbbb"]),
279 t("dddd", TaskStatus::Blocked, &["cccc"]),
280 ]);
281 let stuck = i.stuck();
282 assert_eq!(stuck.len(), 4);
283 for roots in stuck.values() {
284 assert!(roots.iter().eq(["2026-aaaa"].iter()), "{roots:?}");
285 }
286 let d = i.task("2026-dddd").unwrap();
287 assert!(i.waits_on(d)[0].contains("cycle"), "{:?}", i.waits_on(d));
288 }
289
290 #[test]
291 fn a_self_dependency_is_a_stuck_cycle_of_one() {
292 let i = inv(vec![t("aaaa", TaskStatus::Blocked, &["aaaa"])]);
293 assert_eq!(i.stuck().len(), 1);
294 }
295
296 #[test]
297 fn an_open_question_keeps_a_task_alive_and_an_answered_one_resolves() {
298 let mut open = Question::new(
299 "r".into(),
300 "n".into(),
301 "s".into(),
302 "?".into(),
303 String::new(),
304 vec![],
305 );
306 open.id = "2026-qqqq".into();
307 let mut answered = open.clone();
308 answered.id = "2026-rrrr".into();
309 answered.status = QuestionStatus::Answered;
310 let tasks = vec![
311 t("bbbb", TaskStatus::Held, &[]),
312 t("cccc", TaskStatus::Blocked, &["bbbb", "qqqq"]),
313 t("dddd", TaskStatus::Blocked, &["bbbb", "rrrr"]),
314 ];
315 let i = Inventory::new(tasks, &[open, answered]);
316 let stuck = i.stuck();
317 assert!(!stuck.contains_key("2026-cccc"));
318 assert!(stuck.contains_key("2026-dddd"));
319 let c = i.task("2026-cccc").unwrap();
320 assert_eq!(i.waits_on(c)[1], "qqqq (question open)");
321 }
322}