1use std::collections::{BTreeMap, HashMap, HashSet};
9
10use chrono::{DateTime, Utc};
11
12use crate::entities::Priority;
13use crate::materialize::World;
14use crate::resolve::{ResolvedStatus, Status};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Identity {
18 pub dev: String,
19 pub machine: String,
20 pub worktree: String,
21}
22
23#[derive(Debug, Clone)]
24pub struct RankedTask {
25 pub task_id: String,
26 pub priority: Priority,
27 pub unblocks: usize,
28 pub created: String,
29 pub reason: String,
30}
31
32pub fn rank_next(
35 world: &World,
36 statuses: &BTreeMap<String, ResolvedStatus>,
37 now: DateTime<Utc>,
38 me: &Identity,
39) -> Vec<RankedTask> {
40 let is_open = |id: &str| {
41 statuses
42 .get(id)
43 .map(|r| r.status == Status::Open)
44 .unwrap_or(false)
45 };
46
47 let mut blocks: HashMap<&str, Vec<&str>> = HashMap::new();
49 let mut blocked_by: HashMap<&str, Vec<&str>> = HashMap::new();
51 for dep in world.deps.values() {
52 if !world.tasks.contains_key(&*dep.blocker_task_id)
54 || !world.tasks.contains_key(&*dep.blocked_task_id)
55 {
56 continue;
57 }
58 blocks
59 .entry(&dep.blocker_task_id)
60 .or_default()
61 .push(&dep.blocked_task_id);
62 blocked_by
63 .entry(&dep.blocked_task_id)
64 .or_default()
65 .push(&dep.blocker_task_id);
66 }
67
68 let mut candidates: Vec<RankedTask> = Vec::new();
69 let mut eligible_count = 0usize;
70 for (id, task) in &world.tasks {
71 if !is_open(id) {
72 continue;
73 }
74 let all_blockers_closed = blocked_by
75 .get(id.as_str())
76 .map(|bs| bs.iter().all(|b| !is_open(b)))
77 .unwrap_or(true);
78 if !all_blockers_closed {
79 continue;
80 }
81 if let Some(claim) = world.live_claim(id, now) {
82 let mine =
83 claim.dev == me.dev && claim.machine == me.machine && claim.worktree == me.worktree;
84 if !mine {
85 continue;
86 }
87 }
88 eligible_count += 1;
89 candidates.push(RankedTask {
90 task_id: id.clone(),
91 priority: task.priority,
92 unblocks: transitive_unblocks(id, &blocks, &is_open),
93 created: task.created.clone(),
94 reason: String::new(),
95 });
96 }
97
98 candidates.sort_by(|a, b| {
99 (
100 a.priority.rank(),
101 std::cmp::Reverse(a.unblocks),
102 a.created.as_str(),
103 a.task_id.as_str(),
104 )
105 .cmp(&(
106 b.priority.rank(),
107 std::cmp::Reverse(b.unblocks),
108 b.created.as_str(),
109 b.task_id.as_str(),
110 ))
111 });
112
113 for (i, c) in candidates.iter_mut().enumerate() {
114 let mut parts = vec![c.priority.label().to_string()];
115 if c.unblocks > 0 {
116 parts.push(format!(
117 "unblocks {} open task{}",
118 c.unblocks,
119 if c.unblocks == 1 { "" } else { "s" }
120 ));
121 }
122 parts.push(if i == 0 {
123 format!("ranked 1st of {eligible_count} eligible")
124 } else {
125 format!("ranked {} of {eligible_count} eligible", ordinal(i + 1))
126 });
127 c.reason = parts.join("; ");
128 }
129 candidates
130}
131
132fn transitive_unblocks(
134 id: &str,
135 blocks: &HashMap<&str, Vec<&str>>,
136 is_open: &impl Fn(&str) -> bool,
137) -> usize {
138 let mut seen: HashSet<&str> = HashSet::new();
139 let mut stack: Vec<&str> = blocks.get(id).cloned().unwrap_or_default();
140 let mut count = 0;
141 while let Some(next) = stack.pop() {
142 if next == id || !seen.insert(next) {
143 continue;
144 }
145 if is_open(next) {
146 count += 1;
147 }
148 if let Some(more) = blocks.get(next) {
149 stack.extend(more);
150 }
151 }
152 count
153}
154
155fn ordinal(n: usize) -> String {
156 let suffix = match (n % 10, n % 100) {
157 (1, 11) | (2, 12) | (3, 13) => "th",
158 (1, _) => "st",
159 (2, _) => "nd",
160 (3, _) => "rd",
161 _ => "th",
162 };
163 format!("{n}{suffix}")
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::entities::*;
170 use crate::ids::{PrefixError, resolve_prefix, short_id};
171 use crate::resolve::Resolution;
172
173 fn me() -> Identity {
174 Identity {
175 dev: "me@x".into(),
176 machine: "m1".into(),
177 worktree: "/w1".into(),
178 }
179 }
180
181 fn now() -> DateTime<Utc> {
182 "2026-07-18T12:00:00Z".parse().unwrap()
183 }
184
185 fn world_with(tasks: &[(&str, Priority, &str)]) -> (World, BTreeMap<String, ResolvedStatus>) {
186 let mut w = World::default();
187 let mut statuses = BTreeMap::new();
188 for (id, priority, created_at) in tasks {
189 w.tasks.insert(
190 id.to_string(),
191 Task {
192 id: (*id).into(),
193 project_id: "p".into(),
194 title: format!("task {id}"),
195 body: String::new(),
196 priority: *priority,
197 labels: vec![],
198 created_by_dev: "d".into(),
199 created_by_machine: "m".into(),
200 created: created_at.to_string(),
201 },
202 );
203 statuses.insert(
204 id.to_string(),
205 ResolvedStatus {
206 status: Status::Open,
207 resolution: Resolution::Exact,
208 },
209 );
210 }
211 (w, statuses)
212 }
213
214 fn dep(w: &mut World, blocker: &str, blocked: &str) {
215 let id = dependency_id(blocker, blocked);
216 w.deps.insert(
217 id.clone(),
218 Dependency {
219 id: id.into(),
220 project_id: "p".into(),
221 blocker_task_id: blocker.into(),
222 blocked_task_id: blocked.into(),
223 },
224 );
225 }
226
227 fn close(statuses: &mut BTreeMap<String, ResolvedStatus>, id: &str) {
228 statuses.insert(
229 id.to_string(),
230 ResolvedStatus {
231 status: Status::Closed,
232 resolution: Resolution::Exact,
233 },
234 );
235 }
236
237 fn claim(w: &mut World, task: &str, dev: &str, machine: &str, worktree: &str, at: &str) {
238 w.claims.insert(
239 task.to_string(),
240 Claim {
241 id: task.into(),
242 project_id: "p".into(),
243 task_id: task.into(),
244 dev: dev.into(),
245 machine: machine.into(),
246 worktree: worktree.into(),
247 created: at.into(),
248 ttl_secs: 86400,
249 },
250 );
251 }
252
253 #[test]
254 fn priority_beats_unblock_count() {
255 let (mut w, statuses) = world_with(&[
256 ("aaaa", Priority::P1, "2026-07-01T00:00:00Z"),
257 ("bbbb", Priority::P0, "2026-07-02T00:00:00Z"),
258 ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
259 ("dddd", Priority::P2, "2026-07-04T00:00:00Z"),
260 ]);
261 dep(&mut w, "aaaa", "cccc");
263 dep(&mut w, "aaaa", "dddd");
264 let ranked = rank_next(&w, &statuses, now(), &me());
265 assert_eq!(ranked[0].task_id, "bbbb");
266 assert_eq!(ranked[1].task_id, "aaaa");
267 assert!(ranked[0].reason.starts_with("P0"));
268 }
269
270 #[test]
271 fn unblock_count_beats_age() {
272 let (mut w, statuses) = world_with(&[
273 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"), ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"), ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
276 ]);
277 dep(&mut w, "bbbb", "cccc");
278 let ranked = rank_next(&w, &statuses, now(), &me());
279 assert_eq!(ranked[0].task_id, "bbbb");
280 assert!(ranked[0].reason.contains("unblocks 1 open task"));
281 }
282
283 #[test]
284 fn transitive_unblocks_counts_chain_and_survives_cycles() {
285 let (mut w, mut statuses) = world_with(&[
286 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"),
287 ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"),
288 ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
289 ("dddd", Priority::P2, "2026-07-04T00:00:00Z"),
290 ]);
291 dep(&mut w, "aaaa", "bbbb");
293 dep(&mut w, "bbbb", "cccc");
294 dep(&mut w, "cccc", "aaaa");
295 dep(&mut w, "aaaa", "dddd");
296 close(&mut statuses, "dddd");
297 let ranked = rank_next(&w, &statuses, now(), &me());
298 assert!(ranked.is_empty());
302 close(&mut statuses, "cccc");
303 let ranked = rank_next(&w, &statuses, now(), &me());
304 assert_eq!(ranked[0].task_id, "aaaa");
305 assert_eq!(ranked[0].unblocks, 1);
307 }
308
309 #[test]
310 fn blocked_task_ineligible_until_blocker_closed() {
311 let (mut w, mut statuses) = world_with(&[
312 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"),
313 ("bbbb", Priority::P0, "2026-07-02T00:00:00Z"),
314 ]);
315 dep(&mut w, "aaaa", "bbbb");
316 let ranked = rank_next(&w, &statuses, now(), &me());
317 assert_eq!(
318 ranked
319 .iter()
320 .map(|r| r.task_id.as_str())
321 .collect::<Vec<_>>(),
322 ["aaaa"]
323 );
324 close(&mut statuses, "aaaa");
325 let ranked = rank_next(&w, &statuses, now(), &me());
326 assert_eq!(ranked[0].task_id, "bbbb");
327 }
328
329 #[test]
330 fn foreign_live_claim_excludes_but_own_or_expired_does_not() {
331 let (mut w, statuses) = world_with(&[
332 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"),
333 ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"),
334 ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
335 ]);
336 claim(
337 &mut w,
338 "aaaa",
339 "other@x",
340 "m2",
341 "/w2",
342 "2026-07-18T11:00:00Z",
343 ); claim(&mut w, "bbbb", "me@x", "m1", "/w1", "2026-07-18T11:00:00Z"); claim(
346 &mut w,
347 "cccc",
348 "other@x",
349 "m2",
350 "/w2",
351 "2026-07-01T00:00:00Z",
352 ); let ranked: Vec<_> = rank_next(&w, &statuses, now(), &me())
354 .into_iter()
355 .map(|r| r.task_id)
356 .collect();
357 assert_eq!(ranked, ["bbbb", "cccc"]);
358 }
359
360 #[test]
361 fn short_ids_and_prefix_resolution() {
362 let (w, _) = world_with(&[
363 ("3f2a99aabbcc", Priority::P2, "2026-07-01T00:00:00Z"),
364 ("3f2b00ddeeff", Priority::P2, "2026-07-02T00:00:00Z"),
365 ]);
366 assert_eq!(short_id(&w, "3f2a99aabbcc"), "lv-3f2a");
367 assert_eq!(resolve_prefix(&w, "lv-3f2a").unwrap(), "3f2a99aabbcc");
368 assert_eq!(resolve_prefix(&w, "3f2b").unwrap(), "3f2b00ddeeff");
369 assert!(matches!(
370 resolve_prefix(&w, "3f2"),
371 Err(PrefixError::Ambiguous(..))
372 ));
373 assert!(matches!(
374 resolve_prefix(&w, "9999"),
375 Err(PrefixError::NotFound(..))
376 ));
377 }
378
379 #[test]
380 fn short_id_lengthens_on_collision() {
381 let (w, _) = world_with(&[
382 ("3f2a99aabbcc", Priority::P2, "2026-07-01T00:00:00Z"),
383 ("3f2a99ffeedd", Priority::P2, "2026-07-02T00:00:00Z"),
384 ]);
385 assert_eq!(short_id(&w, "3f2a99aabbcc"), "lv-3f2a99aa");
386 }
387}