Skip to main content

bvr/analysis/
causal.rs

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// ---------------------------------------------------------------------------
10// Blocker Chain
11// ---------------------------------------------------------------------------
12
13#[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
45/// BFS upward from target through blocker edges to find the full blocker chain.
46pub 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    // Sort chain by depth ascending, then by ID for determinism
123    chain.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.id.cmp(&b.id)));
124
125    // Sort roots by priority (lower = higher priority), then by ID
126    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// ---------------------------------------------------------------------------
145// Impact Network
146// ---------------------------------------------------------------------------
147
148#[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
215/// Build the full impact network from issue dependencies and shared file modifications.
216pub fn build_impact_network(
217    graph: &IssueGraph,
218    histories: &BTreeMap<String, HistoryBeadCompat>,
219) -> ImpactNetwork {
220    let all_ids = graph.issue_ids_sorted();
221
222    // Build edges from three sources: dependencies, shared commits, shared files
223    let mut edges = Vec::new();
224    let mut degree_map: HashMap<String, usize> = HashMap::new();
225
226    // 1. Dependency edges
227    for id in &all_ids {
228        for blocker_id in graph.blockers(id) {
229            // Canonical ordering for dedup: smaller ID first
230            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    // 2. Shared-commit edges: beads that share the same commit SHA
246    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    // 3. Shared-file edges: beads that touched the same files
280    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    // Deduplicate edges by (from, to, type)
316    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    // Compute degrees
327    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    // Build nodes
333    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    // Simple cluster detection via connected components on edge graph
357    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        // Impact-network edges are canonicalized into undirected pairs, so use
370        // the undirected density formula 2E / (N * (N - 1)).
371        (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    // Build adjacency list
398    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    // Collect all node IDs that appear in edges
413    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        // BFS to find connected component
421        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(&current) {
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        // Compute cluster metadata
444        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        // Shared files across cluster
451        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        // Label from most common shared file prefix
468        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        // Central bead: highest degree in cluster
477        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        // Assign cluster_id to nodes
495        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
517/// Extract a subnetwork around a specific bead up to a given depth (capped at 3).
518pub 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        // Subnetworks preserve the same undirected edge semantics as the full
578        // impact network, so density must stay on the same normalized scale.
579        (2.0 * sub_edges.len() as f64) / (n as f64 * (n as f64 - 1.0))
580    } else {
581        0.0
582    };
583
584    // Recompute clusters for subnetwork
585    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
612/// Build impact network result, either full or subnetwork for a specific bead.
613pub 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// ---------------------------------------------------------------------------
658// Causality Analysis
659// ---------------------------------------------------------------------------
660
661#[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
728/// Build the causal chain for a specific bead from its history events and commits.
729pub 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    // Collect raw events
741    let mut raw_events: Vec<(String, String, Option<String>, Option<String>)> = Vec::new();
742    // (timestamp, type, commit_sha, blocker_id)
743
744    if let Some(h) = history {
745        // Lifecycle events
746        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        // Commit events
756        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    // Check for blocking relationships
769    let blockers = graph.blockers(bead_id);
770    for blocker_id in &blockers {
771        if let Some(blocker_history) = histories.get(blocker_id) {
772            // Find when blocker was closed (unblocked event)
773            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            // Find when blocking relationship was created (blocked event)
786            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    // Sort by timestamp, then by event type for stability
798    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    // Build causal events
802    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    // Calculate duration_next_ms between consecutive events
845    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    // Compute insights
864    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    // Find blocked periods
874    let mut blocked_periods = Vec::new();
875    let mut blocked_start: Option<(String, String)> = None; // (timestamp, blocker_id)
876
877    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    // Compute gaps
910    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    // Generate summary and recommendations
932    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
985/// Parse an ISO-8601 timestamp string to milliseconds since epoch (public API).
986pub fn parse_timestamp_ms_pub(ts: &str) -> Option<u64> {
987    parse_timestamp_ms(ts)
988}
989
990/// Parse an ISO-8601 timestamp string to milliseconds since epoch.
991fn parse_timestamp_ms(ts: &str) -> Option<u64> {
992    if ts.is_empty() {
993        return None;
994    }
995    // Parse RFC3339/ISO-8601 timestamps
996    // Format: 2025-02-27T07:00:00Z or 2025-02-27T07:00:00+00:00
997    let ts = ts.trim();
998
999    // Try to extract year-month-day hour:minute:second
1000    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    // Simple epoch calculation (approximate, good enough for duration differences)
1035    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// ---------------------------------------------------------------------------
1065// Tests
1066// ---------------------------------------------------------------------------
1067
1068#[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    // ---- Blocker Chain Tests ----
1169
1170    #[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); // B at depth 1
1213        assert_eq!(result.chain[1].depth, 2); // A at depth 2
1214        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); // A is closed, so B is not blocked
1228    }
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        // Root blockers sorted by priority
1241        assert_eq!(result.root_blockers[0].id, "R2"); // priority 1
1242        assert_eq!(result.root_blockers[1].id, "R1"); // priority 3
1243    }
1244
1245    // ---- Impact Network Tests ----
1246
1247    #[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        // Should have a shared_commit edge
1306        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        // Should include A and B (connected by dependency), but not C or D
1325        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    // ---- Causality Tests ----
1331
1332    #[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); // created, claimed, commit, closed
1371        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        // Should have blocked and unblocked events from the blocker
1403        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        // First event has no caused_by
1439        assert!(result.chain.events[0].caused_by_id.is_none());
1440        assert_eq!(result.chain.events[0].enables_ids, vec![1]);
1441        // Second event caused by first
1442        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); // Exactly one day in ms
1453    }
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    // --- parse_timestamp_ms edge cases ---
1462
1463    #[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        // time_parts.len() >= 2 should still work with just hour:minute
1483        let ms = parse_timestamp_ms("2025-01-01T12:30Z");
1484        assert!(ms.is_some());
1485    }
1486
1487    // --- days_from_date tests ---
1488
1489    #[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        // 2000 is a leap year, Feb has 29 days
1527        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); // Feb 28 → Feb 29 → Mar 1
1530    }
1531
1532    // --- get_blocker_chain edge cases ---
1533
1534    #[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"); // priority 1
1555        assert_eq!(result.root_blockers[1].id, "R3"); // priority 3
1556        assert_eq!(result.root_blockers[2].id, "R1"); // priority 5
1557    }
1558
1559    // --- get_subnetwork tests ---
1560
1561    #[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        // depth=100 should be clamped to 3
1572        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        // Unknown bead won't match any nodes
1588        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    // --- build_impact_network_result tests ---
1606
1607    #[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    // --- Impact network shared file edges ---
1650
1651    #[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        // Only 1 shared file — should NOT create a shared_file edge
1661        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    // --- Network stats ---
1713
1714    #[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); // C is isolated
1728        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    // --- Causality insights ---
1756
1757    #[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        // Blocked period should be substantial
1791        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    // --- Cluster detection ---
1904
1905    #[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        // No edges, so no clusters
1916        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}