1#![allow(clippy::must_use_candidate, clippy::module_name_repetitions)]
55
56use std::collections::{HashMap, HashSet};
57
58use crate::crdt::item_state::WorkItemState;
59
60#[derive(Debug, Clone, Default)]
82pub struct BlockingGraph {
83 blocked_by: HashMap<String, HashSet<String>>,
85 related_to: HashMap<String, HashSet<String>>,
87 all_items: HashSet<String>,
89}
90
91impl BlockingGraph {
92 pub fn from_states(states: &HashMap<String, WorkItemState>) -> Self {
103 let all_items: HashSet<String> = states.keys().cloned().collect();
104 let mut blocked_by: HashMap<String, HashSet<String>> = HashMap::new();
105 let mut related_to: HashMap<String, HashSet<String>> = HashMap::new();
106
107 for (item_id, state) in states {
108 let blockers: HashSet<String> = state.blocked_by_ids().into_iter().cloned().collect();
109 if !blockers.is_empty() {
110 blocked_by.insert(item_id.clone(), blockers);
111 }
112
113 let related: HashSet<String> = state.related_to_ids().into_iter().cloned().collect();
114 if !related.is_empty() {
115 related_to.insert(item_id.clone(), related);
116 }
117 }
118
119 Self {
120 blocked_by,
121 related_to,
122 all_items,
123 }
124 }
125
126 pub fn is_blocked(&self, item_id: &str) -> bool {
131 self.blocked_by
132 .get(item_id)
133 .is_some_and(|blockers| !blockers.is_empty())
134 }
135
136 pub fn get_blockers(&self, item_id: &str) -> HashSet<&str> {
140 self.blocked_by
141 .get(item_id)
142 .map(|blockers| blockers.iter().map(String::as_str).collect())
143 .unwrap_or_default()
144 }
145
146 pub fn get_related(&self, item_id: &str) -> HashSet<&str> {
151 self.related_to
152 .get(item_id)
153 .map(|related| related.iter().map(String::as_str).collect())
154 .unwrap_or_default()
155 }
156
157 pub fn ready_items(&self) -> HashSet<&str> {
166 self.all_items
167 .iter()
168 .filter(|id| !self.is_blocked(id.as_str()))
169 .map(String::as_str)
170 .collect()
171 }
172
173 pub fn blocked_items(&self) -> HashSet<&str> {
175 self.blocked_by
176 .iter()
177 .filter(|(_, blockers)| !blockers.is_empty())
178 .map(|(id, _)| id.as_str())
179 .collect()
180 }
181
182 pub fn len(&self) -> usize {
184 self.all_items.len()
185 }
186
187 pub fn is_empty(&self) -> bool {
189 self.all_items.is_empty()
190 }
191
192 pub fn all_item_ids(&self) -> impl Iterator<Item = &str> {
196 self.all_items.iter().map(String::as_str)
197 }
198}
199
200pub fn is_blocked<S: std::hash::BuildHasher>(
208 item_id: &str,
209 states: &HashMap<String, WorkItemState, S>,
210) -> bool {
211 states
212 .get(item_id)
213 .is_some_and(|state| !state.blocked_by.is_empty())
214}
215
216pub fn get_blockers<S: std::hash::BuildHasher>(
220 item_id: &str,
221 states: &HashMap<String, WorkItemState, S>,
222) -> HashSet<String> {
223 states
224 .get(item_id)
225 .map(|state| state.blocked_by_ids().into_iter().cloned().collect())
226 .unwrap_or_default()
227}
228
229pub fn ready_items<S: std::hash::BuildHasher>(
234 states: &HashMap<String, WorkItemState, S>,
235) -> Vec<String> {
236 states
237 .keys()
238 .filter(|id| !is_blocked(id.as_str(), states))
239 .cloned()
240 .collect()
241}
242
243#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::clock::itc::Stamp;
251 use crate::crdt::item_state::WorkItemState;
252 use crate::event::Event;
253 use crate::event::data::{EventData, LinkData, UnlinkData};
254 use crate::event::types::EventType;
255 use crate::model::item_id::ItemId;
256 use std::collections::BTreeMap;
257
258 fn make_link_event(
263 target: &str,
264 link_type: &str,
265 wall_ts: i64,
266 agent: &str,
267 hash: &str,
268 ) -> Event {
269 let mut stamp = Stamp::seed();
270 stamp.event();
271 Event {
272 wall_ts_us: wall_ts,
273 agent: agent.to_string(),
274 itc: stamp.to_string(),
275 parents: vec![],
276 event_type: EventType::Link,
277 item_id: ItemId::new_unchecked("bn-test"),
278 data: EventData::Link(LinkData {
279 target: target.to_string(),
280 link_type: link_type.to_string(),
281 extra: BTreeMap::new(),
282 }),
283 event_hash: hash.to_string(),
284 }
285 }
286
287 fn make_unlink_event(
288 target: &str,
289 link_type: Option<&str>,
290 wall_ts: i64,
291 agent: &str,
292 hash: &str,
293 ) -> Event {
294 let mut stamp = Stamp::seed();
295 stamp.event();
296 Event {
297 wall_ts_us: wall_ts,
298 agent: agent.to_string(),
299 itc: stamp.to_string(),
300 parents: vec![],
301 event_type: EventType::Unlink,
302 item_id: ItemId::new_unchecked("bn-test"),
303 data: EventData::Unlink(UnlinkData {
304 target: target.to_string(),
305 link_type: link_type.map(|s| s.to_string()),
306 extra: BTreeMap::new(),
307 }),
308 event_hash: hash.to_string(),
309 }
310 }
311
312 fn state_with_blockers(blocker_ids: &[&str]) -> WorkItemState {
314 let mut state = WorkItemState::new();
315 for (i, blocker) in blocker_ids.iter().enumerate() {
316 let hash = format!("blake3:link{i}");
317 let event = make_link_event(blocker, "blocks", 1000 + i as i64, "agent", &hash);
318 state.apply_event(&event);
319 }
320 state
321 }
322
323 fn state_with_related(related_ids: &[&str]) -> WorkItemState {
325 let mut state = WorkItemState::new();
326 for (i, related) in related_ids.iter().enumerate() {
327 let hash = format!("blake3:rel{i}");
328 let event = make_link_event(related, "related_to", 1000 + i as i64, "agent", &hash);
329 state.apply_event(&event);
330 }
331 state
332 }
333
334 #[test]
339 fn empty_graph_from_empty_states() {
340 let states: HashMap<String, WorkItemState> = HashMap::new();
341 let graph = BlockingGraph::from_states(&states);
342
343 assert!(graph.is_empty());
344 assert_eq!(graph.len(), 0);
345 assert!(graph.ready_items().is_empty());
346 assert!(graph.blocked_items().is_empty());
347 }
348
349 #[test]
350 fn unblocked_item_is_ready() {
351 let mut states = HashMap::new();
352 states.insert("bn-1".to_string(), WorkItemState::new());
353
354 let graph = BlockingGraph::from_states(&states);
355
356 assert!(!graph.is_blocked("bn-1"));
357 assert!(graph.ready_items().contains("bn-1"));
358 assert!(!graph.blocked_items().contains("bn-1"));
359 }
360
361 #[test]
362 fn blocked_item_is_not_ready() {
363 let mut states = HashMap::new();
364 states.insert("bn-1".to_string(), state_with_blockers(&["bn-2"]));
365 states.insert("bn-2".to_string(), WorkItemState::new());
366
367 let graph = BlockingGraph::from_states(&states);
368
369 assert!(graph.is_blocked("bn-1"));
370 assert!(!graph.ready_items().contains("bn-1"));
371 assert!(graph.blocked_items().contains("bn-1"));
372
373 assert!(!graph.is_blocked("bn-2"));
375 assert!(graph.ready_items().contains("bn-2"));
376 }
377
378 #[test]
379 fn multiple_blockers() {
380 let mut states = HashMap::new();
381 states.insert("bn-1".to_string(), state_with_blockers(&["bn-2", "bn-3"]));
382 states.insert("bn-2".to_string(), WorkItemState::new());
383 states.insert("bn-3".to_string(), WorkItemState::new());
384
385 let graph = BlockingGraph::from_states(&states);
386
387 assert!(graph.is_blocked("bn-1"));
388 let blockers = graph.get_blockers("bn-1");
389 assert_eq!(blockers.len(), 2);
390 assert!(blockers.contains("bn-2"));
391 assert!(blockers.contains("bn-3"));
392 }
393
394 #[test]
395 fn related_links_do_not_block() {
396 let mut states = HashMap::new();
397 states.insert("bn-1".to_string(), state_with_related(&["bn-2"]));
398 states.insert("bn-2".to_string(), WorkItemState::new());
399
400 let graph = BlockingGraph::from_states(&states);
401
402 assert!(!graph.is_blocked("bn-1"));
404 assert!(graph.ready_items().contains("bn-1"));
405
406 let related = graph.get_related("bn-1");
408 assert!(related.contains("bn-2"));
409 }
410
411 #[test]
416 fn link_then_unlink_removes_blocker() {
417 let mut state = WorkItemState::new();
418 state.apply_event(&make_link_event(
419 "bn-dep",
420 "blocks",
421 1000,
422 "alice",
423 "blake3:l1",
424 ));
425 assert!(!state.blocked_by.is_empty());
426
427 state.apply_event(&make_unlink_event(
428 "bn-dep",
429 Some("blocks"),
430 2000,
431 "alice",
432 "blake3:ul1",
433 ));
434 assert!(state.blocked_by.is_empty());
435
436 let mut states = HashMap::new();
437 states.insert("bn-task".to_string(), state);
438
439 let graph = BlockingGraph::from_states(&states);
440 assert!(!graph.is_blocked("bn-task"));
441 assert!(graph.ready_items().contains("bn-task"));
442 }
443
444 #[test]
445 fn unlink_without_link_is_noop() {
446 let mut state = WorkItemState::new();
447 state.apply_event(&make_unlink_event(
448 "bn-nonexistent",
449 Some("blocks"),
450 1000,
451 "alice",
452 "blake3:ul1",
453 ));
454
455 let mut states = HashMap::new();
456 states.insert("bn-task".to_string(), state);
457
458 let graph = BlockingGraph::from_states(&states);
459 assert!(!graph.is_blocked("bn-task"));
460 }
461
462 #[test]
467 fn concurrent_link_and_unlink_add_wins() {
468 let mut state_a = WorkItemState::new();
470 state_a.apply_event(&make_link_event(
471 "bn-dep",
472 "blocks",
473 1000,
474 "alice",
475 "blake3:l1",
476 ));
477
478 let state_b = WorkItemState::new();
480 let mut merged = state_a.clone();
484 merged.merge(&state_b);
485
486 let mut states = HashMap::new();
487 states.insert("bn-task".to_string(), merged);
488
489 let graph = BlockingGraph::from_states(&states);
490 assert!(
491 graph.is_blocked("bn-task"),
492 "add-wins: concurrent add should survive empty remove"
493 );
494 }
495
496 #[test]
497 fn concurrent_add_wins_over_remove() {
498 let mut base = WorkItemState::new();
500 base.apply_event(&make_link_event(
501 "bn-dep",
502 "blocks",
503 1000,
504 "alice",
505 "blake3:l1",
506 ));
507
508 let mut state_a = base.clone();
510 state_a.apply_event(&make_unlink_event(
511 "bn-dep",
512 Some("blocks"),
513 2000,
514 "alice",
515 "blake3:ul1",
516 ));
517 assert!(state_a.blocked_by.is_empty());
518
519 let mut state_b = base.clone();
521 state_b.apply_event(&make_link_event(
522 "bn-dep",
523 "blocks",
524 2000,
525 "bob",
526 "blake3:l2",
527 ));
528
529 let mut merged = state_a.clone();
531 merged.merge(&state_b);
532
533 let mut states = HashMap::new();
534 states.insert("bn-task".to_string(), merged);
535
536 let graph = BlockingGraph::from_states(&states);
537 assert!(
538 graph.is_blocked("bn-task"),
539 "add-wins: B's concurrent re-add should survive A's remove"
540 );
541 }
542
543 #[test]
548 fn cross_goal_blocking_works() {
549 let mut states = HashMap::new();
551 states.insert(
552 "bn-goal1-task".to_string(),
553 state_with_blockers(&["bn-goal2-task"]),
554 );
555 states.insert("bn-goal2-task".to_string(), WorkItemState::new());
556
557 let graph = BlockingGraph::from_states(&states);
558
559 assert!(graph.is_blocked("bn-goal1-task"));
560 let blockers = graph.get_blockers("bn-goal1-task");
561 assert!(blockers.contains("bn-goal2-task"));
562
563 assert!(!graph.is_blocked("bn-goal2-task"));
565 }
566
567 #[test]
568 fn cross_goal_blocker_not_in_states_still_blocks() {
569 let mut states = HashMap::new();
571 states.insert("bn-task".to_string(), state_with_blockers(&["bn-external"]));
572 let graph = BlockingGraph::from_states(&states);
575
576 assert!(graph.is_blocked("bn-task"));
578 assert!(!graph.ready_items().contains("bn-task"));
579 }
580
581 #[test]
586 fn ready_items_excludes_blocked() {
587 let mut states = HashMap::new();
588 states.insert("bn-1".to_string(), WorkItemState::new());
589 states.insert("bn-2".to_string(), state_with_blockers(&["bn-1"]));
590 states.insert("bn-3".to_string(), WorkItemState::new());
591
592 let graph = BlockingGraph::from_states(&states);
593 let ready = graph.ready_items();
594
595 assert!(ready.contains("bn-1"));
596 assert!(!ready.contains("bn-2"));
597 assert!(ready.contains("bn-3"));
598 assert_eq!(ready.len(), 2);
599 }
600
601 #[test]
602 fn chain_blocking_all_after_first_blocked() {
603 let mut states = HashMap::new();
605 states.insert("bn-1".to_string(), WorkItemState::new());
606 states.insert("bn-2".to_string(), state_with_blockers(&["bn-1"]));
607 states.insert("bn-3".to_string(), state_with_blockers(&["bn-2"]));
608
609 let graph = BlockingGraph::from_states(&states);
610 let ready = graph.ready_items();
611
612 assert!(ready.contains("bn-1"), "bn-1 has no blockers");
613 assert!(!ready.contains("bn-2"), "bn-2 blocked by bn-1");
614 assert!(!ready.contains("bn-3"), "bn-3 blocked by bn-2");
615 assert_eq!(ready.len(), 1);
616 }
617
618 #[test]
623 fn standalone_is_blocked() {
624 let mut states = HashMap::new();
625 states.insert("bn-1".to_string(), state_with_blockers(&["bn-2"]));
626 states.insert("bn-2".to_string(), WorkItemState::new());
627
628 assert!(is_blocked("bn-1", &states));
629 assert!(!is_blocked("bn-2", &states));
630 assert!(!is_blocked("bn-unknown", &states));
631 }
632
633 #[test]
634 fn standalone_get_blockers() {
635 let mut states = HashMap::new();
636 states.insert("bn-1".to_string(), state_with_blockers(&["bn-2", "bn-3"]));
637
638 let blockers = get_blockers("bn-1", &states);
639 assert_eq!(blockers.len(), 2);
640 assert!(blockers.contains("bn-2"));
641 assert!(blockers.contains("bn-3"));
642
643 assert!(get_blockers("bn-unknown", &states).is_empty());
645 }
646
647 #[test]
648 fn standalone_ready_items() {
649 let mut states = HashMap::new();
650 states.insert("bn-1".to_string(), WorkItemState::new());
651 states.insert("bn-2".to_string(), state_with_blockers(&["bn-1"]));
652 states.insert("bn-3".to_string(), WorkItemState::new());
653
654 let ready = ready_items(&states);
655 let ready_set: HashSet<_> = ready.iter().map(String::as_str).collect();
656
657 assert!(ready_set.contains("bn-1"));
658 assert!(!ready_set.contains("bn-2"));
659 assert!(ready_set.contains("bn-3"));
660 }
661
662 #[test]
667 fn get_blockers_for_unknown_item_returns_empty() {
668 let states: HashMap<String, WorkItemState> = HashMap::new();
669 let graph = BlockingGraph::from_states(&states);
670
671 assert!(graph.get_blockers("bn-unknown").is_empty());
672 assert!(graph.get_related("bn-unknown").is_empty());
673 assert!(!graph.is_blocked("bn-unknown"));
674 }
675
676 #[test]
681 fn item_with_both_blocking_and_relates() {
682 let mut state = WorkItemState::new();
683 state.apply_event(&make_link_event(
684 "bn-blocker",
685 "blocks",
686 1000,
687 "alice",
688 "blake3:l1",
689 ));
690 state.apply_event(&make_link_event(
691 "bn-related",
692 "related_to",
693 1001,
694 "alice",
695 "blake3:l2",
696 ));
697
698 let mut states = HashMap::new();
699 states.insert("bn-task".to_string(), state);
700
701 let graph = BlockingGraph::from_states(&states);
702
703 assert!(graph.is_blocked("bn-task"));
704 assert!(graph.get_blockers("bn-task").contains("bn-blocker"));
705 assert!(graph.get_related("bn-task").contains("bn-related"));
706 assert!(!graph.ready_items().contains("bn-task"));
707 }
708
709 #[test]
714 fn blocked_by_type_alias_works() {
715 let mut state = WorkItemState::new();
717 state.apply_event(&make_link_event(
718 "bn-dep",
719 "blocked_by",
720 1000,
721 "alice",
722 "blake3:l1",
723 ));
724
725 let mut states = HashMap::new();
726 states.insert("bn-task".to_string(), state);
727
728 let graph = BlockingGraph::from_states(&states);
729 assert!(graph.is_blocked("bn-task"));
730 }
731
732 #[test]
733 fn related_type_alias_works() {
734 let mut state = WorkItemState::new();
736 state.apply_event(&make_link_event(
737 "bn-dep",
738 "related",
739 1000,
740 "alice",
741 "blake3:l1",
742 ));
743
744 let mut states = HashMap::new();
745 states.insert("bn-task".to_string(), state);
746
747 let graph = BlockingGraph::from_states(&states);
748 assert!(!graph.is_blocked("bn-task"));
749 assert!(graph.get_related("bn-task").contains("bn-dep"));
750 }
751}