Skip to main content

levi_core/
resolve.rs

1//! Per-checkout status resolution (spec §Status resolution).
2//!
3//! Effective status of a task on a checkout = fold of its StatusChanges,
4//! restricted to those whose `anchor_commit` is an ancestor of HEAD
5//! (unanchored changes apply everywhere), ordered by (at, id). A change whose
6//! anchor's ancestry is *unknown* (missing commit facts / unfetched sha) is
7//! skipped — treated open — and downgrades the resolution to `Partial` so the
8//! caller can flag the task. No qualifying changes ⇒ open.
9
10use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
11
12use crate::entities::{CommitFact, StatusChange, StatusKind};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Ancestry {
16    Yes,
17    No,
18    Unknown,
19}
20
21/// Answers "is `sha` an ancestor of (or equal to) this checkout's head?".
22/// `&mut` so implementations can memoize.
23pub trait AncestorSet {
24    fn contains(&mut self, sha: &str) -> Ancestry;
25}
26
27impl<A: AncestorSet + ?Sized> AncestorSet for &mut A {
28    fn contains(&mut self, sha: &str) -> Ancestry {
29        (**self).contains(sha)
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum Status {
35    Open,
36    Closed,
37}
38
39impl Status {
40    pub fn label(self) -> &'static str {
41        match self {
42            Status::Open => "open",
43            Status::Closed => "closed",
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Resolution {
50    /// Ancestry answered by the real repo (gix merge-base).
51    Exact,
52    /// Ancestry answered by walking the CommitFact graph.
53    Facts,
54    /// At least one relevant anchor could not be resolved; unknown changes
55    /// were treated as open and the task should be flagged.
56    Partial,
57}
58
59impl Resolution {
60    pub fn label(self) -> &'static str {
61        match self {
62            Resolution::Exact => "exact",
63            Resolution::Facts => "facts",
64            Resolution::Partial => "partial",
65        }
66    }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct ResolvedStatus {
71    pub status: Status,
72    pub resolution: Resolution,
73}
74
75/// Fold `changes` (must already be in (at, id) order, as produced by
76/// `World::changes_for`) against an ancestor set. `base` is `Exact` or
77/// `Facts` depending on where `anc` gets its answers.
78pub fn effective_status(
79    changes: &[&StatusChange],
80    anc: &mut impl AncestorSet,
81    base: Resolution,
82) -> ResolvedStatus {
83    let mut status = Status::Open;
84    let mut resolution = base;
85    for change in changes {
86        let applies = match &change.anchor_commit {
87            None => true,
88            Some(sha) => match anc.contains(sha) {
89                Ancestry::Yes => true,
90                Ancestry::No => false,
91                Ancestry::Unknown => {
92                    resolution = Resolution::Partial;
93                    false
94                }
95            },
96        };
97        if applies {
98            status = match change.to_status {
99                StatusKind::Closed => Status::Closed,
100                StatusKind::Reopened => Status::Open,
101            };
102        }
103    }
104    ResolvedStatus { status, resolution }
105}
106
107/// Ancestor set over the CommitFact graph, for git-free nodes (the hub, the
108/// dashboard's branch selector). BFS closure from `head`; if the walk ever
109/// needs a sha with no fact, the closure is incomplete and non-members answer
110/// `Unknown` instead of `No` (spec: never silently closed — and never
111/// silently *not*-closed either).
112pub struct FactsAncestors {
113    reachable: HashSet<String>,
114    complete: bool,
115}
116
117impl FactsAncestors {
118    pub fn new(facts: &BTreeMap<String, CommitFact>, head: &str) -> Self {
119        let mut reachable = HashSet::new();
120        let mut complete = true;
121        let mut queue = VecDeque::from([head.to_string()]);
122        while let Some(sha) = queue.pop_front() {
123            if !reachable.insert(sha.clone()) {
124                continue;
125            }
126            match facts.get(&sha) {
127                Some(fact) => queue.extend(fact.parents.iter().cloned()),
128                None => complete = false,
129            }
130        }
131        // `head` itself only counts as reachable if we actually know it.
132        if !facts.contains_key(head) {
133            reachable.remove(head);
134            complete = false;
135        }
136        Self {
137            reachable,
138            complete,
139        }
140    }
141}
142
143impl AncestorSet for FactsAncestors {
144    fn contains(&mut self, sha: &str) -> Ancestry {
145        if self.reachable.contains(sha) {
146            Ancestry::Yes
147        } else if self.complete {
148            Ancestry::No
149        } else {
150            Ancestry::Unknown
151        }
152    }
153}
154
155/// Test helper: membership map, `Yes` iff contained, else `No`.
156pub struct MapAncestors(pub HashSet<String>);
157
158impl MapAncestors {
159    pub fn of<const N: usize>(shas: [&str; N]) -> Self {
160        Self(shas.iter().map(|s| s.to_string()).collect())
161    }
162}
163
164impl AncestorSet for MapAncestors {
165    fn contains(&mut self, sha: &str) -> Ancestry {
166        if self.0.contains(sha) {
167            Ancestry::Yes
168        } else {
169            Ancestry::No
170        }
171    }
172}
173
174/// Test helper: like MapAncestors but with an explicit unknown set.
175pub struct PartialMapAncestors {
176    pub yes: HashSet<String>,
177    pub unknown: HashSet<String>,
178}
179
180impl AncestorSet for PartialMapAncestors {
181    fn contains(&mut self, sha: &str) -> Ancestry {
182        if self.yes.contains(sha) {
183            Ancestry::Yes
184        } else if self.unknown.contains(sha) {
185            Ancestry::Unknown
186        } else {
187            Ancestry::No
188        }
189    }
190}
191
192/// Memoizing wrapper so repeated anchors don't repeat expensive lookups.
193pub struct CachedAncestors<A: AncestorSet> {
194    inner: A,
195    cache: HashMap<String, Ancestry>,
196}
197
198impl<A: AncestorSet> CachedAncestors<A> {
199    pub fn new(inner: A) -> Self {
200        Self {
201            inner,
202            cache: HashMap::new(),
203        }
204    }
205}
206
207impl<A: AncestorSet> AncestorSet for CachedAncestors<A> {
208    fn contains(&mut self, sha: &str) -> Ancestry {
209        if let Some(a) = self.cache.get(sha) {
210            return *a;
211        }
212        let a = self.inner.contains(sha);
213        self.cache.insert(sha.to_string(), a);
214        a
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn change(id: &str, kind: StatusKind, anchor: Option<&str>, at: &str) -> StatusChange {
223        StatusChange {
224            id: id.into(),
225            project_id: "p".into(),
226            task_id: "t1".into(),
227            to_status: kind,
228            anchor_commit: anchor.map(str::to_string),
229            created: at.into(),
230            by_dev: "d".into(),
231            by_machine: "m".into(),
232        }
233    }
234
235    fn resolve(changes: &[StatusChange], anc: &mut impl AncestorSet) -> ResolvedStatus {
236        let mut refs: Vec<&StatusChange> = changes.iter().collect();
237        refs.sort_by(|a, b| (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0)));
238        effective_status(&refs, anc, Resolution::Exact)
239    }
240
241    #[test]
242    fn no_changes_means_open() {
243        let r = resolve(&[], &mut MapAncestors::of([]));
244        assert_eq!(
245            r,
246            ResolvedStatus {
247                status: Status::Open,
248                resolution: Resolution::Exact
249            }
250        );
251    }
252
253    #[test]
254    fn close_anchored_on_ancestor_closes_here_only() {
255        let cs = [change(
256            "a",
257            StatusKind::Closed,
258            Some("sha1"),
259            "2026-07-01T00:00:00Z",
260        )];
261        // On a checkout containing sha1:
262        assert_eq!(
263            resolve(&cs, &mut MapAncestors::of(["sha1"])).status,
264            Status::Closed
265        );
266        // On a branch that doesn't contain it:
267        assert_eq!(resolve(&cs, &mut MapAncestors::of([])).status, Status::Open);
268    }
269
270    #[test]
271    fn close_then_reopen_both_in_ancestry() {
272        let cs = [
273            change(
274                "a",
275                StatusKind::Closed,
276                Some("sha1"),
277                "2026-07-01T00:00:00Z",
278            ),
279            change(
280                "b",
281                StatusKind::Reopened,
282                Some("sha2"),
283                "2026-07-02T00:00:00Z",
284            ),
285        ];
286        assert_eq!(
287            resolve(&cs, &mut MapAncestors::of(["sha1", "sha2"])).status,
288            Status::Open
289        );
290        // Reopen anchored elsewhere: still closed here.
291        assert_eq!(
292            resolve(&cs, &mut MapAncestors::of(["sha1"])).status,
293            Status::Closed
294        );
295    }
296
297    #[test]
298    fn unanchored_changes_apply_everywhere() {
299        let closed_everywhere = [change(
300            "a",
301            StatusKind::Closed,
302            None,
303            "2026-07-01T00:00:00Z",
304        )];
305        assert_eq!(
306            resolve(&closed_everywhere, &mut MapAncestors::of([])).status,
307            Status::Closed
308        );
309
310        let reopened_everywhere = [
311            change(
312                "a",
313                StatusKind::Closed,
314                Some("sha1"),
315                "2026-07-01T00:00:00Z",
316            ),
317            change("b", StatusKind::Reopened, None, "2026-07-02T00:00:00Z"),
318        ];
319        assert_eq!(
320            resolve(&reopened_everywhere, &mut MapAncestors::of(["sha1"])).status,
321            Status::Open
322        );
323    }
324
325    #[test]
326    fn lww_tie_on_at_broken_by_change_id() {
327        let at = "2026-07-01T00:00:00Z";
328        let cs = [
329            change("zz", StatusKind::Closed, None, at),
330            change("aa", StatusKind::Reopened, None, at),
331        ];
332        // "zz" sorts after "aa", so the close wins.
333        assert_eq!(
334            resolve(&cs, &mut MapAncestors::of([])).status,
335            Status::Closed
336        );
337    }
338
339    #[test]
340    fn unknown_anchor_treated_open_and_flagged() {
341        let cs = [change(
342            "a",
343            StatusKind::Closed,
344            Some("ghost"),
345            "2026-07-01T00:00:00Z",
346        )];
347        let mut anc = PartialMapAncestors {
348            yes: HashSet::new(),
349            unknown: HashSet::from(["ghost".to_string()]),
350        };
351        let r = resolve(&cs, &mut anc);
352        assert_eq!(r.status, Status::Open);
353        assert_eq!(r.resolution, Resolution::Partial);
354    }
355
356    #[test]
357    fn unknown_reopen_with_later_solid_close_stays_closed_but_partial() {
358        let cs = [
359            change(
360                "a",
361                StatusKind::Reopened,
362                Some("ghost"),
363                "2026-07-01T00:00:00Z",
364            ),
365            change(
366                "b",
367                StatusKind::Closed,
368                Some("sha1"),
369                "2026-07-02T00:00:00Z",
370            ),
371        ];
372        let mut anc = PartialMapAncestors {
373            yes: HashSet::from(["sha1".to_string()]),
374            unknown: HashSet::from(["ghost".to_string()]),
375        };
376        let r = resolve(&cs, &mut anc);
377        assert_eq!(r.status, Status::Closed);
378        assert_eq!(r.resolution, Resolution::Partial);
379    }
380
381    fn facts(edges: &[(&str, &[&str])]) -> BTreeMap<String, CommitFact> {
382        edges
383            .iter()
384            .map(|(sha, parents)| {
385                (
386                    sha.to_string(),
387                    CommitFact {
388                        id: (*sha).into(),
389                        project_id: "p".into(),
390                        parents: parents.iter().map(|p| p.to_string()).collect(),
391                    },
392                )
393            })
394            .collect()
395    }
396
397    #[test]
398    fn facts_linear_chain() {
399        // c3 -> c2 -> c1 (root)
400        let f = facts(&[("c1", &[]), ("c2", &["c1"]), ("c3", &["c2"])]);
401        let mut anc = FactsAncestors::new(&f, "c3");
402        assert_eq!(anc.contains("c1"), Ancestry::Yes);
403        assert_eq!(anc.contains("c3"), Ancestry::Yes);
404        let mut anc_at_c2 = FactsAncestors::new(&f, "c2");
405        assert_eq!(anc_at_c2.contains("c3"), Ancestry::No);
406    }
407
408    #[test]
409    fn facts_diamond_merge() {
410        // m -> {a, b} -> root
411        let f = facts(&[
412            ("root", &[]),
413            ("a", &["root"]),
414            ("b", &["root"]),
415            ("m", &["a", "b"]),
416        ]);
417        let mut anc = FactsAncestors::new(&f, "m");
418        assert_eq!(anc.contains("a"), Ancestry::Yes);
419        assert_eq!(anc.contains("b"), Ancestry::Yes);
420        assert_eq!(anc.contains("root"), Ancestry::Yes);
421    }
422
423    #[test]
424    fn facts_incomplete_graph_answers_unknown_for_absent() {
425        // c2's parent c1 has no fact: closure incomplete.
426        let f = facts(&[("c2", &["c1"]), ("c3", &["c2"])]);
427        let mut anc = FactsAncestors::new(&f, "c3");
428        assert_eq!(anc.contains("c2"), Ancestry::Yes); // actually reached
429        assert_eq!(anc.contains("other"), Ancestry::Unknown); // can't rule out
430    }
431
432    #[test]
433    fn facts_missing_head_is_all_unknown() {
434        let f = facts(&[("c1", &[])]);
435        let mut anc = FactsAncestors::new(&f, "nope");
436        assert_eq!(anc.contains("c1"), Ancestry::Unknown);
437        assert_eq!(anc.contains("nope"), Ancestry::Unknown);
438    }
439}