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    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
32/// Rank all eligible tasks, best first. `statuses` must contain a resolved
33/// status for every task in `world`.
34pub 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    // blocker -> directly blocked open tasks
48    let mut blocks: HashMap<&str, Vec<&str>> = HashMap::new();
49    // blocked -> blockers
50    let mut blocked_by: HashMap<&str, Vec<&str>> = HashMap::new();
51    for dep in world.deps.values() {
52        // Deps referencing deleted tasks are ignored.
53        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
132/// Count open tasks transitively blocked by `id` (cycle-safe).
133fn 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        // aaaa unblocks two tasks; bbbb unblocks none but is P0.
262        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"), // older
274            ("bbbb", Priority::P2, "2026-07-02T00:00:00Z"), // newer but unblocks
275            ("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        // aaaa -> bbbb -> cccc, and a cycle cccc -> aaaa; dddd closed.
292        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        // Everything is blocked by an open blocker except… aaaa is blocked by
299        // cccc (open), bbbb by aaaa (open), cccc by bbbb (open): cycle makes
300        // all ineligible. Break it: close cccc.
301        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        // aaaa unblocks bbbb (open) transitively; cccc and dddd are closed.
306        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        ); // foreign, live
344        claim(&mut w, "bbbb", "me@x", "m1", "/w1", "2026-07-18T11:00:00Z"); // mine
345        claim(
346            &mut w,
347            "cccc",
348            "other@x",
349            "m2",
350            "/w2",
351            "2026-07-01T00:00:00Z",
352        ); // expired
353        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}