1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4
5use super::git_history::HistoryBeadCompat;
6
7#[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#[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 let file_bead_map = build_file_bead_map(histories);
70
71 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; }
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 check_message_patterns(&commit.message, &mut signals);
98
99 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 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 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 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 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 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 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 for (pos, _) in text.match_indices('-') {
296 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 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#[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#[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 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#[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#[must_use]
477pub fn compute_hotspots(
478 histories: &BTreeMap<String, HistoryBeadCompat>,
479 limit: usize,
480) -> Vec<FileHotspot> {
481 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#[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#[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#[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 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 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#[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#[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 let mut target_commits = BTreeMap::<String, Vec<String>>::new(); 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 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#[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#[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#[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 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 let mut related = Vec::new();
855 for (other_id, other_history) in histories {
856 if other_id == bead_id {
857 continue;
858 }
859
860 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#[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#[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); assert_eq!(stats.total_bead_links, 4); assert_eq!(stats.files_with_multiple_beads, 1); }
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 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 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); }
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 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); 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 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 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}