Skip to main content

bvr/analysis/
file_intel.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use super::git_history::HistoryBeadCompat;
6
7// ---------------------------------------------------------------------------
8// Orphan detection
9// ---------------------------------------------------------------------------
10
11#[derive(Debug, Clone, Serialize)]
12pub struct OrphanCandidate {
13    pub sha: String,
14    pub short_sha: String,
15    pub message: String,
16    pub author: String,
17    pub author_email: String,
18    pub timestamp: String,
19    pub files: Vec<String>,
20    pub suspicion_score: u32,
21    pub probable_beads: Vec<ProbableBead>,
22    pub signals: Vec<OrphanSignalHit>,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct ProbableBead {
27    pub bead_id: String,
28    pub title: String,
29    pub status: String,
30    pub confidence: u32,
31    pub reasons: Vec<String>,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub struct OrphanSignalHit {
36    pub signal: String,
37    pub weight: u32,
38    pub detail: String,
39}
40
41#[derive(Debug, Clone, Serialize)]
42pub struct OrphanStats {
43    pub total_commits: usize,
44    pub correlated_count: usize,
45    pub orphan_count: usize,
46    pub candidate_count: usize,
47    pub orphan_ratio: f64,
48    pub avg_suspicion: f64,
49}
50
51#[derive(Debug, Clone, Serialize)]
52pub struct OrphanReport {
53    pub stats: OrphanStats,
54    pub candidates: Vec<OrphanCandidate>,
55}
56
57/// Detect orphan commits — commits not correlated with any bead.
58///
59/// Uses heuristics (message patterns, file overlap, author proximity) to
60/// score orphans and suggest probable bead links.
61#[must_use]
62pub fn detect_orphans(
63    all_commits: &[super::git_history::GitCommitRecord],
64    histories: &BTreeMap<String, HistoryBeadCompat>,
65    commit_index: &BTreeMap<String, Vec<String>>,
66    min_score: u32,
67) -> OrphanReport {
68    // Build file→bead index for file overlap checks
69    let file_bead_map = build_file_bead_map(histories);
70
71    // Build author→recent-commit-timestamps for author proximity
72    let author_linked: BTreeMap<String, Vec<&str>> = {
73        let mut map = BTreeMap::<String, Vec<&str>>::new();
74        for history in histories.values() {
75            for commit in history.commits.as_deref().unwrap_or_default() {
76                let key = commit.author_email.to_ascii_lowercase();
77                map.entry(key).or_default().push(&commit.timestamp);
78            }
79        }
80        map
81    };
82
83    let total_commits = all_commits.len();
84    let mut orphan_commits = Vec::new();
85
86    for commit in all_commits {
87        if commit_index.contains_key(&commit.sha) {
88            continue; // Already correlated
89        }
90
91        let files: Vec<String> = commit.files.iter().map(|f| f.path.clone()).collect();
92
93        let mut signals = Vec::new();
94        let mut probable_beads_map = BTreeMap::<String, (u32, Vec<String>)>::new();
95
96        // Signal 1: Message patterns
97        check_message_patterns(&commit.message, &mut signals);
98
99        // Signal 2: File overlap with bead-touched files
100        let mut overlapping_files = 0;
101        for file_path in &files {
102            let normalized = normalize_path(file_path);
103            if let Some(bead_ids) = file_bead_map.get(&normalized) {
104                overlapping_files += 1;
105                for bead_id in bead_ids {
106                    let entry = probable_beads_map
107                        .entry(bead_id.clone())
108                        .or_insert_with(|| (0, Vec::new()));
109                    entry.0 += 25;
110                    entry.1.push(format!("File overlap: {normalized}"));
111                }
112            }
113        }
114
115        if overlapping_files > 0 {
116            signals.push(OrphanSignalHit {
117                signal: "file_overlap".to_string(),
118                weight: 25,
119                detail: format!("{overlapping_files} file(s) overlap with bead-tracked files"),
120            });
121        }
122
123        // Signal 3: Author proximity (author has linked commits)
124        let author_key = commit.author_email.to_ascii_lowercase();
125        if author_linked.contains_key(&author_key) {
126            signals.push(OrphanSignalHit {
127                signal: "author_proximity".to_string(),
128                weight: 15,
129                detail: format!("Author {} has linked commits", commit.author),
130            });
131        }
132
133        let total_score: u32 = signals.iter().map(|s| s.weight).sum::<u32>().min(100);
134
135        if total_score < min_score {
136            continue;
137        }
138
139        // Build probable beads list (top 3)
140        let mut probable_beads: Vec<ProbableBead> = probable_beads_map
141            .into_iter()
142            .filter_map(|(bead_id, (conf, reasons))| {
143                histories.get(&bead_id).map(|h| ProbableBead {
144                    bead_id: h.bead_id.clone(),
145                    title: h.title.clone(),
146                    status: h.status.clone(),
147                    confidence: conf.min(100),
148                    reasons,
149                })
150            })
151            .collect();
152        probable_beads.sort_by_key(|b| std::cmp::Reverse(b.confidence));
153        probable_beads.truncate(3);
154
155        orphan_commits.push(OrphanCandidate {
156            sha: commit.sha.clone(),
157            short_sha: commit.short_sha.clone(),
158            message: commit.message.clone(),
159            author: commit.author.clone(),
160            author_email: commit.author_email.clone(),
161            timestamp: commit.timestamp.clone(),
162            files,
163            suspicion_score: total_score,
164            probable_beads,
165            signals,
166        });
167    }
168
169    orphan_commits.sort_by(|a, b| {
170        b.suspicion_score
171            .cmp(&a.suspicion_score)
172            .then_with(|| a.sha.cmp(&b.sha))
173    });
174
175    let correlated_count = commit_index.len();
176    let orphan_count = total_commits.saturating_sub(correlated_count);
177    let candidate_count = orphan_commits.len();
178    let avg_suspicion = if candidate_count > 0 {
179        orphan_commits
180            .iter()
181            .map(|c| f64::from(c.suspicion_score))
182            .sum::<f64>()
183            / candidate_count as f64
184    } else {
185        0.0
186    };
187    let orphan_ratio = if total_commits > 0 {
188        orphan_count as f64 / total_commits as f64
189    } else {
190        0.0
191    };
192
193    OrphanReport {
194        stats: OrphanStats {
195            total_commits,
196            correlated_count,
197            orphan_count,
198            candidate_count,
199            orphan_ratio,
200            avg_suspicion,
201        },
202        candidates: orphan_commits,
203    }
204}
205
206fn check_message_patterns(message: &str, signals: &mut Vec<OrphanSignalHit>) {
207    let lower = message.to_ascii_lowercase();
208
209    let word_patterns: &[(&[&str], &str, u32)] = &[
210        (&["fix", "fixed", "fixes"], "fix/fixed pattern", 10),
211        (&["close", "closed", "closes"], "close/closes pattern", 10),
212        (&["resolve", "resolved", "resolves"], "resolve pattern", 10),
213        (
214            &["implement", "implemented", "implements"],
215            "implement pattern",
216            8,
217        ),
218        (&["add", "added", "adds"], "add/added pattern", 5),
219    ];
220
221    let mut total_weight = 0u32;
222
223    for (words, detail, weight) in word_patterns {
224        if words.iter().any(|w| has_word_boundary(&lower, w)) {
225            total_weight += weight;
226            signals.push(OrphanSignalHit {
227                signal: "message_pattern".to_string(),
228                weight: *weight,
229                detail: detail.to_string(),
230            });
231        }
232    }
233
234    // Check for issue reference (#N)
235    if has_issue_ref_pattern(&lower) {
236        total_weight += 15;
237        signals.push(OrphanSignalHit {
238            signal: "message_pattern".to_string(),
239            weight: 15,
240            detail: "issue reference (#N)".to_string(),
241        });
242    }
243
244    // Check for bead-like ID pattern (word-word-digits)
245    if has_bead_id_pattern(&lower) {
246        total_weight += 20;
247        signals.push(OrphanSignalHit {
248            signal: "message_pattern".to_string(),
249            weight: 20,
250            detail: "bead-like ID pattern".to_string(),
251        });
252    }
253
254    // Cap total message signal weight at 35
255    if total_weight > 35 {
256        let excess = total_weight - 35;
257        let mut remaining = excess;
258        for signal in signals.iter_mut().rev() {
259            if signal.signal == "message_pattern" && remaining > 0 {
260                let reduction = signal.weight.min(remaining);
261                signal.weight -= reduction;
262                remaining -= reduction;
263            }
264        }
265    }
266}
267
268fn has_word_boundary(text: &str, word: &str) -> bool {
269    text.match_indices(word).any(|(start, matched)| {
270        let left = if start > 0 {
271            text.as_bytes().get(start - 1).copied()
272        } else {
273            None
274        };
275        let right = text.as_bytes().get(start + matched.len()).copied();
276
277        let left_ok = left.is_none_or(|c| !c.is_ascii_alphanumeric());
278        let right_ok = right.is_none_or(|c| !c.is_ascii_alphanumeric());
279        left_ok && right_ok
280    })
281}
282
283fn has_issue_ref_pattern(text: &str) -> bool {
284    // Look for #N pattern
285    text.match_indices('#').any(|(pos, _)| {
286        text[pos + 1..]
287            .chars()
288            .next()
289            .is_some_and(|c| c.is_ascii_digit())
290    })
291}
292
293fn has_bead_id_pattern(text: &str) -> bool {
294    // Look for patterns like "bd-123", "bv-abc", "feat-42"
295    for (pos, _) in text.match_indices('-') {
296        // Check left side has alpha chars
297        let left = &text[..pos];
298        let has_alpha_left = left
299            .chars()
300            .rev()
301            .take_while(char::is_ascii_alphanumeric)
302            .any(|c: char| c.is_ascii_alphabetic());
303        // Check right side has digits
304        let right = &text[pos + 1..];
305        let has_digit_right = right
306            .chars()
307            .take_while(char::is_ascii_alphanumeric)
308            .any(|c: char| c.is_ascii_digit());
309
310        if has_alpha_left && has_digit_right {
311            return true;
312        }
313    }
314    false
315}
316
317fn build_file_bead_map(
318    histories: &BTreeMap<String, HistoryBeadCompat>,
319) -> BTreeMap<String, BTreeSet<String>> {
320    let mut map = BTreeMap::<String, BTreeSet<String>>::new();
321    for history in histories.values() {
322        for commit in history.commits.as_deref().unwrap_or_default() {
323            for file in &commit.files {
324                let normalized = normalize_path(&file.path);
325                map.entry(normalized)
326                    .or_default()
327                    .insert(history.bead_id.clone());
328            }
329        }
330    }
331    map
332}
333
334fn normalize_path(path: &str) -> String {
335    let normalized = path.trim().replace('\\', "/");
336    let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
337    normalized.trim_end_matches('/').to_string()
338}
339
340// ---------------------------------------------------------------------------
341// File-to-bead mapping
342// ---------------------------------------------------------------------------
343
344#[derive(Debug, Clone, Serialize)]
345pub struct BeadReference {
346    pub bead_id: String,
347    pub title: String,
348    pub status: String,
349    pub commit_count: usize,
350    pub last_touch: String,
351    pub total_changes: i64,
352}
353
354#[derive(Debug, Clone, Serialize)]
355pub struct FileBeadLookupResult {
356    pub file_path: String,
357    pub open_beads: Vec<BeadReference>,
358    pub closed_beads: Vec<BeadReference>,
359    pub total_beads: usize,
360}
361
362#[derive(Debug, Clone, Serialize)]
363pub struct FileIndexStats {
364    pub total_files: usize,
365    pub total_bead_links: usize,
366    pub files_with_multiple_beads: usize,
367}
368
369/// Look up which beads have touched a given file path.
370///
371/// Supports exact match and prefix (directory) matching.
372#[must_use]
373pub fn lookup_file_beads(
374    path: &str,
375    histories: &BTreeMap<String, HistoryBeadCompat>,
376    closed_limit: usize,
377) -> FileBeadLookupResult {
378    let target = normalize_path(path);
379    let mut bead_refs = BTreeMap::<String, (Vec<String>, String, i64)>::new();
380
381    for history in histories.values() {
382        for commit in history.commits.as_deref().unwrap_or_default() {
383            let matches = commit.files.iter().any(|f| {
384                let norm = normalize_path(&f.path);
385                norm == target || norm.starts_with(&format!("{target}/"))
386            });
387
388            if matches {
389                let entry = bead_refs
390                    .entry(history.bead_id.clone())
391                    .or_insert_with(|| (Vec::new(), String::new(), 0));
392                entry.0.push(commit.sha.clone());
393                if entry.1.is_empty() || commit.timestamp > entry.1 {
394                    entry.1.clone_from(&commit.timestamp);
395                }
396                for f in &commit.files {
397                    let norm = normalize_path(&f.path);
398                    if norm == target || norm.starts_with(&format!("{target}/")) {
399                        entry.2 += f.insertions + f.deletions;
400                    }
401                }
402            }
403        }
404    }
405
406    let mut open_beads = Vec::new();
407    let mut closed_beads = Vec::new();
408
409    for (bead_id, (shas, last_touch, total_changes)) in &bead_refs {
410        let Some(history) = histories.get(bead_id) else {
411            continue;
412        };
413
414        let reference = BeadReference {
415            bead_id: bead_id.clone(),
416            title: history.title.clone(),
417            status: history.status.clone(),
418            commit_count: shas.len(),
419            last_touch: last_touch.clone(),
420            total_changes: *total_changes,
421        };
422
423        if is_open_status(&history.status) {
424            open_beads.push(reference);
425        } else {
426            closed_beads.push(reference);
427        }
428    }
429
430    // Sort by commit_count descending, then by bead_id
431    open_beads.sort_by(|a, b| {
432        b.commit_count
433            .cmp(&a.commit_count)
434            .then_with(|| a.bead_id.cmp(&b.bead_id))
435    });
436    closed_beads.sort_by(|a, b| {
437        b.commit_count
438            .cmp(&a.commit_count)
439            .then_with(|| a.bead_id.cmp(&b.bead_id))
440    });
441
442    if closed_limit > 0 {
443        closed_beads.truncate(closed_limit);
444    }
445
446    let total_beads = open_beads.len() + closed_beads.len();
447
448    FileBeadLookupResult {
449        file_path: target,
450        open_beads,
451        closed_beads,
452        total_beads,
453    }
454}
455
456fn is_open_status(status: &str) -> bool {
457    !matches!(
458        status.trim().to_ascii_lowercase().as_str(),
459        "closed" | "tombstone"
460    )
461}
462
463// ---------------------------------------------------------------------------
464// File hotspots
465// ---------------------------------------------------------------------------
466
467#[derive(Debug, Clone, Serialize)]
468pub struct FileHotspot {
469    pub file_path: String,
470    pub total_beads: usize,
471    pub open_beads: usize,
472    pub closed_beads: usize,
473}
474
475/// Find files touched by the most beads ("hotspots").
476#[must_use]
477pub fn compute_hotspots(
478    histories: &BTreeMap<String, HistoryBeadCompat>,
479    limit: usize,
480) -> Vec<FileHotspot> {
481    // file → set of (bead_id, is_open)
482    let mut file_beads = BTreeMap::<String, BTreeMap<String, bool>>::new();
483
484    for history in histories.values() {
485        let is_open = is_open_status(&history.status);
486        for commit in history.commits.as_deref().unwrap_or_default() {
487            for file in &commit.files {
488                let normalized = normalize_path(&file.path);
489                file_beads
490                    .entry(normalized)
491                    .or_default()
492                    .insert(history.bead_id.clone(), is_open);
493            }
494        }
495    }
496
497    let mut hotspots: Vec<FileHotspot> = file_beads
498        .into_iter()
499        .map(|(path, beads)| {
500            let open = beads.values().filter(|&&is_open| is_open).count();
501            let closed = beads.len() - open;
502            FileHotspot {
503                file_path: path,
504                total_beads: beads.len(),
505                open_beads: open,
506                closed_beads: closed,
507            }
508        })
509        .collect();
510
511    hotspots.sort_by(|a, b| {
512        b.total_beads
513            .cmp(&a.total_beads)
514            .then_with(|| a.file_path.cmp(&b.file_path))
515    });
516
517    if limit > 0 {
518        hotspots.truncate(limit);
519    }
520
521    hotspots
522}
523
524/// Compute file index statistics.
525#[must_use]
526pub fn compute_file_index_stats(histories: &BTreeMap<String, HistoryBeadCompat>) -> FileIndexStats {
527    let mut file_beads = BTreeMap::<String, BTreeSet<String>>::new();
528
529    for history in histories.values() {
530        for commit in history.commits.as_deref().unwrap_or_default() {
531            for file in &commit.files {
532                let normalized = normalize_path(&file.path);
533                file_beads
534                    .entry(normalized)
535                    .or_default()
536                    .insert(history.bead_id.clone());
537            }
538        }
539    }
540
541    let total_files = file_beads.len();
542    let total_bead_links: usize = file_beads.values().map(BTreeSet::len).sum();
543    let files_with_multiple_beads = file_beads.values().filter(|s| s.len() > 1).count();
544
545    FileIndexStats {
546        total_files,
547        total_bead_links,
548        files_with_multiple_beads,
549    }
550}
551
552// ---------------------------------------------------------------------------
553// Impact analysis
554// ---------------------------------------------------------------------------
555
556#[derive(Debug, Clone, Serialize)]
557pub struct AffectedBead {
558    pub bead_id: String,
559    pub title: String,
560    pub status: String,
561    pub overlap_files: Vec<String>,
562    pub overlap_count: usize,
563    pub relevance: f64,
564}
565
566#[derive(Debug, Clone, Serialize)]
567pub struct ImpactResult {
568    pub files: Vec<String>,
569    pub affected_beads: Vec<AffectedBead>,
570    pub risk_level: String,
571    pub risk_score: f64,
572    pub summary: String,
573}
574
575/// Analyze the impact of modifying a set of files.
576///
577/// Returns affected beads with relevance scoring and an overall risk level.
578#[must_use]
579pub fn analyze_impact(
580    file_paths: &[String],
581    histories: &BTreeMap<String, HistoryBeadCompat>,
582) -> ImpactResult {
583    let mut targets = BTreeSet::<String>::new();
584    let mut normalized_targets = Vec::<String>::new();
585    for path in file_paths {
586        let normalized = normalize_path(path);
587        if normalized.is_empty() || !targets.insert(normalized.clone()) {
588            continue;
589        }
590        normalized_targets.push(normalized);
591    }
592
593    // Find all beads that touched any of the target files
594    let mut bead_overlaps = BTreeMap::<String, BTreeSet<String>>::new();
595    for history in histories.values() {
596        for commit in history.commits.as_deref().unwrap_or_default() {
597            for file in &commit.files {
598                let norm = normalize_path(&file.path);
599                if targets.contains(&norm) {
600                    bead_overlaps
601                        .entry(history.bead_id.clone())
602                        .or_default()
603                        .insert(norm);
604                }
605            }
606        }
607    }
608
609    let mut risk_score = 0.0_f64;
610    let mut affected_beads = Vec::new();
611
612    for (bead_id, overlap_files) in &bead_overlaps {
613        let Some(history) = histories.get(bead_id) else {
614            continue;
615        };
616
617        let status_weight = match history.status.to_ascii_lowercase().as_str() {
618            "in_progress" => 0.4,
619            "open" | "ready" | "blocked" | "deferred" | "pinned" | "hooked" | "review" => 0.2,
620            "closed" | "tombstone" => 0.05,
621            _ => 0.1,
622        };
623
624        let overlap_ratio = if targets.is_empty() {
625            0.0
626        } else {
627            overlap_files.len() as f64 / targets.len() as f64
628        };
629
630        let relevance = (overlap_ratio * 0.5 + status_weight * 0.5).min(1.0);
631        risk_score += status_weight;
632
633        affected_beads.push(AffectedBead {
634            bead_id: bead_id.clone(),
635            title: history.title.clone(),
636            status: history.status.clone(),
637            overlap_files: overlap_files.iter().cloned().collect(),
638            overlap_count: overlap_files.len(),
639            relevance,
640        });
641    }
642
643    // Multiple file modification boosts risk
644    if targets.len() > 1 {
645        risk_score += 0.1;
646    }
647
648    risk_score = risk_score.min(1.0);
649
650    let risk_level = if risk_score >= 0.7 {
651        "critical"
652    } else if risk_score >= 0.4 {
653        "high"
654    } else if risk_score >= 0.2 {
655        "medium"
656    } else {
657        "low"
658    };
659
660    affected_beads.sort_by(|a, b| {
661        b.relevance
662            .total_cmp(&a.relevance)
663            .then_with(|| a.bead_id.cmp(&b.bead_id))
664    });
665
666    let summary = format!(
667        "{} file(s) affect {} bead(s), risk: {}",
668        targets.len(),
669        affected_beads.len(),
670        risk_level
671    );
672
673    ImpactResult {
674        files: normalized_targets,
675        affected_beads,
676        risk_level: risk_level.to_string(),
677        risk_score,
678        summary,
679    }
680}
681
682// ---------------------------------------------------------------------------
683// Co-change / file relations
684// ---------------------------------------------------------------------------
685
686#[derive(Debug, Clone, Serialize)]
687pub struct CoChangeEntry {
688    pub file_path: String,
689    pub co_change_count: usize,
690    pub total_commits: usize,
691    pub correlation: f64,
692}
693
694#[derive(Debug, Clone, Serialize)]
695pub struct FileRelationsResult {
696    pub source_file: String,
697    pub related_files: Vec<CoChangeEntry>,
698    pub total_commits_for_source: usize,
699}
700
701/// Compute co-change relationships for a given file.
702///
703/// Finds files that are frequently modified together in the same commits,
704/// filtered by correlation threshold and result limit.
705#[must_use]
706pub fn compute_file_relations(
707    source_path: &str,
708    histories: &BTreeMap<String, HistoryBeadCompat>,
709    threshold: f64,
710    limit: usize,
711) -> FileRelationsResult {
712    let target = normalize_path(source_path);
713
714    // Collect all commits that touch the target file (deduplicated by SHA)
715    let mut target_commits = BTreeMap::<String, Vec<String>>::new(); // SHA → all files in commit
716
717    let mut seen_shas = BTreeSet::new();
718    for history in histories.values() {
719        for commit in history.commits.as_deref().unwrap_or_default() {
720            if seen_shas.contains(&commit.sha) {
721                continue;
722            }
723
724            let touches_target = commit
725                .files
726                .iter()
727                .any(|f| normalize_path(&f.path) == target);
728            if touches_target {
729                seen_shas.insert(commit.sha.clone());
730                let all_files: Vec<String> = commit
731                    .files
732                    .iter()
733                    .map(|f| normalize_path(&f.path))
734                    .filter(|p| p != &target)
735                    .collect::<BTreeSet<_>>()
736                    .into_iter()
737                    .collect();
738                target_commits.insert(commit.sha.clone(), all_files);
739            }
740        }
741    }
742
743    let total_commits_for_source = target_commits.len();
744
745    // Count co-changes per file
746    let mut co_counts = BTreeMap::<String, usize>::new();
747    for files in target_commits.values() {
748        for file in files {
749            *co_counts.entry(file.clone()).or_insert(0) += 1;
750        }
751    }
752
753    let mut related: Vec<CoChangeEntry> = co_counts
754        .into_iter()
755        .map(|(path, count)| {
756            let correlation = if total_commits_for_source > 0 {
757                count as f64 / total_commits_for_source as f64
758            } else {
759                0.0
760            };
761            CoChangeEntry {
762                file_path: path,
763                co_change_count: count,
764                total_commits: total_commits_for_source,
765                correlation,
766            }
767        })
768        .filter(|e| e.correlation >= threshold)
769        .collect();
770
771    related.sort_by(|a, b| {
772        b.correlation
773            .total_cmp(&a.correlation)
774            .then_with(|| a.file_path.cmp(&b.file_path))
775    });
776
777    if limit > 0 {
778        related.truncate(limit);
779    }
780
781    FileRelationsResult {
782        source_file: target,
783        related_files: related,
784        total_commits_for_source,
785    }
786}
787
788// ---------------------------------------------------------------------------
789// Related work (bead-to-bead similarity via shared files)
790// ---------------------------------------------------------------------------
791
792#[derive(Debug, Clone, Serialize)]
793pub struct RelatedBead {
794    pub bead_id: String,
795    pub title: String,
796    pub status: String,
797    pub shared_files: Vec<String>,
798    pub shared_file_count: usize,
799    pub relevance: f64,
800}
801
802#[derive(Debug, Clone, Serialize)]
803pub struct RelatedWorkResult {
804    pub source_bead: String,
805    pub related: Vec<RelatedBead>,
806}
807
808/// Find beads related to a given bead based on shared file modifications.
809///
810/// Includes closed beads. Use [`find_related_work_with_options`] to control this.
811#[must_use]
812pub fn find_related_work(
813    bead_id: &str,
814    histories: &BTreeMap<String, HistoryBeadCompat>,
815    min_relevance: u32,
816    max_results: usize,
817) -> RelatedWorkResult {
818    find_related_work_with_options(bead_id, histories, min_relevance, max_results, true)
819}
820
821/// Find beads related to a given bead, with explicit closed-issue control.
822#[must_use]
823pub fn find_related_work_with_options(
824    bead_id: &str,
825    histories: &BTreeMap<String, HistoryBeadCompat>,
826    min_relevance: u32,
827    max_results: usize,
828    include_closed: bool,
829) -> RelatedWorkResult {
830    let Some(source) = histories.get(bead_id) else {
831        return RelatedWorkResult {
832            source_bead: bead_id.to_string(),
833            related: Vec::new(),
834        };
835    };
836
837    // Gather all files touched by the source bead
838    let source_files: BTreeSet<String> = source
839        .commits
840        .as_deref()
841        .unwrap_or_default()
842        .iter()
843        .flat_map(|c| c.files.iter().map(|f| normalize_path(&f.path)))
844        .collect();
845
846    if source_files.is_empty() {
847        return RelatedWorkResult {
848            source_bead: bead_id.to_string(),
849            related: Vec::new(),
850        };
851    }
852
853    // Find other beads that share files
854    let mut related = Vec::new();
855    for (other_id, other_history) in histories {
856        if other_id == bead_id {
857            continue;
858        }
859
860        // Skip closed/tombstone beads unless include_closed is set
861        if !include_closed {
862            let normalized = other_history.status.trim().to_ascii_lowercase();
863            if normalized == "closed" || normalized == "tombstone" {
864                continue;
865            }
866        }
867
868        let other_files: BTreeSet<String> = other_history
869            .commits
870            .as_deref()
871            .unwrap_or_default()
872            .iter()
873            .flat_map(|c| c.files.iter().map(|f| normalize_path(&f.path)))
874            .collect();
875
876        let shared: Vec<String> = source_files.intersection(&other_files).cloned().collect();
877
878        if shared.is_empty() {
879            continue;
880        }
881
882        let relevance = shared.len() as f64 / source_files.len() as f64;
883
884        related.push(RelatedBead {
885            bead_id: other_id.clone(),
886            title: other_history.title.clone(),
887            status: other_history.status.clone(),
888            shared_file_count: shared.len(),
889            shared_files: shared,
890            relevance,
891        });
892    }
893
894    related.sort_by(|a, b| {
895        b.relevance
896            .total_cmp(&a.relevance)
897            .then_with(|| a.bead_id.cmp(&b.bead_id))
898    });
899
900    if min_relevance > 0 {
901        let threshold = f64::from(min_relevance.min(100)) / 100.0;
902        related.retain(|r| r.relevance >= threshold);
903    }
904
905    if max_results > 0 {
906        related.truncate(max_results);
907    }
908
909    RelatedWorkResult {
910        source_bead: bead_id.to_string(),
911        related,
912    }
913}
914
915// ---------------------------------------------------------------------------
916// Robot output structs
917// ---------------------------------------------------------------------------
918
919#[derive(Debug, Serialize)]
920pub struct RobotOrphansOutput {
921    #[serde(flatten)]
922    pub envelope: crate::robot::RobotEnvelope,
923    #[serde(flatten)]
924    pub report: OrphanReport,
925}
926
927#[derive(Debug, Serialize)]
928pub struct RobotFileBeadsOutput {
929    #[serde(flatten)]
930    pub envelope: crate::robot::RobotEnvelope,
931    #[serde(flatten)]
932    pub result: FileBeadLookupResult,
933}
934
935#[derive(Debug, Serialize)]
936pub struct RobotFileHotspotsOutput {
937    #[serde(flatten)]
938    pub envelope: crate::robot::RobotEnvelope,
939    pub hotspots: Vec<FileHotspot>,
940    pub stats: FileIndexStats,
941}
942
943#[derive(Debug, Serialize)]
944pub struct RobotImpactOutput {
945    #[serde(flatten)]
946    pub envelope: crate::robot::RobotEnvelope,
947    #[serde(flatten)]
948    pub result: ImpactResult,
949}
950
951#[derive(Debug, Serialize)]
952pub struct RobotFileRelationsOutput {
953    #[serde(flatten)]
954    pub envelope: crate::robot::RobotEnvelope,
955    #[serde(flatten)]
956    pub result: FileRelationsResult,
957}
958
959#[derive(Debug, Serialize)]
960pub struct RobotRelatedWorkOutput {
961    #[serde(flatten)]
962    pub envelope: crate::robot::RobotEnvelope,
963    #[serde(flatten)]
964    pub result: RelatedWorkResult,
965}
966
967// ---------------------------------------------------------------------------
968// Tests
969// ---------------------------------------------------------------------------
970
971#[cfg(test)]
972mod tests {
973    use super::*;
974    use crate::analysis::git_history::{
975        GitCommitRecord, HistoryBeadCompat, HistoryCommitCompat, HistoryFileChangeCompat,
976        HistoryMilestonesCompat,
977    };
978
979    fn make_history(bead_id: &str, status: &str, files: &[&str]) -> HistoryBeadCompat {
980        let commits = files
981            .iter()
982            .enumerate()
983            .map(|(i, path)| HistoryCommitCompat {
984                sha: format!("commit-{bead_id}-{i}"),
985                short_sha: format!("c{i}"),
986                message: format!("work on {bead_id}"),
987                author: "TestUser".to_string(),
988                author_email: "test@example.com".to_string(),
989                timestamp: format!("2026-01-{:02}T10:00:00Z", i + 1),
990                files: vec![HistoryFileChangeCompat {
991                    path: path.to_string(),
992                    action: "M".to_string(),
993                    insertions: 10,
994                    deletions: 2,
995                }],
996                method: "explicit_id".to_string(),
997                confidence: 0.85,
998                reason: "test".to_string(),
999                field_changes: vec![],
1000                bead_diff_lines: vec![],
1001            })
1002            .collect();
1003
1004        HistoryBeadCompat {
1005            bead_id: bead_id.to_string(),
1006            title: format!("Bead {bead_id}"),
1007            status: status.to_string(),
1008            events: vec![],
1009            milestones: HistoryMilestonesCompat::default(),
1010            commits: Some(commits),
1011            cycle_time: None,
1012            last_author: "TestUser".to_string(),
1013        }
1014    }
1015
1016    fn make_git_commit(sha: &str, message: &str, files: &[&str]) -> GitCommitRecord {
1017        GitCommitRecord {
1018            sha: sha.to_string(),
1019            short_sha: sha[..7.min(sha.len())].to_string(),
1020            timestamp: "2026-01-15T10:00:00Z".to_string(),
1021            author: "TestUser".to_string(),
1022            author_email: "test@example.com".to_string(),
1023            message: message.to_string(),
1024            files: files
1025                .iter()
1026                .map(|p| HistoryFileChangeCompat {
1027                    path: p.to_string(),
1028                    action: "M".to_string(),
1029                    insertions: 5,
1030                    deletions: 1,
1031                })
1032                .collect(),
1033            changed_beads: false,
1034            changed_non_beads: true,
1035        }
1036    }
1037
1038    #[test]
1039    fn orphan_detection_basic() {
1040        let mut histories = BTreeMap::new();
1041        histories.insert(
1042            "bd-1".to_string(),
1043            make_history("bd-1", "open", &["src/main.rs"]),
1044        );
1045
1046        let mut commit_index = BTreeMap::new();
1047        commit_index.insert("commit-bd-1-0".to_string(), vec!["bd-1".to_string()]);
1048
1049        let all_commits = vec![
1050            make_git_commit("commit-bd-1-0", "work on bd-1", &["src/main.rs"]),
1051            make_git_commit("orphan-sha-001", "fix bug in main", &["src/main.rs"]),
1052        ];
1053
1054        let report = detect_orphans(&all_commits, &histories, &commit_index, 0);
1055
1056        assert_eq!(report.stats.total_commits, 2);
1057        assert_eq!(report.stats.correlated_count, 1);
1058        assert_eq!(report.stats.orphan_count, 1);
1059        assert!(!report.candidates.is_empty());
1060        assert_eq!(report.candidates[0].sha, "orphan-sha-001");
1061    }
1062
1063    #[test]
1064    fn orphan_min_score_filter() {
1065        let histories = BTreeMap::new();
1066        let commit_index = BTreeMap::new();
1067        let all_commits = vec![make_git_commit("sha-1", "update docs", &["README.md"])];
1068
1069        let report_low = detect_orphans(&all_commits, &histories, &commit_index, 0);
1070        let report_high = detect_orphans(&all_commits, &histories, &commit_index, 90);
1071
1072        assert!(report_low.candidates.len() >= report_high.candidates.len());
1073    }
1074
1075    #[test]
1076    fn file_beads_lookup() {
1077        let mut histories = BTreeMap::new();
1078        histories.insert(
1079            "bd-1".to_string(),
1080            make_history("bd-1", "open", &["src/lib.rs", "src/main.rs"]),
1081        );
1082        histories.insert(
1083            "bd-2".to_string(),
1084            make_history("bd-2", "closed", &["src/lib.rs"]),
1085        );
1086
1087        let result = lookup_file_beads("src/lib.rs", &histories, 20);
1088
1089        assert_eq!(result.file_path, "src/lib.rs");
1090        assert_eq!(result.open_beads.len(), 1);
1091        assert_eq!(result.closed_beads.len(), 1);
1092        assert_eq!(result.total_beads, 2);
1093    }
1094
1095    #[test]
1096    fn file_beads_lookup_treats_review_as_open() {
1097        let mut histories = BTreeMap::new();
1098        histories.insert(
1099            "bd-review".to_string(),
1100            make_history("bd-review", "review", &["src/lib.rs"]),
1101        );
1102        histories.insert(
1103            "bd-closed".to_string(),
1104            make_history("bd-closed", "closed", &["src/lib.rs"]),
1105        );
1106
1107        let result = lookup_file_beads("src/lib.rs", &histories, 10);
1108
1109        assert_eq!(result.open_beads.len(), 1);
1110        assert_eq!(result.open_beads[0].bead_id, "bd-review");
1111        assert_eq!(result.closed_beads.len(), 1);
1112    }
1113
1114    #[test]
1115    fn file_beads_closed_limit() {
1116        let mut histories = BTreeMap::new();
1117        for i in 0..5 {
1118            histories.insert(
1119                format!("bd-c{i}"),
1120                make_history(&format!("bd-c{i}"), "closed", &["shared.rs"]),
1121            );
1122        }
1123
1124        let result = lookup_file_beads("shared.rs", &histories, 2);
1125        assert_eq!(result.closed_beads.len(), 2);
1126    }
1127
1128    #[test]
1129    fn hotspots_ranking() {
1130        let mut histories = BTreeMap::new();
1131        histories.insert(
1132            "bd-1".to_string(),
1133            make_history("bd-1", "open", &["src/hot.rs", "src/cold.rs"]),
1134        );
1135        histories.insert(
1136            "bd-2".to_string(),
1137            make_history("bd-2", "open", &["src/hot.rs"]),
1138        );
1139        histories.insert(
1140            "bd-3".to_string(),
1141            make_history("bd-3", "closed", &["src/hot.rs"]),
1142        );
1143
1144        let hotspots = compute_hotspots(&histories, 10);
1145
1146        assert!(!hotspots.is_empty());
1147        assert_eq!(hotspots[0].file_path, "src/hot.rs");
1148        assert_eq!(hotspots[0].total_beads, 3);
1149        assert_eq!(hotspots[0].open_beads, 2);
1150        assert_eq!(hotspots[0].closed_beads, 1);
1151    }
1152
1153    #[test]
1154    fn hotspots_limit() {
1155        let mut histories = BTreeMap::new();
1156        for i in 0..10 {
1157            histories.insert(
1158                format!("bd-{i}"),
1159                make_history(&format!("bd-{i}"), "open", &[&format!("file{i}.rs")]),
1160            );
1161        }
1162
1163        let hotspots = compute_hotspots(&histories, 3);
1164        assert!(hotspots.len() <= 3);
1165    }
1166
1167    #[test]
1168    fn file_index_stats() {
1169        let mut histories = BTreeMap::new();
1170        histories.insert(
1171            "bd-1".to_string(),
1172            make_history("bd-1", "open", &["a.rs", "b.rs"]),
1173        );
1174        histories.insert(
1175            "bd-2".to_string(),
1176            make_history("bd-2", "open", &["b.rs", "c.rs"]),
1177        );
1178
1179        let stats = compute_file_index_stats(&histories);
1180        assert_eq!(stats.total_files, 3); // a.rs, b.rs, c.rs
1181        assert_eq!(stats.total_bead_links, 4); // bd-1:a, bd-1:b, bd-2:b, bd-2:c
1182        assert_eq!(stats.files_with_multiple_beads, 1); // b.rs
1183    }
1184
1185    #[test]
1186    fn normalize_path_consistency() {
1187        assert_eq!(normalize_path("src\\main.rs"), "src/main.rs");
1188        assert_eq!(normalize_path("./src/main.rs"), "src/main.rs");
1189        assert_eq!(normalize_path("src/dir/"), "src/dir");
1190        assert_eq!(normalize_path("src/main.rs"), "src/main.rs");
1191        assert_eq!(normalize_path("  ./src/main.rs  "), "src/main.rs");
1192    }
1193
1194    #[test]
1195    fn empty_histories_produce_empty_results() {
1196        let histories = BTreeMap::new();
1197        let hotspots = compute_hotspots(&histories, 10);
1198        assert!(hotspots.is_empty());
1199
1200        let result = lookup_file_beads("any.rs", &histories, 20);
1201        assert_eq!(result.total_beads, 0);
1202
1203        let stats = compute_file_index_stats(&histories);
1204        assert_eq!(stats.total_files, 0);
1205    }
1206
1207    #[test]
1208    fn impact_analysis_basic() {
1209        let mut histories = BTreeMap::new();
1210        histories.insert(
1211            "bd-1".to_string(),
1212            make_history("bd-1", "in_progress", &["src/main.rs"]),
1213        );
1214        histories.insert(
1215            "bd-2".to_string(),
1216            make_history("bd-2", "closed", &["src/main.rs", "src/lib.rs"]),
1217        );
1218
1219        let result = analyze_impact(&["src/main.rs".to_string()], &histories);
1220
1221        assert_eq!(result.affected_beads.len(), 2);
1222        assert!(!result.risk_level.is_empty());
1223        assert!(result.risk_score > 0.0);
1224        // in_progress bead should have higher relevance
1225        assert!(result.affected_beads[0].bead_id == "bd-1");
1226    }
1227
1228    #[test]
1229    fn impact_analysis_no_overlap() {
1230        let mut histories = BTreeMap::new();
1231        histories.insert(
1232            "bd-1".to_string(),
1233            make_history("bd-1", "open", &["other.rs"]),
1234        );
1235
1236        let result = analyze_impact(&["unrelated.rs".to_string()], &histories);
1237
1238        assert!(result.affected_beads.is_empty());
1239        assert_eq!(result.risk_level, "low");
1240    }
1241
1242    #[test]
1243    fn impact_analysis_ignores_empty_file_entries() {
1244        let mut histories = BTreeMap::new();
1245        histories.insert(
1246            "bd-1".to_string(),
1247            make_history("bd-1", "open", &["src/main.rs"]),
1248        );
1249
1250        let result = analyze_impact(
1251            &["src/main.rs".to_string(), " ".to_string(), "".to_string()],
1252            &histories,
1253        );
1254
1255        assert_eq!(result.files, vec!["src/main.rs".to_string()]);
1256        assert!(result.summary.starts_with("1 file(s) affect"));
1257        assert!(
1258            result.risk_score < 0.3,
1259            "empty entries should not trigger multi-file risk inflation"
1260        );
1261    }
1262
1263    #[test]
1264    fn impact_analysis_dedupes_repeated_normalized_paths() {
1265        let mut histories = BTreeMap::new();
1266        histories.insert(
1267            "bd-1".to_string(),
1268            make_history("bd-1", "open", &["src/main.rs"]),
1269        );
1270
1271        let result = analyze_impact(
1272            &[
1273                "src/main.rs".to_string(),
1274                "./src/main.rs".to_string(),
1275                " src\\main.rs ".to_string(),
1276            ],
1277            &histories,
1278        );
1279
1280        assert_eq!(result.files, vec!["src/main.rs".to_string()]);
1281        assert!(result.summary.starts_with("1 file(s) affect"));
1282    }
1283
1284    fn make_history_multi_file(
1285        bead_id: &str,
1286        status: &str,
1287        file_sets: &[&[&str]],
1288    ) -> HistoryBeadCompat {
1289        let commits = file_sets
1290            .iter()
1291            .enumerate()
1292            .map(|(i, files)| HistoryCommitCompat {
1293                sha: format!("commit-{bead_id}-{i}"),
1294                short_sha: format!("c{i}"),
1295                message: format!("work on {bead_id}"),
1296                author: "TestUser".to_string(),
1297                author_email: "test@example.com".to_string(),
1298                timestamp: format!("2026-01-{:02}T10:00:00Z", i + 1),
1299                files: files
1300                    .iter()
1301                    .map(|p| HistoryFileChangeCompat {
1302                        path: p.to_string(),
1303                        action: "M".to_string(),
1304                        insertions: 5,
1305                        deletions: 1,
1306                    })
1307                    .collect(),
1308                method: "explicit_id".to_string(),
1309                confidence: 0.85,
1310                reason: "test".to_string(),
1311                field_changes: vec![],
1312                bead_diff_lines: vec![],
1313            })
1314            .collect();
1315
1316        HistoryBeadCompat {
1317            bead_id: bead_id.to_string(),
1318            title: format!("Bead {bead_id}"),
1319            status: status.to_string(),
1320            events: vec![],
1321            milestones: HistoryMilestonesCompat::default(),
1322            commits: Some(commits),
1323            cycle_time: None,
1324            last_author: "TestUser".to_string(),
1325        }
1326    }
1327
1328    #[test]
1329    fn file_relations_basic() {
1330        let mut histories = BTreeMap::new();
1331        // Two commits: one touches A+B, another touches A+C
1332        histories.insert(
1333            "bd-1".to_string(),
1334            make_history_multi_file("bd-1", "open", &[&["a.rs", "b.rs"], &["a.rs", "c.rs"]]),
1335        );
1336
1337        let result = compute_file_relations("a.rs", &histories, 0.0, 10);
1338
1339        assert_eq!(result.source_file, "a.rs");
1340        assert_eq!(result.total_commits_for_source, 2);
1341        assert!(result.related_files.len() >= 2); // b.rs and c.rs
1342    }
1343
1344    #[test]
1345    fn file_relations_threshold() {
1346        let mut histories = BTreeMap::new();
1347        histories.insert(
1348            "bd-1".to_string(),
1349            make_history_multi_file(
1350                "bd-1",
1351                "open",
1352                &[&["a.rs", "b.rs"], &["a.rs", "c.rs"], &["a.rs", "b.rs"]],
1353            ),
1354        );
1355
1356        // b.rs appears 2/3 times (0.67), c.rs 1/3 (0.33)
1357        let result = compute_file_relations("a.rs", &histories, 0.5, 10);
1358        assert!(
1359            result.related_files.iter().all(|r| r.correlation >= 0.5),
1360            "All results should meet threshold"
1361        );
1362    }
1363
1364    #[test]
1365    fn file_relations_deduplicate_repeated_paths_within_one_commit() {
1366        let mut histories = BTreeMap::new();
1367        histories.insert(
1368            "bd-1".to_string(),
1369            make_history_multi_file(
1370                "bd-1",
1371                "open",
1372                &[&["src/main.rs", "src/lib.rs", "src/lib.rs"]],
1373            ),
1374        );
1375
1376        let result = compute_file_relations("src/main.rs", &histories, 0.0, 10);
1377
1378        assert_eq!(result.total_commits_for_source, 1);
1379        assert_eq!(result.related_files.len(), 1);
1380        assert_eq!(result.related_files[0].file_path, "src/lib.rs");
1381        assert_eq!(result.related_files[0].co_change_count, 1);
1382        assert_eq!(result.related_files[0].correlation, 1.0);
1383    }
1384
1385    #[test]
1386    fn related_work_basic() {
1387        let mut histories = BTreeMap::new();
1388        histories.insert(
1389            "bd-1".to_string(),
1390            make_history("bd-1", "open", &["shared.rs", "only-1.rs"]),
1391        );
1392        histories.insert(
1393            "bd-2".to_string(),
1394            make_history("bd-2", "open", &["shared.rs", "only-2.rs"]),
1395        );
1396        histories.insert(
1397            "bd-3".to_string(),
1398            make_history("bd-3", "open", &["unrelated.rs"]),
1399        );
1400
1401        let result = find_related_work("bd-1", &histories, 0, 10);
1402
1403        assert_eq!(result.source_bead, "bd-1");
1404        assert_eq!(result.related.len(), 1); // Only bd-2 shares files
1405        assert_eq!(result.related[0].bead_id, "bd-2");
1406        assert_eq!(result.related[0].shared_file_count, 1);
1407    }
1408
1409    #[test]
1410    fn related_work_missing_bead() {
1411        let histories = BTreeMap::new();
1412        let result = find_related_work("nonexistent", &histories, 0, 10);
1413        assert!(result.related.is_empty());
1414    }
1415
1416    #[test]
1417    fn related_work_excludes_closed_when_flag_unset() {
1418        let mut histories = BTreeMap::new();
1419        histories.insert(
1420            "bd-1".to_string(),
1421            make_history("bd-1", "open", &["shared.rs"]),
1422        );
1423        histories.insert(
1424            "bd-2".to_string(),
1425            make_history("bd-2", "closed", &["shared.rs"]),
1426        );
1427        histories.insert(
1428            "bd-3".to_string(),
1429            make_history("bd-3", "open", &["shared.rs"]),
1430        );
1431
1432        // include_closed=false should exclude bd-2
1433        let result = find_related_work_with_options("bd-1", &histories, 0, 10, false);
1434        assert_eq!(result.related.len(), 1);
1435        assert_eq!(result.related[0].bead_id, "bd-3");
1436
1437        // include_closed=true should include bd-2
1438        let result = find_related_work_with_options("bd-1", &histories, 0, 10, true);
1439        assert_eq!(result.related.len(), 2);
1440    }
1441
1442    #[test]
1443    fn related_work_treats_min_relevance_as_percent_threshold() {
1444        let mut histories = BTreeMap::new();
1445        histories.insert(
1446            "bd-1".to_string(),
1447            make_history("bd-1", "open", &["a.rs", "b.rs", "c.rs", "d.rs"]),
1448        );
1449        histories.insert("bd-2".to_string(), make_history("bd-2", "open", &["a.rs"]));
1450        histories.insert(
1451            "bd-3".to_string(),
1452            make_history("bd-3", "open", &["a.rs", "b.rs"]),
1453        );
1454
1455        let result = find_related_work_with_options("bd-1", &histories, 20, 10, true);
1456        assert_eq!(result.related.len(), 2, "20% should keep both overlaps");
1457
1458        let result = find_related_work_with_options("bd-1", &histories, 30, 10, true);
1459        assert_eq!(
1460            result.related.len(),
1461            1,
1462            "30% should keep only the 50% overlap"
1463        );
1464        assert_eq!(result.related[0].bead_id, "bd-3");
1465    }
1466
1467    #[test]
1468    fn related_work_caps_min_relevance_above_one_hundred_percent() {
1469        let mut histories = BTreeMap::new();
1470        histories.insert(
1471            "bd-1".to_string(),
1472            make_history("bd-1", "open", &["shared.rs"]),
1473        );
1474        histories.insert(
1475            "bd-2".to_string(),
1476            make_history("bd-2", "open", &["shared.rs"]),
1477        );
1478
1479        let result = find_related_work_with_options("bd-1", &histories, 150, 10, true);
1480        assert_eq!(result.related.len(), 1);
1481        assert_eq!(result.related[0].bead_id, "bd-2");
1482    }
1483}