Skip to main content

levi_core/
rank.rs

1//! Ranking for `levi next` (spec §Ranking).
2//!
3//! Eligible = open on this checkout ∧ every blocker closed on this checkout ∧
4//! no live claim by someone else. Order: priority (P0 first) → transitive
5//! unblock count (closing this frees the most open work) → age (oldest
6//! first) → task id. Deterministic and explainable.
7
8use 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    /// Minted per-machine UUID (see levi's state layer). Empty = unknown.
21    pub machine_id: String,
22    pub worktree: String,
23}
24
25/// Is this claim held by `me`? Compares by machine_id when both sides have
26/// one; falls back to the display name for legacy events (pre-machine-id).
27pub 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
45/// Rank all eligible tasks, best first. `statuses` must contain a resolved
46/// status for every task in `world`; `foreign` maps
47/// "{project_id}/{task_id}" to the resolved status of cross-project
48/// blockers (missing key = unknown = still blocking, spec: never silently
49/// unblocked).
50pub 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    // blocker -> directly blocked open tasks
65    let mut blocks: HashMap<&str, Vec<&str>> = HashMap::new();
66    // blocked -> blockers
67    let mut blocked_by: HashMap<&str, Vec<&str>> = HashMap::new();
68    // blocked -> unmet cross-project blocker keys
69    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            // Cross-project: satisfied iff the ladder says Closed.
76            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        // Same-project deps referencing deleted tasks are ignored.
83        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
163/// Count open tasks transitively blocked by `id` (cycle-safe).
164fn 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        // aaaa unblocks two tasks; bbbb unblocks none but is P0.
298        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"), // older
310            ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"), // newer but unblocks
311            ("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        // aaaa -> bbbb -> cccc, and a cycle cccc -> aaaa; dddd closed.
328        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        // Everything is blocked by an open blocker except… aaaa is blocked by
335        // cccc (open), bbbb by aaaa (open), cccc by bbbb (open): cycle makes
336        // all ineligible. Break it: close cccc.
337        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        // aaaa unblocks bbbb (open) transitively; cccc and dddd are closed.
342        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        ); // foreign, live
380        claim(&mut w, "bbbb", "me@x", "m1", "/w1", "2026-07-18T11:00:00Z"); // mine
381        claim(
382            &mut w,
383            "cccc",
384            "other@x",
385            "m2",
386            "/w2",
387            "2026-07-01T00:00:00Z",
388        ); // expired
389        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        // Unknown foreign status (missing key): blocked.
440        let ranked = rank_next(&w, &statuses, &BTreeMap::new(), now(), &me());
441        assert!(ranked.is_empty());
442        // Foreign open: still blocked.
443        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 closed: eligible.
447        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        // Both have ids: ids decide, names ignored.
472        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        // Either side legacy (empty id): display name decides.
475        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}