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    /// The exact anchor isn't present, but a patch-id-equivalent commit is.
20    Rewritten,
21}
22
23/// Answers "is `sha` an ancestor of (or equal to) this checkout's head?".
24/// `&mut` so implementations can memoize.
25pub trait AncestorSet {
26    fn contains(&mut self, sha: &str) -> Ancestry;
27}
28
29impl<A: AncestorSet + ?Sized> AncestorSet for &mut A {
30    fn contains(&mut self, sha: &str) -> Ancestry {
31        (**self).contains(sha)
32    }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub enum Status {
37    Open,
38    Closed,
39}
40
41impl Status {
42    pub fn label(self) -> &'static str {
43        match self {
44            Status::Open => "open",
45            Status::Closed => "closed",
46        }
47    }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Resolution {
52    /// Ancestry answered by the real repo (gix merge-base).
53    Exact,
54    /// Ancestry answered by walking the CommitFact graph.
55    Facts,
56    /// At least one relevant anchor could not be resolved; unknown changes
57    /// were treated as open and the task should be flagged.
58    Partial,
59    /// Resolved via a patch-id match (squash/rebase/cherry-pick), not the
60    /// exact anchor — an inferred close, honestly flagged.
61    Squashed,
62}
63
64impl Resolution {
65    pub fn label(self) -> &'static str {
66        match self {
67            Resolution::Exact => "exact",
68            Resolution::Facts => "facts",
69            Resolution::Partial => "partial",
70            Resolution::Squashed => "squashed",
71        }
72    }
73
74    /// Keep the weaker of two grades. Order: Partial < Squashed < Facts/Exact.
75    pub fn weaken(self, other: Resolution) -> Resolution {
76        fn rank(r: Resolution) -> u8 {
77            match r {
78                Resolution::Partial => 0,
79                Resolution::Squashed => 1,
80                Resolution::Facts | Resolution::Exact => 2,
81            }
82        }
83        if rank(other) < rank(self) {
84            other
85        } else {
86            self
87        }
88    }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct ResolvedStatus {
93    pub status: Status,
94    pub resolution: Resolution,
95}
96
97/// Fold `changes` (must already be in (at, id) order, as produced by
98/// `World::changes_for`) against an ancestor set. `base` is `Exact` or
99/// `Facts` depending on where `anc` gets its answers.
100pub fn effective_status(
101    changes: &[&StatusChange],
102    anc: &mut impl AncestorSet,
103    base: Resolution,
104) -> ResolvedStatus {
105    let mut status = Status::Open;
106    let mut resolution = base;
107    for change in changes {
108        let applies = match &change.anchor_commit {
109            None => true,
110            Some(sha) => match anc.contains(sha) {
111                Ancestry::Yes => true,
112                Ancestry::Rewritten => {
113                    resolution = resolution.weaken(Resolution::Squashed);
114                    true
115                }
116                Ancestry::No => false,
117                Ancestry::Unknown => {
118                    resolution = resolution.weaken(Resolution::Partial);
119                    false
120                }
121            },
122        };
123        if applies {
124            status = match change.to_status {
125                StatusKind::Closed => Status::Closed,
126                StatusKind::Reopened => Status::Open,
127            };
128        }
129    }
130    ResolvedStatus { status, resolution }
131}
132
133/// Ancestor set over the CommitFact graph, for git-free nodes (the hub, the
134/// dashboard's branch selector). BFS closure from `head`; if the walk ever
135/// needs a sha with no fact, the closure is incomplete and non-members answer
136/// `Unknown` instead of `No` (spec: never silently closed — and never
137/// silently *not*-closed either).
138pub struct FactsAncestors {
139    reachable: HashSet<String>,
140    complete: bool,
141    /// patch-ids of commits reachable from head (for squash matching).
142    reachable_patches: HashSet<String>,
143    /// sha -> patch-id for every known fact (to look up an anchor's patch-id).
144    patch_of: HashMap<String, String>,
145}
146
147impl FactsAncestors {
148    pub fn new(facts: &BTreeMap<String, CommitFact>, head: &str) -> Self {
149        let mut reachable = HashSet::new();
150        let mut complete = true;
151        let mut queue = VecDeque::from([head.to_string()]);
152        while let Some(sha) = queue.pop_front() {
153            if !reachable.insert(sha.clone()) {
154                continue;
155            }
156            match facts.get(&sha) {
157                Some(fact) => queue.extend(fact.parents.iter().cloned()),
158                None => complete = false,
159            }
160        }
161        // `head` itself only counts as reachable if we actually know it.
162        if !facts.contains_key(head) {
163            reachable.remove(head);
164            complete = false;
165        }
166        let patch_of: HashMap<String, String> = facts
167            .iter()
168            .filter_map(|(sha, f)| f.patch_id.clone().map(|p| (sha.clone(), p)))
169            .collect();
170        let reachable_patches: HashSet<String> = reachable
171            .iter()
172            .filter_map(|sha| patch_of.get(sha).cloned())
173            .collect();
174        Self {
175            reachable,
176            complete,
177            reachable_patches,
178            patch_of,
179        }
180    }
181}
182
183impl AncestorSet for FactsAncestors {
184    fn contains(&mut self, sha: &str) -> Ancestry {
185        if self.reachable.contains(sha) {
186            return Ancestry::Yes;
187        }
188        // Squash/rebase: the exact sha is gone, but its diff (patch-id) may be
189        // present in head's ancestry under a new sha.
190        if let Some(patch) = self.patch_of.get(sha)
191            && self.reachable_patches.contains(patch)
192        {
193            return Ancestry::Rewritten;
194        }
195        if self.complete {
196            Ancestry::No
197        } else {
198            Ancestry::Unknown
199        }
200    }
201}
202
203/// Test helper: membership map, `Yes` iff contained, else `No`.
204pub struct MapAncestors(pub HashSet<String>);
205
206impl MapAncestors {
207    pub fn of<const N: usize>(shas: [&str; N]) -> Self {
208        Self(shas.iter().map(|s| s.to_string()).collect())
209    }
210}
211
212impl AncestorSet for MapAncestors {
213    fn contains(&mut self, sha: &str) -> Ancestry {
214        if self.0.contains(sha) {
215            Ancestry::Yes
216        } else {
217            Ancestry::No
218        }
219    }
220}
221
222/// Test helper: like MapAncestors but with an explicit unknown set.
223pub struct PartialMapAncestors {
224    pub yes: HashSet<String>,
225    pub unknown: HashSet<String>,
226}
227
228impl AncestorSet for PartialMapAncestors {
229    fn contains(&mut self, sha: &str) -> Ancestry {
230        if self.yes.contains(sha) {
231            Ancestry::Yes
232        } else if self.unknown.contains(sha) {
233            Ancestry::Unknown
234        } else {
235            Ancestry::No
236        }
237    }
238}
239
240/// Memoizing wrapper so repeated anchors don't repeat expensive lookups.
241pub struct CachedAncestors<A: AncestorSet> {
242    inner: A,
243    cache: HashMap<String, Ancestry>,
244}
245
246impl<A: AncestorSet> CachedAncestors<A> {
247    pub fn new(inner: A) -> Self {
248        Self {
249            inner,
250            cache: HashMap::new(),
251        }
252    }
253}
254
255impl<A: AncestorSet> AncestorSet for CachedAncestors<A> {
256    fn contains(&mut self, sha: &str) -> Ancestry {
257        if let Some(a) = self.cache.get(sha) {
258            return *a;
259        }
260        let a = self.inner.contains(sha);
261        self.cache.insert(sha.to_string(), a);
262        a
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    fn change(id: &str, kind: StatusKind, anchor: Option<&str>, at: &str) -> StatusChange {
271        StatusChange {
272            id: id.into(),
273            project_id: "p".into(),
274            task_id: "t1".into(),
275            to_status: kind,
276            anchor_commit: anchor.map(str::to_string),
277            created: at.into(),
278            by_dev: "d".into(),
279            by_machine: "m".into(),
280        }
281    }
282
283    fn resolve(changes: &[StatusChange], anc: &mut impl AncestorSet) -> ResolvedStatus {
284        let mut refs: Vec<&StatusChange> = changes.iter().collect();
285        refs.sort_by(|a, b| (a.created.as_str(), &*a.id.0).cmp(&(b.created.as_str(), &*b.id.0)));
286        effective_status(&refs, anc, Resolution::Exact)
287    }
288
289    #[test]
290    fn no_changes_means_open() {
291        let r = resolve(&[], &mut MapAncestors::of([]));
292        assert_eq!(
293            r,
294            ResolvedStatus {
295                status: Status::Open,
296                resolution: Resolution::Exact
297            }
298        );
299    }
300
301    #[test]
302    fn close_anchored_on_ancestor_closes_here_only() {
303        let cs = [change(
304            "a",
305            StatusKind::Closed,
306            Some("sha1"),
307            "2026-07-01T00:00:00Z",
308        )];
309        // On a checkout containing sha1:
310        assert_eq!(
311            resolve(&cs, &mut MapAncestors::of(["sha1"])).status,
312            Status::Closed
313        );
314        // On a branch that doesn't contain it:
315        assert_eq!(resolve(&cs, &mut MapAncestors::of([])).status, Status::Open);
316    }
317
318    #[test]
319    fn close_then_reopen_both_in_ancestry() {
320        let cs = [
321            change(
322                "a",
323                StatusKind::Closed,
324                Some("sha1"),
325                "2026-07-01T00:00:00Z",
326            ),
327            change(
328                "b",
329                StatusKind::Reopened,
330                Some("sha2"),
331                "2026-07-02T00:00:00Z",
332            ),
333        ];
334        assert_eq!(
335            resolve(&cs, &mut MapAncestors::of(["sha1", "sha2"])).status,
336            Status::Open
337        );
338        // Reopen anchored elsewhere: still closed here.
339        assert_eq!(
340            resolve(&cs, &mut MapAncestors::of(["sha1"])).status,
341            Status::Closed
342        );
343    }
344
345    #[test]
346    fn unanchored_changes_apply_everywhere() {
347        let closed_everywhere = [change(
348            "a",
349            StatusKind::Closed,
350            None,
351            "2026-07-01T00:00:00Z",
352        )];
353        assert_eq!(
354            resolve(&closed_everywhere, &mut MapAncestors::of([])).status,
355            Status::Closed
356        );
357
358        let reopened_everywhere = [
359            change(
360                "a",
361                StatusKind::Closed,
362                Some("sha1"),
363                "2026-07-01T00:00:00Z",
364            ),
365            change("b", StatusKind::Reopened, None, "2026-07-02T00:00:00Z"),
366        ];
367        assert_eq!(
368            resolve(&reopened_everywhere, &mut MapAncestors::of(["sha1"])).status,
369            Status::Open
370        );
371    }
372
373    #[test]
374    fn lww_tie_on_at_broken_by_change_id() {
375        let at = "2026-07-01T00:00:00Z";
376        let cs = [
377            change("zz", StatusKind::Closed, None, at),
378            change("aa", StatusKind::Reopened, None, at),
379        ];
380        // "zz" sorts after "aa", so the close wins.
381        assert_eq!(
382            resolve(&cs, &mut MapAncestors::of([])).status,
383            Status::Closed
384        );
385    }
386
387    #[test]
388    fn unknown_anchor_treated_open_and_flagged() {
389        let cs = [change(
390            "a",
391            StatusKind::Closed,
392            Some("ghost"),
393            "2026-07-01T00:00:00Z",
394        )];
395        let mut anc = PartialMapAncestors {
396            yes: HashSet::new(),
397            unknown: HashSet::from(["ghost".to_string()]),
398        };
399        let r = resolve(&cs, &mut anc);
400        assert_eq!(r.status, Status::Open);
401        assert_eq!(r.resolution, Resolution::Partial);
402    }
403
404    #[test]
405    fn unknown_reopen_with_later_solid_close_stays_closed_but_partial() {
406        let cs = [
407            change(
408                "a",
409                StatusKind::Reopened,
410                Some("ghost"),
411                "2026-07-01T00:00:00Z",
412            ),
413            change(
414                "b",
415                StatusKind::Closed,
416                Some("sha1"),
417                "2026-07-02T00:00:00Z",
418            ),
419        ];
420        let mut anc = PartialMapAncestors {
421            yes: HashSet::from(["sha1".to_string()]),
422            unknown: HashSet::from(["ghost".to_string()]),
423        };
424        let r = resolve(&cs, &mut anc);
425        assert_eq!(r.status, Status::Closed);
426        assert_eq!(r.resolution, Resolution::Partial);
427    }
428
429    fn facts(edges: &[(&str, &[&str])]) -> BTreeMap<String, CommitFact> {
430        edges
431            .iter()
432            .map(|(sha, parents)| {
433                (
434                    sha.to_string(),
435                    CommitFact {
436                        id: (*sha).into(),
437                        project_id: "p".into(),
438                        parents: parents.iter().map(|p| p.to_string()).collect(),
439                        patch_id: None,
440                    },
441                )
442            })
443            .collect()
444    }
445
446    #[test]
447    fn facts_linear_chain() {
448        // c3 -> c2 -> c1 (root)
449        let f = facts(&[("c1", &[]), ("c2", &["c1"]), ("c3", &["c2"])]);
450        let mut anc = FactsAncestors::new(&f, "c3");
451        assert_eq!(anc.contains("c1"), Ancestry::Yes);
452        assert_eq!(anc.contains("c3"), Ancestry::Yes);
453        let mut anc_at_c2 = FactsAncestors::new(&f, "c2");
454        assert_eq!(anc_at_c2.contains("c3"), Ancestry::No);
455    }
456
457    #[test]
458    fn facts_diamond_merge() {
459        // m -> {a, b} -> root
460        let f = facts(&[
461            ("root", &[]),
462            ("a", &["root"]),
463            ("b", &["root"]),
464            ("m", &["a", "b"]),
465        ]);
466        let mut anc = FactsAncestors::new(&f, "m");
467        assert_eq!(anc.contains("a"), Ancestry::Yes);
468        assert_eq!(anc.contains("b"), Ancestry::Yes);
469        assert_eq!(anc.contains("root"), Ancestry::Yes);
470    }
471
472    #[test]
473    fn facts_incomplete_graph_answers_unknown_for_absent() {
474        // c2's parent c1 has no fact: closure incomplete.
475        let f = facts(&[("c2", &["c1"]), ("c3", &["c2"])]);
476        let mut anc = FactsAncestors::new(&f, "c3");
477        assert_eq!(anc.contains("c2"), Ancestry::Yes); // actually reached
478        assert_eq!(anc.contains("other"), Ancestry::Unknown); // can't rule out
479    }
480
481    #[test]
482    fn facts_missing_head_is_all_unknown() {
483        let f = facts(&[("c1", &[])]);
484        let mut anc = FactsAncestors::new(&f, "nope");
485        assert_eq!(anc.contains("c1"), Ancestry::Unknown);
486        assert_eq!(anc.contains("nope"), Ancestry::Unknown);
487    }
488
489    mod reconcile_tests {
490        use super::*;
491
492        fn closed_change(anchor: &str) -> StatusChange {
493            StatusChange {
494                id: "c1".into(),
495                project_id: "p".into(),
496                task_id: "t".into(),
497                to_status: StatusKind::Closed,
498                anchor_commit: Some(anchor.into()),
499                created: "2026-01-01T00:00:00Z".into(),
500                by_dev: "d".into(),
501                by_machine: "m".into(),
502            }
503        }
504
505        fn fact(sha: &str, parents: &[&str], patch: Option<&str>) -> CommitFact {
506            CommitFact {
507                id: sha.into(),
508                project_id: "p".into(),
509                parents: parents.iter().map(|s| s.to_string()).collect(),
510                patch_id: patch.map(str::to_string),
511            }
512        }
513
514        /// Anchor absent from ancestry but its patch-id matches a head-ancestry
515        /// commit -> Rewritten.
516        #[test]
517        fn facts_patch_id_fallback_resolves_rewritten() {
518            // head: c2 <- c1. squashed-away anchor `X` shares patch-id "pX" with c1.
519            let facts: BTreeMap<String, CommitFact> = [
520                ("c1".to_string(), fact("c1", &[], Some("pX"))),
521                ("c2".to_string(), fact("c2", &["c1"], Some("pOther"))),
522                ("X".to_string(), fact("X", &[], Some("pX"))),
523            ]
524            .into_iter()
525            .collect();
526            let mut anc = FactsAncestors::new(&facts, "c2");
527            assert_eq!(anc.contains("c1"), Ancestry::Yes); // exact, reachable
528            assert_eq!(anc.contains("X"), Ancestry::Rewritten); // squashed, patch match
529        }
530
531        /// No patch-id match -> No (when the graph is complete).
532        #[test]
533        fn facts_no_patch_match_is_no() {
534            let facts: BTreeMap<String, CommitFact> = [
535                ("c1".to_string(), fact("c1", &[], Some("pA"))),
536                ("X".to_string(), fact("X", &[], Some("pB"))),
537            ]
538            .into_iter()
539            .collect();
540            let mut anc = FactsAncestors::new(&facts, "c1");
541            assert_eq!(anc.contains("X"), Ancestry::No);
542        }
543
544        /// An anchor with no patch-id never matches.
545        #[test]
546        fn facts_none_patch_never_matches() {
547            let facts: BTreeMap<String, CommitFact> = [
548                ("c1".to_string(), fact("c1", &[], Some("pA"))),
549                ("X".to_string(), fact("X", &[], None)),
550            ]
551            .into_iter()
552            .collect();
553            let mut anc = FactsAncestors::new(&facts, "c1");
554            assert_eq!(anc.contains("X"), Ancestry::No);
555        }
556
557        /// A `Rewritten` anchor closes the task but downgrades to Squashed.
558        #[test]
559        fn rewritten_resolves_closed_squashed() {
560            struct Rw;
561            impl AncestorSet for Rw {
562                fn contains(&mut self, _sha: &str) -> Ancestry {
563                    Ancestry::Rewritten
564                }
565            }
566            let ch = closed_change("deadbeef");
567            let got = effective_status(&[&ch], &mut Rw, Resolution::Exact);
568            assert_eq!(got.status, Status::Closed);
569            assert_eq!(got.resolution, Resolution::Squashed);
570            assert_eq!(got.resolution.label(), "squashed");
571        }
572
573        /// Grade fold keeps the weakest: Partial beats Squashed beats Exact.
574        #[test]
575        fn resolution_weaken_orders_partial_below_squashed_below_exact() {
576            assert_eq!(
577                Resolution::Exact.weaken(Resolution::Squashed),
578                Resolution::Squashed
579            );
580            assert_eq!(
581                Resolution::Squashed.weaken(Resolution::Partial),
582                Resolution::Partial
583            );
584            assert_eq!(
585                Resolution::Facts.weaken(Resolution::Squashed),
586                Resolution::Squashed
587            );
588            // A stronger grade never upgrades a weaker one.
589            assert_eq!(
590                Resolution::Squashed.weaken(Resolution::Exact),
591                Resolution::Squashed
592            );
593        }
594    }
595}