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}
20
21pub 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 Exact,
52 Facts,
54 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
75pub 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
107pub 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 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
155pub 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
174pub 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
192pub 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 assert_eq!(
263 resolve(&cs, &mut MapAncestors::of(["sha1"])).status,
264 Status::Closed
265 );
266 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 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 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 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 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 let f = facts(&[("c2", &["c1"]), ("c3", &["c2"])]);
427 let mut anc = FactsAncestors::new(&f, "c3");
428 assert_eq!(anc.contains("c2"), Ancestry::Yes); assert_eq!(anc.contains("other"), Ancestry::Unknown); }
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}