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 machine_id: String,
22 pub worktree: String,
23}
24
25pub fn claim_is(claim: &crate::entities::Claim, me: &Identity) -> bool {
28 let machine_matches = if !claim.machine_id.is_empty() && !me.machine_id.is_empty() {
29 claim.machine_id == me.machine_id
30 } else {
31 claim.machine == me.machine
32 };
33 claim.dev == me.dev && machine_matches && claim.worktree == me.worktree
34}
35
36#[derive(Debug, Clone)]
37pub struct RankedTask {
38 pub task_id: String,
39 pub priority: Priority,
40 pub unblocks: usize,
41 pub created: String,
42 pub reason: String,
43}
44
45pub fn rank_next(
51 world: &World,
52 statuses: &BTreeMap<String, ResolvedStatus>,
53 foreign: &BTreeMap<String, Status>,
54 now: DateTime<Utc>,
55 me: &Identity,
56) -> Vec<RankedTask> {
57 let is_open = |id: &str| {
58 statuses
59 .get(id)
60 .map(|r| r.status == Status::Open)
61 .unwrap_or(false)
62 };
63
64 let mut blocks: HashMap<&str, Vec<&str>> = HashMap::new();
66 let mut blocked_by: HashMap<&str, Vec<&str>> = HashMap::new();
68 let mut foreign_blocked: HashMap<&str, bool> = HashMap::new();
70 for dep in world.deps.values() {
71 if !world.tasks.contains_key(&*dep.blocked_task_id) {
72 continue;
73 }
74 if let Some(blocker_project) = &dep.blocker_project_id {
75 let key = crate::entities::foreign_key(blocker_project, &dep.blocker_task_id);
77 let closed = foreign.get(&key) == Some(&Status::Closed);
78 let entry = foreign_blocked.entry(&dep.blocked_task_id).or_insert(false);
79 *entry |= !closed;
80 continue;
81 }
82 if !world.tasks.contains_key(&*dep.blocker_task_id) {
84 continue;
85 }
86 blocks
87 .entry(&dep.blocker_task_id)
88 .or_default()
89 .push(&dep.blocked_task_id);
90 blocked_by
91 .entry(&dep.blocked_task_id)
92 .or_default()
93 .push(&dep.blocker_task_id);
94 }
95
96 let mut candidates: Vec<RankedTask> = Vec::new();
97 let mut eligible_count = 0usize;
98 for (id, task) in &world.tasks {
99 if !is_open(id) {
100 continue;
101 }
102 let all_blockers_closed = blocked_by
103 .get(id.as_str())
104 .map(|bs| bs.iter().all(|b| !is_open(b)))
105 .unwrap_or(true);
106 if !all_blockers_closed {
107 continue;
108 }
109 if foreign_blocked.get(id.as_str()).copied().unwrap_or(false) {
110 continue;
111 }
112 if let Some(claim) = world.live_claim(id, now) {
113 let mine =
114 claim.dev == me.dev && claim.machine == me.machine && claim.worktree == me.worktree;
115 if !mine {
116 continue;
117 }
118 }
119 eligible_count += 1;
120 candidates.push(RankedTask {
121 task_id: id.clone(),
122 priority: task.priority,
123 unblocks: transitive_unblocks(id, &blocks, &is_open),
124 created: task.created.clone(),
125 reason: String::new(),
126 });
127 }
128
129 candidates.sort_by(|a, b| {
130 (
131 a.priority.rank(),
132 std::cmp::Reverse(a.unblocks),
133 a.created.as_str(),
134 a.task_id.as_str(),
135 )
136 .cmp(&(
137 b.priority.rank(),
138 std::cmp::Reverse(b.unblocks),
139 b.created.as_str(),
140 b.task_id.as_str(),
141 ))
142 });
143
144 for (i, c) in candidates.iter_mut().enumerate() {
145 let mut parts = vec![c.priority.label().to_string()];
146 if c.unblocks > 0 {
147 parts.push(format!(
148 "unblocks {} open task{}",
149 c.unblocks,
150 if c.unblocks == 1 { "" } else { "s" }
151 ));
152 }
153 parts.push(if i == 0 {
154 format!("ranked 1st of {eligible_count} eligible")
155 } else {
156 format!("ranked {} of {eligible_count} eligible", ordinal(i + 1))
157 });
158 c.reason = parts.join("; ");
159 }
160 candidates
161}
162
163fn transitive_unblocks(
165 id: &str,
166 blocks: &HashMap<&str, Vec<&str>>,
167 is_open: &impl Fn(&str) -> bool,
168) -> usize {
169 let mut seen: HashSet<&str> = HashSet::new();
170 let mut stack: Vec<&str> = blocks.get(id).cloned().unwrap_or_default();
171 let mut count = 0;
172 while let Some(next) = stack.pop() {
173 if next == id || !seen.insert(next) {
174 continue;
175 }
176 if is_open(next) {
177 count += 1;
178 }
179 if let Some(more) = blocks.get(next) {
180 stack.extend(more);
181 }
182 }
183 count
184}
185
186fn ordinal(n: usize) -> String {
187 let suffix = match (n % 10, n % 100) {
188 (1, 11) | (2, 12) | (3, 13) => "th",
189 (1, _) => "st",
190 (2, _) => "nd",
191 (3, _) => "rd",
192 _ => "th",
193 };
194 format!("{n}{suffix}")
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::entities::*;
201 use crate::ids::{PrefixError, resolve_prefix, short_id};
202 use crate::resolve::Resolution;
203
204 fn me() -> Identity {
205 Identity {
206 dev: "me@x".into(),
207 machine: "m1".into(),
208 machine_id: String::new(),
209 worktree: "/w1".into(),
210 }
211 }
212
213 fn now() -> DateTime<Utc> {
214 "2026-07-18T12:00:00Z".parse().unwrap()
215 }
216
217 fn world_with(tasks: &[(&str, Priority, &str)]) -> (World, BTreeMap<String, ResolvedStatus>) {
218 let mut w = World::default();
219 let mut statuses = BTreeMap::new();
220 for (id, priority, created_at) in tasks {
221 w.tasks.insert(
222 id.to_string(),
223 Task {
224 id: (*id).into(),
225 project_id: "p".into(),
226 title: format!("task {id}"),
227 body: String::new(),
228 priority: *priority,
229 labels: vec![],
230 created_by_dev: "d".into(),
231 created_by_machine: "m".into(),
232 created: created_at.to_string(),
233 },
234 );
235 statuses.insert(
236 id.to_string(),
237 ResolvedStatus {
238 status: Status::Open,
239 resolution: Resolution::Exact,
240 },
241 );
242 }
243 (w, statuses)
244 }
245
246 fn dep(w: &mut World, blocker: &str, blocked: &str) {
247 let id = dependency_id(blocker, blocked);
248 w.deps.insert(
249 id.clone(),
250 Dependency {
251 id: id.into(),
252 project_id: "p".into(),
253 blocker_task_id: blocker.into(),
254 blocked_task_id: blocked.into(),
255 blocker_project_id: None,
256 blocker_ref: None,
257 via: None,
258 },
259 );
260 }
261
262 fn close(statuses: &mut BTreeMap<String, ResolvedStatus>, id: &str) {
263 statuses.insert(
264 id.to_string(),
265 ResolvedStatus {
266 status: Status::Closed,
267 resolution: Resolution::Exact,
268 },
269 );
270 }
271
272 fn claim(w: &mut World, task: &str, dev: &str, machine: &str, worktree: &str, at: &str) {
273 w.claims.insert(
274 task.to_string(),
275 Claim {
276 id: task.into(),
277 project_id: "p".into(),
278 task_id: task.into(),
279 dev: dev.into(),
280 machine: machine.into(),
281 worktree: worktree.into(),
282 machine_id: String::new(),
283 created: at.into(),
284 ttl_secs: 86400,
285 },
286 );
287 }
288
289 #[test]
290 fn priority_beats_unblock_count() {
291 let (mut w, statuses) = world_with(&[
292 ("aaaa", Priority::P1, "2026-07-01T00:00:00Z"),
293 ("bbbb", Priority::P0, "2026-07-02T00:00:00Z"),
294 ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
295 ("dddd", Priority::P2, "2026-07-04T00:00:00Z"),
296 ]);
297 dep(&mut w, "aaaa", "cccc");
299 dep(&mut w, "aaaa", "dddd");
300 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
301 assert_eq!(ranked[0].task_id, "bbbb");
302 assert_eq!(ranked[1].task_id, "aaaa");
303 assert!(ranked[0].reason.starts_with("P0"));
304 }
305
306 #[test]
307 fn unblock_count_beats_age() {
308 let (mut w, statuses) = world_with(&[
309 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"), ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"), ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
312 ]);
313 dep(&mut w, "bbbb", "cccc");
314 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
315 assert_eq!(ranked[0].task_id, "bbbb");
316 assert!(ranked[0].reason.contains("unblocks 1 open task"));
317 }
318
319 #[test]
320 fn transitive_unblocks_counts_chain_and_survives_cycles() {
321 let (mut w, mut statuses) = world_with(&[
322 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"),
323 ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"),
324 ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
325 ("dddd", Priority::P2, "2026-07-04T00:00:00Z"),
326 ]);
327 dep(&mut w, "aaaa", "bbbb");
329 dep(&mut w, "bbbb", "cccc");
330 dep(&mut w, "cccc", "aaaa");
331 dep(&mut w, "aaaa", "dddd");
332 close(&mut statuses, "dddd");
333 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
334 assert!(ranked.is_empty());
338 close(&mut statuses, "cccc");
339 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
340 assert_eq!(ranked[0].task_id, "aaaa");
341 assert_eq!(ranked[0].unblocks, 1);
343 }
344
345 #[test]
346 fn blocked_task_ineligible_until_blocker_closed() {
347 let (mut w, mut statuses) = world_with(&[
348 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"),
349 ("bbbb", Priority::P0, "2026-07-02T00:00:00Z"),
350 ]);
351 dep(&mut w, "aaaa", "bbbb");
352 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
353 assert_eq!(
354 ranked
355 .iter()
356 .map(|r| r.task_id.as_str())
357 .collect::<Vec<_>>(),
358 ["aaaa"]
359 );
360 close(&mut statuses, "aaaa");
361 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
362 assert_eq!(ranked[0].task_id, "bbbb");
363 }
364
365 #[test]
366 fn foreign_live_claim_excludes_but_own_or_expired_does_not() {
367 let (mut w, statuses) = world_with(&[
368 ("aaaa", Priority::P2, "2026-07-01T00:00:00Z"),
369 ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"),
370 ("cccc", Priority::P2, "2026-07-03T00:00:00Z"),
371 ]);
372 claim(
373 &mut w,
374 "aaaa",
375 "other@x",
376 "m2",
377 "/w2",
378 "2026-07-18T11:00:00Z",
379 ); claim(&mut w, "bbbb", "me@x", "m1", "/w1", "2026-07-18T11:00:00Z"); claim(
382 &mut w,
383 "cccc",
384 "other@x",
385 "m2",
386 "/w2",
387 "2026-07-01T00:00:00Z",
388 ); let ranked: Vec<_> = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me())
390 .into_iter()
391 .map(|r| r.task_id)
392 .collect();
393 assert_eq!(ranked, ["bbbb", "cccc"]);
394 }
395
396 #[test]
397 fn short_ids_and_prefix_resolution() {
398 let (w, _) = world_with(&[
399 ("3f2a99aabbcc", Priority::P2, "2026-07-01T00:00:00Z"),
400 ("3f2b00ddeeff", Priority::P2, "2026-07-02T00:00:00Z"),
401 ]);
402 assert_eq!(short_id(&w, "3f2a99aabbcc"), "lv-3f2a");
403 assert_eq!(resolve_prefix(&w, "lv-3f2a").unwrap(), "3f2a99aabbcc");
404 assert_eq!(resolve_prefix(&w, "3f2b").unwrap(), "3f2b00ddeeff");
405 assert!(matches!(
406 resolve_prefix(&w, "3f2"),
407 Err(PrefixError::Ambiguous(..))
408 ));
409 assert!(matches!(
410 resolve_prefix(&w, "9999"),
411 Err(PrefixError::NotFound(..))
412 ));
413 }
414
415 #[test]
416 fn short_id_lengthens_on_collision() {
417 let (w, _) = world_with(&[
418 ("3f2a99aabbcc", Priority::P2, "2026-07-01T00:00:00Z"),
419 ("3f2a99ffeedd", Priority::P2, "2026-07-02T00:00:00Z"),
420 ]);
421 assert_eq!(short_id(&w, "3f2a99aabbcc"), "lv-3f2a99aa");
422 }
423
424 #[test]
425 fn foreign_dep_blocks_until_ladder_says_closed() {
426 let (mut w, statuses) = world_with(&[("aaaa", Priority::P0, "2026-07-01T00:00:00Z")]);
427 w.deps.insert(
428 "projB/ffff->aaaa".into(),
429 Dependency {
430 id: "projB/ffff->aaaa".into(),
431 project_id: "p".into(),
432 blocker_task_id: "ffff".into(),
433 blocked_task_id: "aaaa".into(),
434 blocker_project_id: Some("projB".into()),
435 blocker_ref: None,
436 via: Some("cargo: crates.io".into()),
437 },
438 );
439 let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
441 assert!(ranked.is_empty());
442 let mut foreign = BTreeMap::new();
444 foreign.insert("projB/ffff".to_string(), Status::Open);
445 assert!(rank_next(&w, &statuses, &foreign, now(), &me()).is_empty());
446 foreign.insert("projB/ffff".to_string(), Status::Closed);
448 let ranked = rank_next(&w, &statuses, &foreign, now(), &me());
449 assert_eq!(ranked[0].task_id, "aaaa");
450 }
451
452 #[test]
453 fn claim_is_prefers_machine_id_with_legacy_fallback() {
454 let claim = |machine: &str, machine_id: &str| Claim {
455 id: "t".into(),
456 project_id: "p".into(),
457 task_id: "t".into(),
458 dev: "me@x".into(),
459 machine: machine.into(),
460 machine_id: machine_id.into(),
461 worktree: "/w1".into(),
462 created: "2026-07-01T00:00:00Z".into(),
463 ttl_secs: 86400,
464 };
465 let me_with = |machine_id: &str| Identity {
466 dev: "me@x".into(),
467 machine: "m1".into(),
468 machine_id: machine_id.into(),
469 worktree: "/w1".into(),
470 };
471 assert!(claim_is(&claim("other-name", "id-1"), &me_with("id-1")));
473 assert!(!claim_is(&claim("m1", "id-2"), &me_with("id-1")));
474 assert!(claim_is(&claim("m1", ""), &me_with("id-1")));
476 assert!(claim_is(&claim("m1", "id-1"), &me_with("")));
477 assert!(!claim_is(&claim("m2", ""), &me_with("id-1")));
478 }
479}