1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
2
3use serde::Serialize;
4
5use super::git_history::HistoryBeadCompat;
6use super::graph::IssueGraph;
7use crate::model::Issue;
8
9#[derive(Debug, Clone, Serialize)]
14pub struct BlockerChainEntry {
15 pub id: String,
16 pub title: String,
17 pub status: String,
18 pub priority: i32,
19 pub depth: usize,
20 pub is_root: bool,
21 pub actionable: bool,
22 pub blocks_count: usize,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct BlockerChainResult {
27 pub target_id: String,
28 pub target_title: String,
29 pub is_blocked: bool,
30 pub chain_length: usize,
31 pub root_blockers: Vec<BlockerChainEntry>,
32 pub chain: Vec<BlockerChainEntry>,
33 pub has_cycle: bool,
34 pub cycle_ids: Vec<String>,
35}
36
37#[derive(Debug, Serialize)]
38pub struct RobotBlockerChainOutput {
39 #[serde(flatten)]
40 pub envelope: crate::robot::RobotEnvelope,
41 #[serde(flatten)]
42 pub result: BlockerChainResult,
43}
44
45pub fn get_blocker_chain(graph: &IssueGraph, target_id: &str) -> BlockerChainResult {
47 let issue = graph.issue(target_id);
48 let target_title = issue.map_or_else(String::new, |i| i.title.clone());
49
50 let open_blockers = graph.open_blockers(target_id);
51 if open_blockers.is_empty() {
52 return BlockerChainResult {
53 target_id: target_id.to_string(),
54 target_title,
55 is_blocked: false,
56 chain_length: 0,
57 root_blockers: Vec::new(),
58 chain: Vec::new(),
59 has_cycle: false,
60 cycle_ids: Vec::new(),
61 };
62 }
63
64 let mut visited = HashSet::new();
65 visited.insert(target_id.to_string());
66
67 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
68 for blocker_id in &open_blockers {
69 queue.push_back((blocker_id.clone(), 1));
70 }
71
72 let mut chain = Vec::new();
73 let mut roots = Vec::new();
74 let mut cycle_ids = Vec::new();
75
76 while let Some((id, depth)) = queue.pop_front() {
77 if !visited.insert(id.clone()) {
78 cycle_ids.push(id);
79 continue;
80 }
81
82 let entry_issue = graph.issue(&id);
83 let title = entry_issue.map_or_else(String::new, |i| i.title.clone());
84 let status = entry_issue.map_or_else(|| "unknown".to_string(), |i| i.status.clone());
85 let priority = entry_issue.map_or(99, |i| i.priority);
86
87 let this_open_blockers = graph.open_blockers(&id);
88 let is_root = this_open_blockers.is_empty();
89 let actionable = is_root && entry_issue.is_some_and(Issue::is_open_like);
90
91 let dependents = graph.dependents(&id);
92 let blocks_count = dependents
93 .iter()
94 .filter(|dep_id| graph.issue(dep_id).is_some_and(Issue::is_open_like))
95 .count();
96
97 let entry = BlockerChainEntry {
98 id: id.clone(),
99 title,
100 status,
101 priority,
102 depth,
103 is_root,
104 actionable,
105 blocks_count,
106 };
107
108 if is_root {
109 roots.push(entry.clone());
110 }
111 chain.push(entry);
112
113 if !is_root {
114 for blocker_id in &this_open_blockers {
115 if !visited.contains(blocker_id) {
116 queue.push_back((blocker_id.clone(), depth + 1));
117 }
118 }
119 }
120 }
121
122 chain.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.id.cmp(&b.id)));
124
125 roots.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
127
128 cycle_ids.sort();
129 cycle_ids.dedup();
130 let has_cycle = !cycle_ids.is_empty();
131
132 BlockerChainResult {
133 target_id: target_id.to_string(),
134 target_title,
135 is_blocked: true,
136 chain_length: chain.len(),
137 root_blockers: roots,
138 chain,
139 has_cycle,
140 cycle_ids,
141 }
142}
143
144#[derive(Debug, Clone, Serialize)]
149pub struct NetworkNode {
150 pub bead_id: String,
151 pub title: String,
152 pub status: String,
153 pub priority: i32,
154 pub degree: usize,
155 pub commit_count: usize,
156 pub file_count: usize,
157 pub cluster_id: i32,
158}
159
160#[derive(Debug, Clone, Serialize)]
161pub struct NetworkEdge {
162 pub from_bead: String,
163 pub to_bead: String,
164 pub edge_type: String,
165 pub weight: usize,
166 pub details: Vec<String>,
167}
168
169#[derive(Debug, Clone, Serialize)]
170pub struct BeadCluster {
171 pub cluster_id: usize,
172 pub bead_ids: Vec<String>,
173 pub label: String,
174 pub internal_edges: usize,
175 pub central_bead: String,
176 pub shared_files: Vec<String>,
177 pub total_commits: usize,
178}
179
180#[derive(Debug, Clone, Serialize)]
181pub struct NetworkStats {
182 pub total_nodes: usize,
183 pub total_edges: usize,
184 pub cluster_count: usize,
185 pub avg_degree: f64,
186 pub max_degree: usize,
187 pub density: f64,
188 pub isolated_nodes: usize,
189}
190
191#[derive(Debug, Clone, Serialize)]
192pub struct ImpactNetwork {
193 pub nodes: Vec<NetworkNode>,
194 pub edges: Vec<NetworkEdge>,
195 pub clusters: Vec<BeadCluster>,
196 pub stats: NetworkStats,
197}
198
199#[derive(Debug, Serialize)]
200pub struct ImpactNetworkResult {
201 pub bead_id: String,
202 pub depth: usize,
203 pub network: ImpactNetwork,
204 pub top_connected: Vec<String>,
205}
206
207#[derive(Debug, Serialize)]
208pub struct RobotImpactNetworkOutput {
209 #[serde(flatten)]
210 pub envelope: crate::robot::RobotEnvelope,
211 #[serde(flatten)]
212 pub result: ImpactNetworkResult,
213}
214
215pub fn build_impact_network(
217 graph: &IssueGraph,
218 histories: &BTreeMap<String, HistoryBeadCompat>,
219) -> ImpactNetwork {
220 let all_ids = graph.issue_ids_sorted();
221
222 let mut edges = Vec::new();
224 let mut degree_map: HashMap<String, usize> = HashMap::new();
225
226 for id in &all_ids {
228 for blocker_id in graph.blockers(id) {
229 let (from, to) = if *id < blocker_id {
231 (id.clone(), blocker_id.clone())
232 } else {
233 (blocker_id.clone(), id.clone())
234 };
235 edges.push(NetworkEdge {
236 from_bead: from,
237 to_bead: to,
238 edge_type: "dependency".to_string(),
239 weight: 1,
240 details: Vec::new(),
241 });
242 }
243 }
244
245 let mut commit_to_beads: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
247 for history in histories.values() {
248 for commit in history.commits.as_deref().unwrap_or_default() {
249 commit_to_beads
250 .entry(commit.sha.clone())
251 .or_default()
252 .insert(history.bead_id.clone());
253 }
254 }
255 let mut shared_commit_edges: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
256 for (sha, bead_ids) in &commit_to_beads {
257 let ids: Vec<&String> = bead_ids.iter().collect();
258 for i in 0..ids.len() {
259 for j in (i + 1)..ids.len() {
260 let key = (ids[i].clone(), ids[j].clone());
261 shared_commit_edges
262 .entry(key)
263 .or_default()
264 .push(sha.clone());
265 }
266 }
267 }
268 for ((from, to), shas) in &shared_commit_edges {
269 let details: Vec<String> = shas.iter().take(5).cloned().collect();
270 edges.push(NetworkEdge {
271 from_bead: from.clone(),
272 to_bead: to.clone(),
273 edge_type: "shared_commit".to_string(),
274 weight: shas.len(),
275 details,
276 });
277 }
278
279 let mut bead_files: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
281 for history in histories.values() {
282 for commit in history.commits.as_deref().unwrap_or_default() {
283 for file in &commit.files {
284 bead_files
285 .entry(history.bead_id.clone())
286 .or_default()
287 .insert(file.path.clone());
288 }
289 }
290 }
291 let mut shared_file_edges: BTreeMap<(String, String), Vec<String>> = BTreeMap::new();
292 let bead_ids: Vec<&String> = bead_files.keys().collect();
293 for i in 0..bead_ids.len() {
294 for j in (i + 1)..bead_ids.len() {
295 let files_a = &bead_files[bead_ids[i]];
296 let files_b = &bead_files[bead_ids[j]];
297 let shared: Vec<String> = files_a.intersection(files_b).cloned().collect();
298 if shared.len() >= 2 {
299 let key = (bead_ids[i].clone(), bead_ids[j].clone());
300 shared_file_edges.insert(key, shared);
301 }
302 }
303 }
304 for ((from, to), files) in &shared_file_edges {
305 let details: Vec<String> = files.iter().take(5).cloned().collect();
306 edges.push(NetworkEdge {
307 from_bead: from.clone(),
308 to_bead: to.clone(),
309 edge_type: "shared_file".to_string(),
310 weight: files.len(),
311 details,
312 });
313 }
314
315 edges.sort_by(|a, b| {
317 a.from_bead
318 .cmp(&b.from_bead)
319 .then_with(|| a.to_bead.cmp(&b.to_bead))
320 .then_with(|| a.edge_type.cmp(&b.edge_type))
321 });
322 edges.dedup_by(|a, b| {
323 a.from_bead == b.from_bead && a.to_bead == b.to_bead && a.edge_type == b.edge_type
324 });
325
326 for edge in &edges {
328 *degree_map.entry(edge.from_bead.clone()).or_default() += 1;
329 *degree_map.entry(edge.to_bead.clone()).or_default() += 1;
330 }
331
332 let mut nodes: Vec<NetworkNode> = all_ids
334 .iter()
335 .map(|id| {
336 let issue = graph.issue(id);
337 let commit_count = histories
338 .get(id)
339 .and_then(|h| h.commits.as_ref())
340 .map_or(0, Vec::len);
341 let file_count = bead_files.get(id).map_or(0, BTreeSet::len);
342
343 NetworkNode {
344 bead_id: id.clone(),
345 title: issue.map_or_else(String::new, |i| i.title.clone()),
346 status: issue.map_or_else(|| "unknown".to_string(), |i| i.status.clone()),
347 priority: issue.map_or(99, |i| i.priority),
348 degree: degree_map.get(id).copied().unwrap_or(0),
349 commit_count,
350 file_count,
351 cluster_id: -1,
352 }
353 })
354 .collect();
355
356 let mut clusters = detect_clusters(&edges, &mut nodes, &bead_files);
358 clusters.sort_by_key(|b| std::cmp::Reverse(b.bead_ids.len()));
359
360 let isolated_nodes = nodes.iter().filter(|n| n.degree == 0).count();
361 let max_degree = nodes.iter().map(|n| n.degree).max().unwrap_or(0);
362 let avg_degree = if nodes.is_empty() {
363 0.0
364 } else {
365 nodes.iter().map(|n| n.degree).sum::<usize>() as f64 / nodes.len() as f64
366 };
367 let n = nodes.len();
368 let density = if n > 1 {
369 (2.0 * edges.len() as f64) / (n as f64 * (n as f64 - 1.0))
372 } else {
373 0.0
374 };
375
376 ImpactNetwork {
377 stats: NetworkStats {
378 total_nodes: nodes.len(),
379 total_edges: edges.len(),
380 cluster_count: clusters.len(),
381 avg_degree,
382 max_degree,
383 density,
384 isolated_nodes,
385 },
386 nodes,
387 edges,
388 clusters,
389 }
390}
391
392fn detect_clusters(
393 edges: &[NetworkEdge],
394 nodes: &mut [NetworkNode],
395 bead_files: &BTreeMap<String, BTreeSet<String>>,
396) -> Vec<BeadCluster> {
397 let mut adj: HashMap<String, HashSet<String>> = HashMap::new();
399 for edge in edges {
400 adj.entry(edge.from_bead.clone())
401 .or_default()
402 .insert(edge.to_bead.clone());
403 adj.entry(edge.to_bead.clone())
404 .or_default()
405 .insert(edge.from_bead.clone());
406 }
407
408 let mut visited = HashSet::new();
409 let mut clusters = Vec::new();
410 let mut cluster_id = 0usize;
411
412 let all_edge_nodes: BTreeSet<String> = adj.keys().cloned().collect();
414
415 for start in &all_edge_nodes {
416 if visited.contains(start) {
417 continue;
418 }
419
420 let mut component = Vec::new();
422 let mut queue = VecDeque::new();
423 queue.push_back(start.clone());
424 visited.insert(start.clone());
425
426 while let Some(current) = queue.pop_front() {
427 component.push(current.clone());
428 if let Some(neighbors) = adj.get(¤t) {
429 for neighbor in neighbors {
430 if visited.insert(neighbor.clone()) {
431 queue.push_back(neighbor.clone());
432 }
433 }
434 }
435 }
436
437 if component.len() < 2 {
438 continue;
439 }
440
441 component.sort();
442
443 let component_set: HashSet<&String> = component.iter().collect();
445 let internal_edges = edges
446 .iter()
447 .filter(|e| component_set.contains(&e.from_bead) && component_set.contains(&e.to_bead))
448 .count();
449
450 let mut file_counts: BTreeMap<String, usize> = BTreeMap::new();
452 for bead_id in &component {
453 if let Some(files) = bead_files.get(bead_id) {
454 for f in files {
455 *file_counts.entry(f.clone()).or_default() += 1;
456 }
457 }
458 }
459 let mut shared_files: Vec<String> = file_counts
460 .iter()
461 .filter(|(_, count)| **count >= 2)
462 .map(|(f, _)| f.clone())
463 .collect();
464 shared_files.sort();
465 shared_files.truncate(10);
466
467 let label = shared_files.first().map_or_else(
469 || format!("cluster-{cluster_id}"),
470 |f| {
471 f.rsplit_once('/')
472 .map_or_else(|| f.clone(), |(dir, _)| dir.to_string())
473 },
474 );
475
476 let central_bead = component
478 .iter()
479 .max_by_key(|id| {
480 adj.get(*id).map_or(0, |n| {
481 n.iter().filter(|x| component_set.contains(x)).count()
482 })
483 })
484 .cloned()
485 .unwrap_or_default();
486
487 let total_commits: usize = component
488 .iter()
489 .filter_map(|id| nodes.iter().find(|n| n.bead_id == *id))
490 .map(|n| n.commit_count)
491 .sum();
492
493 let cluster_id_i32 = i32::try_from(cluster_id).unwrap_or(i32::MAX);
494 for node in nodes.iter_mut() {
496 if component_set.contains(&node.bead_id) {
497 node.cluster_id = cluster_id_i32;
498 }
499 }
500
501 clusters.push(BeadCluster {
502 cluster_id,
503 bead_ids: component,
504 label,
505 internal_edges,
506 central_bead,
507 shared_files,
508 total_commits,
509 });
510
511 cluster_id += 1;
512 }
513
514 clusters
515}
516
517pub fn get_subnetwork(network: &ImpactNetwork, bead_id: &str, depth: usize) -> ImpactNetwork {
519 let capped_depth = depth.clamp(1, 3);
520
521 let mut visited = HashSet::new();
522 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
523 queue.push_back((bead_id.to_string(), 0));
524 visited.insert(bead_id.to_string());
525
526 while let Some((current, level)) = queue.pop_front() {
527 if level >= capped_depth {
528 continue;
529 }
530 for edge in &network.edges {
531 let neighbor = if edge.from_bead == current {
532 &edge.to_bead
533 } else if edge.to_bead == current {
534 &edge.from_bead
535 } else {
536 continue;
537 };
538 if visited.insert(neighbor.clone()) {
539 queue.push_back((neighbor.clone(), level + 1));
540 }
541 }
542 }
543
544 let sub_edges: Vec<NetworkEdge> = network
545 .edges
546 .iter()
547 .filter(|e| visited.contains(&e.from_bead) && visited.contains(&e.to_bead))
548 .cloned()
549 .collect();
550
551 let mut degree_map: HashMap<String, usize> = HashMap::new();
552 for edge in &sub_edges {
553 *degree_map.entry(edge.from_bead.clone()).or_default() += 1;
554 *degree_map.entry(edge.to_bead.clone()).or_default() += 1;
555 }
556
557 let sub_nodes: Vec<NetworkNode> = network
558 .nodes
559 .iter()
560 .filter(|n| visited.contains(&n.bead_id))
561 .cloned()
562 .map(|mut n| {
563 n.degree = degree_map.get(&n.bead_id).copied().unwrap_or(0);
564 n
565 })
566 .collect();
567
568 let isolated = sub_nodes.iter().filter(|n| n.degree == 0).count();
569 let max_degree = sub_nodes.iter().map(|n| n.degree).max().unwrap_or(0);
570 let avg_degree = if sub_nodes.is_empty() {
571 0.0
572 } else {
573 sub_nodes.iter().map(|n| n.degree).sum::<usize>() as f64 / sub_nodes.len() as f64
574 };
575 let n = sub_nodes.len();
576 let density = if n > 1 {
577 (2.0 * sub_edges.len() as f64) / (n as f64 * (n as f64 - 1.0))
580 } else {
581 0.0
582 };
583
584 let cluster_ids: BTreeSet<usize> = sub_nodes
586 .iter()
587 .filter_map(|n| usize::try_from(n.cluster_id).ok())
588 .collect();
589 let sub_clusters: Vec<BeadCluster> = network
590 .clusters
591 .iter()
592 .filter(|c| cluster_ids.contains(&c.cluster_id))
593 .cloned()
594 .collect();
595
596 ImpactNetwork {
597 stats: NetworkStats {
598 total_nodes: sub_nodes.len(),
599 total_edges: sub_edges.len(),
600 cluster_count: sub_clusters.len(),
601 avg_degree,
602 max_degree,
603 density,
604 isolated_nodes: isolated,
605 },
606 nodes: sub_nodes,
607 edges: sub_edges,
608 clusters: sub_clusters,
609 }
610}
611
612pub fn build_impact_network_result(
614 graph: &IssueGraph,
615 histories: &BTreeMap<String, HistoryBeadCompat>,
616 bead_id: &str,
617 depth: usize,
618) -> ImpactNetworkResult {
619 let full_network = build_impact_network(graph, histories);
620
621 let (network, effective_bead_id, effective_depth) = if bead_id.is_empty() || bead_id == "all" {
622 (full_network, String::new(), 0)
623 } else {
624 let sub = get_subnetwork(&full_network, bead_id, depth);
625 (sub, bead_id.to_string(), depth.clamp(1, 3))
626 };
627
628 let mut top_connected: Vec<String> = network
629 .nodes
630 .iter()
631 .filter(|n| n.degree > 0)
632 .map(|n| n.bead_id.clone())
633 .collect();
634 top_connected.sort_by(|a, b| {
635 let da = network
636 .nodes
637 .iter()
638 .find(|n| n.bead_id == *a)
639 .map_or(0, |n| n.degree);
640 let db = network
641 .nodes
642 .iter()
643 .find(|n| n.bead_id == *b)
644 .map_or(0, |n| n.degree);
645 db.cmp(&da).then_with(|| a.cmp(b))
646 });
647 top_connected.truncate(10);
648
649 ImpactNetworkResult {
650 bead_id: effective_bead_id,
651 depth: effective_depth,
652 network,
653 top_connected,
654 }
655}
656
657#[derive(Debug, Clone, Serialize)]
662pub struct CausalEvent {
663 pub id: usize,
664 #[serde(rename = "type")]
665 pub event_type: String,
666 pub timestamp: String,
667 pub description: String,
668 #[serde(skip_serializing_if = "Option::is_none")]
669 pub commit_sha: Option<String>,
670 #[serde(skip_serializing_if = "Option::is_none")]
671 pub blocker_id: Option<String>,
672 #[serde(skip_serializing_if = "Option::is_none")]
673 pub caused_by_id: Option<usize>,
674 pub enables_ids: Vec<usize>,
675 #[serde(skip_serializing_if = "Option::is_none")]
676 pub duration_next_ms: Option<u64>,
677}
678
679#[derive(Debug, Clone, Serialize)]
680pub struct CausalChain {
681 pub bead_id: String,
682 pub title: String,
683 pub status: String,
684 pub events: Vec<CausalEvent>,
685 pub edge_count: usize,
686 pub start_time: String,
687 pub end_time: String,
688 pub is_complete: bool,
689}
690
691#[derive(Debug, Clone, Serialize)]
692pub struct BlockedPeriod {
693 pub start_time: String,
694 pub end_time: String,
695 pub duration_ms: u64,
696 pub blocker_id: String,
697}
698
699#[derive(Debug, Clone, Serialize)]
700pub struct CausalInsights {
701 pub total_duration_ms: u64,
702 pub blocked_duration_ms: u64,
703 pub active_duration_ms: u64,
704 pub blocked_percentage: f64,
705 pub blocked_periods: Vec<BlockedPeriod>,
706 pub commit_count: usize,
707 pub avg_time_between_ms: u64,
708 pub longest_gap_ms: u64,
709 pub longest_gap_desc: String,
710 pub summary: String,
711 pub recommendations: Vec<String>,
712}
713
714#[derive(Debug, Clone, Serialize)]
715pub struct CausalityResult {
716 pub chain: CausalChain,
717 pub insights: CausalInsights,
718}
719
720#[derive(Debug, Serialize)]
721pub struct RobotCausalityOutput {
722 #[serde(flatten)]
723 pub envelope: crate::robot::RobotEnvelope,
724 #[serde(flatten)]
725 pub result: CausalityResult,
726}
727
728pub fn build_causality_chain(
730 bead_id: &str,
731 histories: &BTreeMap<String, HistoryBeadCompat>,
732 graph: &IssueGraph,
733) -> CausalityResult {
734 let issue = graph.issue(bead_id);
735 let title = issue.map_or_else(String::new, |i| i.title.clone());
736 let status = issue.map_or_else(|| "unknown".to_string(), |i| i.status.clone());
737
738 let history = histories.get(bead_id);
739
740 let mut raw_events: Vec<(String, String, Option<String>, Option<String>)> = Vec::new();
742 if let Some(h) = history {
745 for event in &h.events {
747 let event_type = match event.event_type.as_str() {
748 "created" | "claimed" | "closed" | "reopened" => event.event_type.clone(),
749 "status_change" => "status_change".to_string(),
750 other => other.to_string(),
751 };
752 raw_events.push((event.timestamp.clone(), event_type, None, None));
753 }
754
755 if let Some(commits) = &h.commits {
757 for commit in commits {
758 raw_events.push((
759 commit.timestamp.clone(),
760 "commit".to_string(),
761 Some(commit.short_sha.clone()),
762 None,
763 ));
764 }
765 }
766 }
767
768 let blockers = graph.blockers(bead_id);
770 for blocker_id in &blockers {
771 if let Some(blocker_history) = histories.get(blocker_id) {
772 if let Some(closed_event) = blocker_history
774 .events
775 .iter()
776 .find(|e| e.event_type == "closed")
777 {
778 raw_events.push((
779 closed_event.timestamp.clone(),
780 "unblocked".to_string(),
781 None,
782 Some(blocker_id.clone()),
783 ));
784 }
785 if let Some(created_event) = blocker_history.events.first() {
787 raw_events.push((
788 created_event.timestamp.clone(),
789 "blocked".to_string(),
790 None,
791 Some(blocker_id.clone()),
792 ));
793 }
794 }
795 }
796
797 raw_events.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
799 raw_events.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1 && a.2 == b.2 && a.3 == b.3);
800
801 let mut events: Vec<CausalEvent> = Vec::new();
803 let mut commit_count = 0usize;
804
805 for (idx, (timestamp, event_type, commit_sha, blocker_id)) in raw_events.iter().enumerate() {
806 let description = match event_type.as_str() {
807 "created" => "Issue created".to_string(),
808 "claimed" => "Work started (claimed)".to_string(),
809 "commit" => format!("Commit {}", commit_sha.as_deref().unwrap_or("unknown")),
810 "closed" => "Issue closed".to_string(),
811 "reopened" => "Issue reopened".to_string(),
812 "blocked" => format!("Blocked by {}", blocker_id.as_deref().unwrap_or("unknown")),
813 "unblocked" => format!(
814 "Unblocked ({} closed)",
815 blocker_id.as_deref().unwrap_or("unknown")
816 ),
817 other => other.to_string(),
818 };
819
820 if event_type == "commit" {
821 commit_count += 1;
822 }
823
824 let caused_by_id = if idx > 0 { Some(idx - 1) } else { None };
825 let enables_ids = if idx + 1 < raw_events.len() {
826 vec![idx + 1]
827 } else {
828 Vec::new()
829 };
830
831 events.push(CausalEvent {
832 id: idx,
833 event_type: event_type.clone(),
834 timestamp: timestamp.clone(),
835 description,
836 commit_sha: commit_sha.clone(),
837 blocker_id: blocker_id.clone(),
838 caused_by_id,
839 enables_ids,
840 duration_next_ms: None,
841 });
842 }
843
844 for i in 0..events.len().saturating_sub(1) {
846 let current_ts = parse_timestamp_ms(&events[i].timestamp);
847 let next_ts = parse_timestamp_ms(&events[i + 1].timestamp);
848 if let (Some(c), Some(n)) = (current_ts, next_ts) {
849 events[i].duration_next_ms = Some(n.saturating_sub(c));
850 }
851 }
852
853 let start_time = events
854 .first()
855 .map_or_else(String::new, |e| e.timestamp.clone());
856 let end_time = events
857 .last()
858 .map_or_else(String::new, |e| e.timestamp.clone());
859 let edge_count = events.len().saturating_sub(1);
860
861 let is_complete = issue.is_some_and(|i| !i.is_open_like());
862
863 let total_duration_ms = {
865 let start = parse_timestamp_ms(&start_time);
866 let end = parse_timestamp_ms(&end_time);
867 match (start, end) {
868 (Some(s), Some(e)) => e.saturating_sub(s),
869 _ => 0,
870 }
871 };
872
873 let mut blocked_periods = Vec::new();
875 let mut blocked_start: Option<(String, String)> = None; for event in &events {
878 match event.event_type.as_str() {
879 "blocked" if blocked_start.is_none() => {
880 blocked_start = Some((
881 event.timestamp.clone(),
882 event.blocker_id.clone().unwrap_or_default(),
883 ));
884 }
885 "unblocked" => {
886 if let Some((start, blocker)) = blocked_start.take() {
887 let start_ms = parse_timestamp_ms(&start).unwrap_or(0);
888 let end_ms = parse_timestamp_ms(&event.timestamp).unwrap_or(0);
889 blocked_periods.push(BlockedPeriod {
890 start_time: start,
891 end_time: event.timestamp.clone(),
892 duration_ms: end_ms.saturating_sub(start_ms),
893 blocker_id: blocker,
894 });
895 }
896 }
897 _ => {}
898 }
899 }
900
901 let blocked_duration_ms: u64 = blocked_periods.iter().map(|p| p.duration_ms).sum();
902 let active_duration_ms = total_duration_ms.saturating_sub(blocked_duration_ms);
903 let blocked_percentage = if total_duration_ms > 0 {
904 (blocked_duration_ms as f64 / total_duration_ms as f64) * 100.0
905 } else {
906 0.0
907 };
908
909 let gaps: Vec<u64> = events.iter().filter_map(|e| e.duration_next_ms).collect();
911 let avg_time_between_ms = if gaps.is_empty() {
912 0
913 } else {
914 gaps.iter().sum::<u64>() / gaps.len() as u64
915 };
916 const MS_PER_DAY: u64 = 86_400_000;
917
918 let longest_gap_ms = gaps.iter().copied().max().unwrap_or(0);
919 let longest_gap_desc = if longest_gap_ms > 0 {
920 let days = longest_gap_ms / MS_PER_DAY;
921 if days > 0 {
922 format!("{days}d gap between events")
923 } else {
924 let hours = longest_gap_ms / 3_600_000;
925 format!("{hours}h gap between events")
926 }
927 } else {
928 String::new()
929 };
930
931 let total_days = total_duration_ms / MS_PER_DAY;
933 let summary = if is_complete {
934 format!("Completed in {total_days}d ({blocked_percentage:.0}% blocked)")
935 } else {
936 format!("In progress for {total_days}d ({blocked_percentage:.0}% blocked)")
937 };
938
939 let mut recommendations = Vec::new();
940 if blocked_percentage > 25.0 {
941 recommendations.push(format!(
942 "High blocked percentage ({blocked_percentage:.0}%) - consider addressing blockers earlier"
943 ));
944 }
945 if commit_count > 0 && total_days > 0 {
946 let commits_per_day = commit_count as f64 / total_days as f64;
947 if commits_per_day < 0.1 {
948 recommendations.push(
949 "Few commits over long period - consider more frequent incremental commits"
950 .to_string(),
951 );
952 }
953 }
954 if events.is_empty() {
955 recommendations.push("No history events found - check data completeness".to_string());
956 }
957
958 CausalityResult {
959 chain: CausalChain {
960 bead_id: bead_id.to_string(),
961 title,
962 status,
963 events,
964 edge_count,
965 start_time,
966 end_time,
967 is_complete,
968 },
969 insights: CausalInsights {
970 total_duration_ms,
971 blocked_duration_ms,
972 active_duration_ms,
973 blocked_percentage,
974 blocked_periods,
975 commit_count,
976 avg_time_between_ms,
977 longest_gap_ms,
978 longest_gap_desc,
979 summary,
980 recommendations,
981 },
982 }
983}
984
985pub fn parse_timestamp_ms_pub(ts: &str) -> Option<u64> {
987 parse_timestamp_ms(ts)
988}
989
990fn parse_timestamp_ms(ts: &str) -> Option<u64> {
992 if ts.is_empty() {
993 return None;
994 }
995 let ts = ts.trim();
998
999 let parts: Vec<&str> = ts.split('T').collect();
1001 if parts.len() != 2 {
1002 return None;
1003 }
1004
1005 let date_parts: Vec<u64> = parts[0].split('-').filter_map(|p| p.parse().ok()).collect();
1006 if date_parts.len() != 3 {
1007 return None;
1008 }
1009
1010 let time_str = parts[1]
1011 .trim_end_matches('Z')
1012 .split('+')
1013 .next()?
1014 .split('-')
1015 .next()?
1016 .split('.')
1017 .next()?;
1018 let time_parts: Vec<u64> = time_str.split(':').filter_map(|p| p.parse().ok()).collect();
1019 if time_parts.len() < 2 {
1020 return None;
1021 }
1022
1023 let year = date_parts[0];
1024 let month = date_parts[1];
1025 let day = date_parts[2];
1026 let hour = time_parts[0];
1027 let minute = time_parts[1];
1028 let second = if time_parts.len() > 2 {
1029 time_parts[2]
1030 } else {
1031 0
1032 };
1033
1034 let days_since_epoch = days_from_date(year, month, day)?;
1036 const SECS_PER_DAY: u64 = 86_400;
1037 Some(((days_since_epoch * SECS_PER_DAY) + (hour * 3600) + (minute * 60) + second) * 1000)
1038}
1039
1040fn days_from_date(year: u64, month: u64, day: u64) -> Option<u64> {
1041 if year < 1970 || month == 0 || month > 12 || day == 0 || day > 31 {
1042 return None;
1043 }
1044 let mut days = 0u64;
1045 for y in 1970..year {
1046 days += if is_leap(y) { 366 } else { 365 };
1047 }
1048 let month_days: [u64; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1049 for m in 1..month {
1050 let month_index = usize::try_from(m - 1).ok()?;
1051 days += month_days[month_index];
1052 if m == 2 && is_leap(year) {
1053 days += 1;
1054 }
1055 }
1056 days += day - 1;
1057 Some(days)
1058}
1059
1060const fn is_leap(year: u64) -> bool {
1061 (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
1062}
1063
1064#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use crate::analysis::git_history::{
1072 HistoryBeadCompat, HistoryCommitCompat, HistoryEventCompat, HistoryFileChangeCompat,
1073 HistoryMilestonesCompat,
1074 };
1075 use crate::model::{Dependency, Issue};
1076
1077 fn make_issue(id: &str, title: &str, status: &str, priority: i32) -> Issue {
1078 Issue {
1079 id: id.to_string(),
1080 title: title.to_string(),
1081 status: status.to_string(),
1082 priority,
1083 ..Issue::default()
1084 }
1085 }
1086
1087 fn make_issue_with_deps(
1088 id: &str,
1089 title: &str,
1090 status: &str,
1091 priority: i32,
1092 blockers: &[&str],
1093 ) -> Issue {
1094 let dependencies = blockers
1095 .iter()
1096 .map(|blocker_id| Dependency {
1097 issue_id: id.to_string(),
1098 depends_on_id: blocker_id.to_string(),
1099 dep_type: "blocks".to_string(),
1100 ..Dependency::default()
1101 })
1102 .collect();
1103 Issue {
1104 id: id.to_string(),
1105 title: title.to_string(),
1106 status: status.to_string(),
1107 priority,
1108 dependencies,
1109 ..Issue::default()
1110 }
1111 }
1112
1113 fn make_history(
1114 bead_id: &str,
1115 events: Vec<(&str, &str)>,
1116 commits: Vec<(&str, &str, Vec<&str>)>,
1117 ) -> HistoryBeadCompat {
1118 HistoryBeadCompat {
1119 bead_id: bead_id.to_string(),
1120 title: String::new(),
1121 status: String::new(),
1122 events: events
1123 .into_iter()
1124 .map(|(ts, etype)| HistoryEventCompat {
1125 bead_id: bead_id.to_string(),
1126 event_type: etype.to_string(),
1127 timestamp: ts.to_string(),
1128 commit_sha: String::new(),
1129 commit_message: String::new(),
1130 author: String::new(),
1131 author_email: String::new(),
1132 })
1133 .collect(),
1134 milestones: HistoryMilestonesCompat::default(),
1135 commits: Some(
1136 commits
1137 .into_iter()
1138 .enumerate()
1139 .map(|(i, (sha, ts, files))| HistoryCommitCompat {
1140 sha: sha.to_string(),
1141 short_sha: sha[..7.min(sha.len())].to_string(),
1142 message: format!("commit {i}"),
1143 author: "dev".to_string(),
1144 author_email: "dev@test.com".to_string(),
1145 timestamp: ts.to_string(),
1146 files: files
1147 .into_iter()
1148 .map(|p| HistoryFileChangeCompat {
1149 path: p.to_string(),
1150 action: "modified".to_string(),
1151 insertions: 10,
1152 deletions: 5,
1153 })
1154 .collect(),
1155 method: "message".to_string(),
1156 confidence: 0.9,
1157 reason: "test".to_string(),
1158 field_changes: vec![],
1159 bead_diff_lines: vec![],
1160 })
1161 .collect(),
1162 ),
1163 cycle_time: None,
1164 last_author: String::new(),
1165 }
1166 }
1167
1168 #[test]
1171 fn blocker_chain_no_blockers() {
1172 let issues = vec![make_issue("A", "Task A", "open", 1)];
1173 let graph = IssueGraph::build(&issues);
1174
1175 let result = get_blocker_chain(&graph, "A");
1176 assert!(!result.is_blocked);
1177 assert_eq!(result.chain_length, 0);
1178 assert!(result.chain.is_empty());
1179 assert!(result.root_blockers.is_empty());
1180 }
1181
1182 #[test]
1183 fn blocker_chain_simple() {
1184 let issues = vec![
1185 make_issue("A", "Root", "open", 1),
1186 make_issue_with_deps("B", "Blocked", "blocked", 2, &["A"]),
1187 ];
1188 let graph = IssueGraph::build(&issues);
1189
1190 let result = get_blocker_chain(&graph, "B");
1191 assert!(result.is_blocked);
1192 assert_eq!(result.chain_length, 1);
1193 assert_eq!(result.chain[0].id, "A");
1194 assert!(result.chain[0].is_root);
1195 assert!(result.chain[0].actionable);
1196 assert_eq!(result.root_blockers.len(), 1);
1197 assert!(!result.has_cycle);
1198 }
1199
1200 #[test]
1201 fn blocker_chain_deep() {
1202 let issues = vec![
1203 make_issue("A", "Root", "open", 1),
1204 make_issue_with_deps("B", "Mid", "blocked", 2, &["A"]),
1205 make_issue_with_deps("C", "Target", "blocked", 3, &["B"]),
1206 ];
1207 let graph = IssueGraph::build(&issues);
1208
1209 let result = get_blocker_chain(&graph, "C");
1210 assert!(result.is_blocked);
1211 assert_eq!(result.chain_length, 2);
1212 assert_eq!(result.chain[0].depth, 1); assert_eq!(result.chain[1].depth, 2); assert_eq!(result.root_blockers.len(), 1);
1215 assert_eq!(result.root_blockers[0].id, "A");
1216 }
1217
1218 #[test]
1219 fn blocker_chain_closed_blocker_not_traversed() {
1220 let issues = vec![
1221 make_issue("A", "Closed root", "closed", 1),
1222 make_issue_with_deps("B", "Target", "open", 2, &["A"]),
1223 ];
1224 let graph = IssueGraph::build(&issues);
1225
1226 let result = get_blocker_chain(&graph, "B");
1227 assert!(!result.is_blocked); }
1229
1230 #[test]
1231 fn blocker_chain_deterministic_sorting() {
1232 let issues = vec![
1233 make_issue("R1", "Root 1", "open", 3),
1234 make_issue("R2", "Root 2", "open", 1),
1235 make_issue_with_deps("T", "Target", "blocked", 2, &["R1", "R2"]),
1236 ];
1237 let graph = IssueGraph::build(&issues);
1238
1239 let result = get_blocker_chain(&graph, "T");
1240 assert_eq!(result.root_blockers[0].id, "R2"); assert_eq!(result.root_blockers[1].id, "R1"); }
1244
1245 #[test]
1248 fn impact_network_empty() {
1249 let issues = vec![make_issue("A", "Solo", "open", 1)];
1250 let graph = IssueGraph::build(&issues);
1251 let histories = BTreeMap::new();
1252
1253 let network = build_impact_network(&graph, &histories);
1254 assert_eq!(network.stats.total_nodes, 1);
1255 assert_eq!(network.stats.total_edges, 0);
1256 assert_eq!(network.stats.isolated_nodes, 1);
1257 }
1258
1259 #[test]
1260 fn impact_network_dependency_edges() {
1261 let issues = vec![
1262 make_issue("A", "Root", "open", 1),
1263 make_issue_with_deps("B", "Blocked", "blocked", 2, &["A"]),
1264 ];
1265 let graph = IssueGraph::build(&issues);
1266 let histories = BTreeMap::new();
1267
1268 let network = build_impact_network(&graph, &histories);
1269 assert_eq!(network.stats.total_edges, 1);
1270 let edge = &network.edges[0];
1271 assert_eq!(edge.edge_type, "dependency");
1272 assert!(
1273 (network.stats.density - 1.0).abs() < f64::EPSILON,
1274 "two connected nodes in an undirected network should have density 1.0"
1275 );
1276 }
1277
1278 #[test]
1279 fn impact_network_shared_commits() {
1280 let issues = vec![
1281 make_issue("A", "Task A", "open", 1),
1282 make_issue("B", "Task B", "open", 1),
1283 ];
1284 let graph = IssueGraph::build(&issues);
1285
1286 let mut histories = BTreeMap::new();
1287 histories.insert(
1288 "A".to_string(),
1289 make_history(
1290 "A",
1291 vec![],
1292 vec![("sha1", "2025-01-01T00:00:00Z", vec!["f1.rs"])],
1293 ),
1294 );
1295 histories.insert(
1296 "B".to_string(),
1297 make_history(
1298 "B",
1299 vec![],
1300 vec![("sha1", "2025-01-01T00:00:00Z", vec!["f2.rs"])],
1301 ),
1302 );
1303
1304 let network = build_impact_network(&graph, &histories);
1305 assert!(network.edges.iter().any(|e| e.edge_type == "shared_commit"));
1307 }
1308
1309 #[test]
1310 fn impact_network_subnetwork() {
1311 let issues = vec![
1312 make_issue("A", "A", "open", 1),
1313 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1314 make_issue("C", "C", "open", 1),
1315 make_issue_with_deps("D", "D", "open", 1, &["C"]),
1316 ];
1317 let graph = IssueGraph::build(&issues);
1318 let histories = BTreeMap::new();
1319
1320 let full = build_impact_network(&graph, &histories);
1321 assert_eq!(full.stats.total_nodes, 4);
1322
1323 let sub = get_subnetwork(&full, "A", 1);
1324 assert!(sub.nodes.iter().any(|n| n.bead_id == "A"));
1326 assert!(sub.nodes.iter().any(|n| n.bead_id == "B"));
1327 assert!(!sub.nodes.iter().any(|n| n.bead_id == "C"));
1328 }
1329
1330 #[test]
1333 fn causality_empty_history() {
1334 let issues = vec![make_issue("A", "Task", "open", 1)];
1335 let graph = IssueGraph::build(&issues);
1336 let histories = BTreeMap::new();
1337
1338 let result = build_causality_chain("A", &histories, &graph);
1339 assert!(result.chain.events.is_empty());
1340 assert_eq!(result.insights.commit_count, 0);
1341 assert!(
1342 result
1343 .insights
1344 .recommendations
1345 .iter()
1346 .any(|r| r.contains("No history"))
1347 );
1348 }
1349
1350 #[test]
1351 fn causality_basic_lifecycle() {
1352 let issues = vec![make_issue("A", "Task", "closed", 1)];
1353 let graph = IssueGraph::build(&issues);
1354
1355 let mut histories = BTreeMap::new();
1356 histories.insert(
1357 "A".to_string(),
1358 make_history(
1359 "A",
1360 vec![
1361 ("2025-01-01T00:00:00Z", "created"),
1362 ("2025-01-02T00:00:00Z", "claimed"),
1363 ("2025-01-05T00:00:00Z", "closed"),
1364 ],
1365 vec![("abc1234", "2025-01-03T00:00:00Z", vec!["src/main.rs"])],
1366 ),
1367 );
1368
1369 let result = build_causality_chain("A", &histories, &graph);
1370 assert_eq!(result.chain.events.len(), 4); assert!(result.chain.is_complete);
1372 assert_eq!(result.insights.commit_count, 1);
1373 assert!(result.insights.total_duration_ms > 0);
1374 }
1375
1376 #[test]
1377 fn causality_blocked_periods() {
1378 let issues = vec![
1379 make_issue("blocker", "Blocker", "closed", 1),
1380 make_issue_with_deps("A", "Task", "open", 2, &["blocker"]),
1381 ];
1382 let graph = IssueGraph::build(&issues);
1383
1384 let mut histories = BTreeMap::new();
1385 histories.insert(
1386 "blocker".to_string(),
1387 make_history(
1388 "blocker",
1389 vec![
1390 ("2025-01-01T00:00:00Z", "created"),
1391 ("2025-01-10T00:00:00Z", "closed"),
1392 ],
1393 vec![],
1394 ),
1395 );
1396 histories.insert(
1397 "A".to_string(),
1398 make_history("A", vec![("2025-01-02T00:00:00Z", "created")], vec![]),
1399 );
1400
1401 let result = build_causality_chain("A", &histories, &graph);
1402 let has_blocked = result
1404 .chain
1405 .events
1406 .iter()
1407 .any(|e| e.event_type == "blocked");
1408 let has_unblocked = result
1409 .chain
1410 .events
1411 .iter()
1412 .any(|e| e.event_type == "unblocked");
1413 assert!(has_blocked);
1414 assert!(has_unblocked);
1415 assert!(!result.insights.blocked_periods.is_empty());
1416 }
1417
1418 #[test]
1419 fn causality_causal_links() {
1420 let issues = vec![make_issue("A", "Task", "open", 1)];
1421 let graph = IssueGraph::build(&issues);
1422
1423 let mut histories = BTreeMap::new();
1424 histories.insert(
1425 "A".to_string(),
1426 make_history(
1427 "A",
1428 vec![
1429 ("2025-01-01T00:00:00Z", "created"),
1430 ("2025-01-02T00:00:00Z", "claimed"),
1431 ],
1432 vec![],
1433 ),
1434 );
1435
1436 let result = build_causality_chain("A", &histories, &graph);
1437 assert_eq!(result.chain.events.len(), 2);
1438 assert!(result.chain.events[0].caused_by_id.is_none());
1440 assert_eq!(result.chain.events[0].enables_ids, vec![1]);
1441 assert_eq!(result.chain.events[1].caused_by_id, Some(0));
1443 assert!(result.chain.events[1].enables_ids.is_empty());
1444 }
1445
1446 #[test]
1447 fn timestamp_parsing() {
1448 let ms = parse_timestamp_ms("2025-01-01T00:00:00Z").unwrap();
1449 assert!(ms > 0);
1450
1451 let ms2 = parse_timestamp_ms("2025-01-02T00:00:00Z").unwrap();
1452 assert_eq!(ms2 - ms, 86_400_000); }
1454
1455 #[test]
1456 fn timestamp_parsing_empty() {
1457 assert!(parse_timestamp_ms("").is_none());
1458 assert!(parse_timestamp_ms("invalid").is_none());
1459 }
1460
1461 #[test]
1464 fn timestamp_parsing_with_timezone_offset() {
1465 let ms = parse_timestamp_ms("2025-01-01T00:00:00+00:00");
1466 assert!(ms.is_some());
1467 }
1468
1469 #[test]
1470 fn timestamp_parsing_with_fractional_seconds() {
1471 let ms = parse_timestamp_ms("2025-01-01T12:30:45.123Z");
1472 assert!(ms.is_some());
1473 }
1474
1475 #[test]
1476 fn timestamp_parsing_no_t_separator() {
1477 assert!(parse_timestamp_ms("2025-01-01 12:00:00Z").is_none());
1478 }
1479
1480 #[test]
1481 fn timestamp_parsing_missing_seconds() {
1482 let ms = parse_timestamp_ms("2025-01-01T12:30Z");
1484 assert!(ms.is_some());
1485 }
1486
1487 #[test]
1490 fn days_from_date_pre_1970_returns_none() {
1491 assert!(days_from_date(1969, 12, 31).is_none());
1492 }
1493
1494 #[test]
1495 fn days_from_date_invalid_month_zero() {
1496 assert!(days_from_date(2020, 0, 1).is_none());
1497 }
1498
1499 #[test]
1500 fn days_from_date_invalid_month_13() {
1501 assert!(days_from_date(2020, 13, 1).is_none());
1502 }
1503
1504 #[test]
1505 fn days_from_date_invalid_day_zero() {
1506 assert!(days_from_date(2020, 1, 0).is_none());
1507 }
1508
1509 #[test]
1510 fn days_from_date_invalid_day_32() {
1511 assert!(days_from_date(2020, 1, 32).is_none());
1512 }
1513
1514 #[test]
1515 fn days_from_date_epoch() {
1516 assert_eq!(days_from_date(1970, 1, 1), Some(0));
1517 }
1518
1519 #[test]
1520 fn days_from_date_one_day() {
1521 assert_eq!(days_from_date(1970, 1, 2), Some(1));
1522 }
1523
1524 #[test]
1525 fn days_from_date_leap_year_feb() {
1526 let feb28 = days_from_date(2000, 2, 28).unwrap();
1528 let mar1 = days_from_date(2000, 3, 1).unwrap();
1529 assert_eq!(mar1 - feb28, 2); }
1531
1532 #[test]
1535 fn blocker_chain_unknown_target() {
1536 let issues = vec![make_issue("A", "A", "open", 1)];
1537 let graph = IssueGraph::build(&issues);
1538 let result = get_blocker_chain(&graph, "NONEXISTENT");
1539 assert!(!result.is_blocked);
1540 assert_eq!(result.target_title, "");
1541 }
1542
1543 #[test]
1544 fn blocker_chain_multiple_roots_sorted_by_priority() {
1545 let issues = vec![
1546 make_issue("R1", "Root high", "open", 5),
1547 make_issue("R2", "Root low", "open", 1),
1548 make_issue("R3", "Root mid", "open", 3),
1549 make_issue_with_deps("T", "Target", "blocked", 2, &["R1", "R2", "R3"]),
1550 ];
1551 let graph = IssueGraph::build(&issues);
1552 let result = get_blocker_chain(&graph, "T");
1553 assert_eq!(result.root_blockers.len(), 3);
1554 assert_eq!(result.root_blockers[0].id, "R2"); assert_eq!(result.root_blockers[1].id, "R3"); assert_eq!(result.root_blockers[2].id, "R1"); }
1558
1559 #[test]
1562 fn subnetwork_depth_clamped_to_max_3() {
1563 let issues = vec![
1564 make_issue("A", "A", "open", 1),
1565 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1566 ];
1567 let graph = IssueGraph::build(&issues);
1568 let histories = BTreeMap::new();
1569 let full = build_impact_network(&graph, &histories);
1570
1571 let sub = get_subnetwork(&full, "A", 100);
1573 assert!(sub.stats.total_nodes <= full.stats.total_nodes);
1574 }
1575
1576 #[test]
1577 fn subnetwork_unknown_bead_returns_single_node() {
1578 let issues = vec![
1579 make_issue("A", "A", "open", 1),
1580 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1581 ];
1582 let graph = IssueGraph::build(&issues);
1583 let histories = BTreeMap::new();
1584 let full = build_impact_network(&graph, &histories);
1585
1586 let sub = get_subnetwork(&full, "NONEXISTENT", 2);
1587 assert_eq!(sub.stats.total_nodes, 0);
1589 }
1590
1591 #[test]
1592 fn subnetwork_density_zero_for_single_node() {
1593 let issues = vec![
1594 make_issue("A", "A", "open", 1),
1595 make_issue("B", "B", "open", 1),
1596 ];
1597 let graph = IssueGraph::build(&issues);
1598 let histories = BTreeMap::new();
1599 let full = build_impact_network(&graph, &histories);
1600
1601 let sub = get_subnetwork(&full, "A", 1);
1602 assert_eq!(sub.stats.density, 0.0);
1603 }
1604
1605 #[test]
1608 fn impact_network_result_all_returns_full_network() {
1609 let issues = vec![
1610 make_issue("A", "A", "open", 1),
1611 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1612 ];
1613 let graph = IssueGraph::build(&issues);
1614 let histories = BTreeMap::new();
1615
1616 let result = build_impact_network_result(&graph, &histories, "all", 2);
1617 assert_eq!(result.bead_id, "");
1618 assert_eq!(result.depth, 0);
1619 assert_eq!(result.network.stats.total_nodes, 2);
1620 }
1621
1622 #[test]
1623 fn impact_network_result_specific_bead() {
1624 let issues = vec![
1625 make_issue("A", "A", "open", 1),
1626 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1627 make_issue("C", "C", "open", 1),
1628 make_issue_with_deps("D", "D", "open", 1, &["C"]),
1629 ];
1630 let graph = IssueGraph::build(&issues);
1631 let histories = BTreeMap::new();
1632
1633 let result = build_impact_network_result(&graph, &histories, "A", 1);
1634 assert_eq!(result.bead_id, "A");
1635 assert!(result.depth >= 1);
1636 }
1637
1638 #[test]
1639 fn impact_network_result_empty_bead_id_returns_full() {
1640 let issues = vec![make_issue("A", "A", "open", 1)];
1641 let graph = IssueGraph::build(&issues);
1642 let histories = BTreeMap::new();
1643
1644 let result = build_impact_network_result(&graph, &histories, "", 1);
1645 assert_eq!(result.bead_id, "");
1646 assert_eq!(result.depth, 0);
1647 }
1648
1649 #[test]
1652 fn impact_network_shared_file_edges_require_two_files() {
1653 let issues = vec![
1654 make_issue("A", "A", "open", 1),
1655 make_issue("B", "B", "open", 1),
1656 ];
1657 let graph = IssueGraph::build(&issues);
1658
1659 let mut histories = BTreeMap::new();
1660 histories.insert(
1662 "A".to_string(),
1663 make_history(
1664 "A",
1665 vec![],
1666 vec![("s1", "2025-01-01T00:00:00Z", vec!["shared.rs"])],
1667 ),
1668 );
1669 histories.insert(
1670 "B".to_string(),
1671 make_history(
1672 "B",
1673 vec![],
1674 vec![("s2", "2025-01-02T00:00:00Z", vec!["shared.rs"])],
1675 ),
1676 );
1677
1678 let network = build_impact_network(&graph, &histories);
1679 assert!(!network.edges.iter().any(|e| e.edge_type == "shared_file"));
1680 }
1681
1682 #[test]
1683 fn impact_network_shared_file_edges_with_two_plus_files() {
1684 let issues = vec![
1685 make_issue("A", "A", "open", 1),
1686 make_issue("B", "B", "open", 1),
1687 ];
1688 let graph = IssueGraph::build(&issues);
1689
1690 let mut histories = BTreeMap::new();
1691 histories.insert(
1692 "A".to_string(),
1693 make_history(
1694 "A",
1695 vec![],
1696 vec![("s1", "2025-01-01T00:00:00Z", vec!["f1.rs", "f2.rs"])],
1697 ),
1698 );
1699 histories.insert(
1700 "B".to_string(),
1701 make_history(
1702 "B",
1703 vec![],
1704 vec![("s2", "2025-01-02T00:00:00Z", vec!["f1.rs", "f2.rs"])],
1705 ),
1706 );
1707
1708 let network = build_impact_network(&graph, &histories);
1709 assert!(network.edges.iter().any(|e| e.edge_type == "shared_file"));
1710 }
1711
1712 #[test]
1715 fn impact_network_stats_computed() {
1716 let issues = vec![
1717 make_issue("A", "A", "open", 1),
1718 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1719 make_issue("C", "C", "open", 1),
1720 ];
1721 let graph = IssueGraph::build(&issues);
1722 let histories = BTreeMap::new();
1723
1724 let network = build_impact_network(&graph, &histories);
1725 assert_eq!(network.stats.total_nodes, 3);
1726 assert_eq!(network.stats.total_edges, 1);
1727 assert_eq!(network.stats.isolated_nodes, 1); assert_eq!(network.stats.max_degree, 1);
1729 assert!(
1730 (0.0..=1.0).contains(&network.stats.density),
1731 "undirected density must remain normalized into [0, 1]"
1732 );
1733 }
1734
1735 #[test]
1736 fn subnetwork_density_matches_undirected_formula() {
1737 let issues = vec![
1738 make_issue("A", "A", "open", 1),
1739 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1740 make_issue_with_deps("C", "C", "open", 1, &["B"]),
1741 ];
1742 let graph = IssueGraph::build(&issues);
1743 let histories = BTreeMap::new();
1744 let full = build_impact_network(&graph, &histories);
1745
1746 let sub = get_subnetwork(&full, "B", 1);
1747 assert_eq!(sub.stats.total_nodes, 3);
1748 assert_eq!(sub.stats.total_edges, 2);
1749 assert!(
1750 (sub.stats.density - (2.0 / 3.0)).abs() < 1e-9,
1751 "3-node undirected path with 2 edges should have density 2/3"
1752 );
1753 }
1754
1755 #[test]
1758 fn causality_high_blocked_percentage_recommendation() {
1759 let issues = vec![
1760 make_issue("blocker", "Blocker", "closed", 1),
1761 make_issue_with_deps("A", "Task", "open", 2, &["blocker"]),
1762 ];
1763 let graph = IssueGraph::build(&issues);
1764
1765 let mut histories = BTreeMap::new();
1766 histories.insert(
1767 "blocker".to_string(),
1768 make_history(
1769 "blocker",
1770 vec![
1771 ("2025-01-01T00:00:00Z", "created"),
1772 ("2025-06-01T00:00:00Z", "closed"),
1773 ],
1774 vec![],
1775 ),
1776 );
1777 histories.insert(
1778 "A".to_string(),
1779 make_history(
1780 "A",
1781 vec![
1782 ("2025-01-02T00:00:00Z", "created"),
1783 ("2025-07-01T00:00:00Z", "claimed"),
1784 ],
1785 vec![],
1786 ),
1787 );
1788
1789 let result = build_causality_chain("A", &histories, &graph);
1790 if result.insights.blocked_percentage > 25.0 {
1792 assert!(
1793 result
1794 .insights
1795 .recommendations
1796 .iter()
1797 .any(|r| r.contains("blocked percentage"))
1798 );
1799 }
1800 }
1801
1802 #[test]
1803 fn causality_duration_next_ms_computed() {
1804 let issues = vec![make_issue("A", "Task", "open", 1)];
1805 let graph = IssueGraph::build(&issues);
1806
1807 let mut histories = BTreeMap::new();
1808 histories.insert(
1809 "A".to_string(),
1810 make_history(
1811 "A",
1812 vec![
1813 ("2025-01-01T00:00:00Z", "created"),
1814 ("2025-01-02T00:00:00Z", "claimed"),
1815 ],
1816 vec![],
1817 ),
1818 );
1819
1820 let result = build_causality_chain("A", &histories, &graph);
1821 assert_eq!(result.chain.events.len(), 2);
1822 assert_eq!(result.chain.events[0].duration_next_ms, Some(86_400_000));
1823 }
1824
1825 #[test]
1826 fn causality_not_complete_when_open() {
1827 let issues = vec![make_issue("A", "Task", "open", 1)];
1828 let graph = IssueGraph::build(&issues);
1829 let mut histories = BTreeMap::new();
1830 histories.insert(
1831 "A".to_string(),
1832 make_history("A", vec![("2025-01-01T00:00:00Z", "created")], vec![]),
1833 );
1834
1835 let result = build_causality_chain("A", &histories, &graph);
1836 assert!(!result.chain.is_complete);
1837 }
1838
1839 #[test]
1840 fn causality_complete_when_closed() {
1841 let issues = vec![make_issue("A", "Task", "closed", 1)];
1842 let graph = IssueGraph::build(&issues);
1843 let mut histories = BTreeMap::new();
1844 histories.insert(
1845 "A".to_string(),
1846 make_history(
1847 "A",
1848 vec![
1849 ("2025-01-01T00:00:00Z", "created"),
1850 ("2025-01-05T00:00:00Z", "closed"),
1851 ],
1852 vec![],
1853 ),
1854 );
1855
1856 let result = build_causality_chain("A", &histories, &graph);
1857 assert!(result.chain.is_complete);
1858 }
1859
1860 #[test]
1861 fn causality_edge_count_is_events_minus_one() {
1862 let issues = vec![make_issue("A", "Task", "open", 1)];
1863 let graph = IssueGraph::build(&issues);
1864 let mut histories = BTreeMap::new();
1865 histories.insert(
1866 "A".to_string(),
1867 make_history(
1868 "A",
1869 vec![
1870 ("2025-01-01T00:00:00Z", "created"),
1871 ("2025-01-02T00:00:00Z", "claimed"),
1872 ("2025-01-03T00:00:00Z", "status_change"),
1873 ],
1874 vec![],
1875 ),
1876 );
1877
1878 let result = build_causality_chain("A", &histories, &graph);
1879 assert_eq!(result.chain.edge_count, result.chain.events.len() - 1);
1880 }
1881
1882 #[test]
1883 fn causality_longest_gap_desc_days() {
1884 let issues = vec![make_issue("A", "Task", "open", 1)];
1885 let graph = IssueGraph::build(&issues);
1886 let mut histories = BTreeMap::new();
1887 histories.insert(
1888 "A".to_string(),
1889 make_history(
1890 "A",
1891 vec![
1892 ("2025-01-01T00:00:00Z", "created"),
1893 ("2025-02-01T00:00:00Z", "claimed"),
1894 ],
1895 vec![],
1896 ),
1897 );
1898
1899 let result = build_causality_chain("A", &histories, &graph);
1900 assert!(result.insights.longest_gap_desc.contains("d gap"));
1901 }
1902
1903 #[test]
1906 fn cluster_requires_at_least_two_nodes() {
1907 let issues = vec![
1908 make_issue("A", "A", "open", 1),
1909 make_issue("B", "B", "open", 1),
1910 ];
1911 let graph = IssueGraph::build(&issues);
1912 let histories = BTreeMap::new();
1913
1914 let network = build_impact_network(&graph, &histories);
1915 assert!(network.clusters.is_empty());
1917 }
1918
1919 #[test]
1920 fn cluster_formed_from_connected_edges() {
1921 let issues = vec![
1922 make_issue("A", "A", "open", 1),
1923 make_issue_with_deps("B", "B", "open", 1, &["A"]),
1924 make_issue_with_deps("C", "C", "open", 1, &["A"]),
1925 ];
1926 let graph = IssueGraph::build(&issues);
1927 let histories = BTreeMap::new();
1928
1929 let network = build_impact_network(&graph, &histories);
1930 assert!(!network.clusters.is_empty());
1931 let cluster = &network.clusters[0];
1932 assert!(cluster.bead_ids.len() >= 2);
1933 }
1934}