1use 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 Rewritten,
21}
22
23pub 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 Exact,
54 Facts,
56 Partial,
59 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 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
97pub 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
133pub struct FactsAncestors {
139 reachable: HashSet<String>,
140 complete: bool,
141 reachable_patches: HashSet<String>,
143 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 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 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
203pub 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
222pub 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
240pub 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 assert_eq!(
311 resolve(&cs, &mut MapAncestors::of(["sha1"])).status,
312 Status::Closed
313 );
314 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 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 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 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 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 let f = facts(&[("c2", &["c1"]), ("c3", &["c2"])]);
476 let mut anc = FactsAncestors::new(&f, "c3");
477 assert_eq!(anc.contains("c2"), Ancestry::Yes); assert_eq!(anc.contains("other"), Ancestry::Unknown); }
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 #[test]
517 fn facts_patch_id_fallback_resolves_rewritten() {
518 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); assert_eq!(anc.contains("X"), Ancestry::Rewritten); }
530
531 #[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 #[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 #[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 #[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 assert_eq!(
590 Resolution::Squashed.weaken(Resolution::Exact),
591 Resolution::Squashed
592 );
593 }
594 }
595}