Skip to main content

bvr/analysis/
suggest.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use chrono::Utc;
4use serde::Serialize;
5use serde_json::json;
6
7use crate::analysis::graph::GraphMetrics;
8use crate::model::Issue;
9
10const DEFAULT_MAX_SUGGESTIONS: usize = 50;
11const DUPLICATE_JACCARD_THRESHOLD: f64 = 0.7;
12const DUPLICATE_MIN_KEYWORDS: usize = 2;
13const DUPLICATE_MAX_SUGGESTIONS: usize = 20;
14const DEPENDENCY_MIN_KEYWORD_OVERLAP: usize = 2;
15const DEPENDENCY_MIN_CONFIDENCE: f64 = 0.5;
16const DEPENDENCY_MAX_SUGGESTIONS: usize = 20;
17const LABEL_MIN_CONFIDENCE: f64 = 0.5;
18const LABEL_MAX_PER_ISSUE: usize = 3;
19const LABEL_MAX_TOTAL: usize = 30;
20const CYCLE_MAX: usize = 10;
21const HIGH_CONFIDENCE_THRESHOLD: f64 = 0.7;
22const LOW_CONFIDENCE_THRESHOLD: f64 = 0.4;
23const STALE_DAYS_THRESHOLD: i64 = 90;
24const STALE_PAGERANK_PERCENTILE: f64 = 0.25;
25const STALE_MAX_SUGGESTIONS: usize = 20;
26
27const STOP_WORDS: &[&str] = &[
28    "the", "and", "for", "with", "this", "that", "from", "are", "was", "were", "been", "have",
29    "has", "had", "does", "did", "will", "would", "could", "should", "may", "might", "can", "not",
30    "all", "any", "some", "each", "when", "where", "what", "which", "how", "why", "who", "its",
31    "also", "just", "only", "more", "than", "then", "now", "here", "there", "these", "those",
32    "such", "into", "over", "after", "before", "being", "other", "about", "like", "very", "most",
33    "make", "use",
34];
35
36const BUILTIN_LABEL_MAPPINGS: &[(&str, &[&str])] = &[
37    ("database", &["database", "db"]),
38    ("migration", &["database", "migration"]),
39    ("api", &["api"]),
40    ("endpoint", &["api"]),
41    ("rest", &["api"]),
42    ("graphql", &["api", "graphql"]),
43    ("auth", &["auth", "security"]),
44    ("login", &["auth"]),
45    ("password", &["auth", "security"]),
46    ("security", &["security"]),
47    ("test", &["testing"]),
48    ("tests", &["testing"]),
49    ("unittest", &["testing"]),
50    ("integration", &["testing", "integration"]),
51    ("ui", &["ui", "frontend"]),
52    ("frontend", &["frontend"]),
53    ("backend", &["backend"]),
54    ("server", &["backend"]),
55    ("cli", &["cli"]),
56    ("command", &["cli"]),
57    ("config", &["config"]),
58    ("settings", &["config"]),
59    ("performance", &["performance"]),
60    ("slow", &["performance"]),
61    ("fast", &["performance"]),
62    ("memory", &["performance"]),
63    ("cache", &["performance", "cache"]),
64    ("docs", &["documentation"]),
65    ("readme", &["documentation"]),
66    ("refactor", &["refactoring"]),
67    ("cleanup", &["refactoring", "maintenance"]),
68    ("dependency", &["dependencies"]),
69    ("deps", &["dependencies"]),
70    ("bug", &["bug"]),
71    ("fix", &["bug"]),
72    ("broken", &["bug"]),
73    ("crash", &["bug"]),
74    ("error", &["bug"]),
75    ("feature", &["feature"]),
76    ("enhance", &["enhancement"]),
77    ("improve", &["enhancement"]),
78    ("urgent", &["urgent", "priority"]),
79    ("hotfix", &["urgent", "bug"]),
80];
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum SuggestionType {
85    MissingDependency,
86    PotentialDuplicate,
87    LabelSuggestion,
88    CycleWarning,
89    StaleCleanup,
90}
91
92impl SuggestionType {
93    #[must_use]
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::MissingDependency => "missing_dependency",
97            Self::PotentialDuplicate => "potential_duplicate",
98            Self::LabelSuggestion => "label_suggestion",
99            Self::CycleWarning => "cycle_warning",
100            Self::StaleCleanup => "stale_cleanup",
101        }
102    }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub enum SuggestionConfidenceLevel {
107    Low,
108    Medium,
109    High,
110}
111
112impl SuggestionConfidenceLevel {
113    #[must_use]
114    pub const fn as_str(self) -> &'static str {
115        match self {
116            Self::Low => "low",
117            Self::Medium => "medium",
118            Self::High => "high",
119        }
120    }
121}
122
123#[derive(Debug, Clone)]
124pub struct SuggestOptions {
125    pub min_confidence: f64,
126    pub max_suggestions: usize,
127    pub filter_type: Option<SuggestionType>,
128    pub filter_bead: Option<String>,
129}
130
131impl Default for SuggestOptions {
132    fn default() -> Self {
133        Self {
134            min_confidence: 0.0,
135            max_suggestions: DEFAULT_MAX_SUGGESTIONS,
136            filter_type: None,
137            filter_bead: None,
138        }
139    }
140}
141
142#[derive(Debug, Clone, Serialize)]
143pub struct Suggestion {
144    #[serde(rename = "type")]
145    pub suggestion_type: SuggestionType,
146    pub target_bead: String,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub related_bead: Option<String>,
149    pub summary: String,
150    pub reason: String,
151    pub confidence: f64,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub action_command: Option<String>,
154    pub generated_at: String,
155    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
156    pub metadata: BTreeMap<String, serde_json::Value>,
157}
158
159#[derive(Debug, Clone, Serialize)]
160pub struct SuggestionSet {
161    pub suggestions: Vec<Suggestion>,
162    pub generated_at: String,
163    pub data_hash: String,
164    pub stats: SuggestionStats,
165}
166
167#[derive(Debug, Clone, Serialize)]
168pub struct SuggestionStats {
169    pub total: usize,
170    pub by_type: BTreeMap<String, usize>,
171    pub by_confidence: BTreeMap<String, usize>,
172    pub high_confidence_count: usize,
173    pub actionable_count: usize,
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct SuggestFilter {
178    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
179    pub filter_type: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub min_confidence: Option<f64>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub bead_id: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize)]
187pub struct RobotSuggestOutput {
188    #[serde(flatten)]
189    pub envelope: crate::robot::RobotEnvelope,
190    pub filters: SuggestFilter,
191    pub suggestions: SuggestionSet,
192    pub usage_hints: Vec<String>,
193}
194
195#[must_use]
196pub fn generate_robot_suggest_output(
197    issues: &[Issue],
198    metrics: &GraphMetrics,
199    options: &SuggestOptions,
200) -> RobotSuggestOutput {
201    let env = crate::robot::envelope(issues);
202    let mut suggestions = detect_potential_duplicates(issues, &env.generated_at);
203    suggestions.extend(detect_missing_dependencies(issues, &env.generated_at));
204    suggestions.extend(detect_label_suggestions(issues, &env.generated_at));
205    suggestions.extend(detect_cycle_warnings(metrics, &env.generated_at));
206    suggestions.extend(detect_stale_cleanup(issues, metrics, &env.generated_at));
207    suggestions.retain(|suggestion| matches_filters(suggestion, options));
208    sort_suggestions(&mut suggestions);
209    if options.max_suggestions > 0 && suggestions.len() > options.max_suggestions {
210        suggestions.truncate(options.max_suggestions);
211    }
212
213    let suggestion_set = SuggestionSet {
214        stats: compute_stats(&suggestions),
215        suggestions,
216        generated_at: env.generated_at.clone(),
217        data_hash: env.data_hash.clone(),
218    };
219
220    let active_bead_filter = options
221        .filter_bead
222        .as_ref()
223        .map(|value| value.trim().to_string())
224        .filter(|value| !value.is_empty());
225
226    let filters = SuggestFilter {
227        filter_type: options.filter_type.map(|value| value.as_str().to_string()),
228        min_confidence: if options.min_confidence > 0.0 {
229            Some(options.min_confidence)
230        } else {
231            None
232        },
233        bead_id: active_bead_filter,
234    };
235
236    RobotSuggestOutput {
237        envelope: env,
238        filters,
239        suggestions: suggestion_set,
240        usage_hints: vec![
241            "jq '.suggestions.suggestions[:5]' - Top 5 suggestions by confidence".to_string(),
242            "jq '.suggestions.suggestions[] | select(.type==\"potential_duplicate\")' - Filter duplicates".to_string(),
243            "jq '.suggestions.suggestions[] | select(.confidence >= 0.8)' - High-confidence only".to_string(),
244            "jq '.suggestions.stats.by_type' - Count by suggestion type".to_string(),
245            "jq '.suggestions.suggestions[].action_command' - All action commands".to_string(),
246            "--suggest-type=dependency - Filter to dependency suggestions".to_string(),
247            "--suggest-confidence=0.7 - Minimum confidence threshold".to_string(),
248            "--suggest-bead=<id> - Suggestions for specific bead".to_string(),
249        ],
250    }
251}
252
253fn detect_potential_duplicates(issues: &[Issue], generated_at: &str) -> Vec<Suggestion> {
254    if issues.len() < 2 {
255        return Vec::new();
256    }
257
258    let keyword_sets = issues
259        .iter()
260        .map(|issue| extract_keywords(&issue.title, &issue.description))
261        .collect::<Vec<_>>();
262
263    let mut suggestions = Vec::<Suggestion>::new();
264    for left_index in 0..issues.len() {
265        if keyword_sets[left_index].len() < DUPLICATE_MIN_KEYWORDS {
266            continue;
267        }
268
269        for right_index in (left_index + 1)..issues.len() {
270            if keyword_sets[right_index].len() < DUPLICATE_MIN_KEYWORDS {
271                continue;
272            }
273
274            let left_issue = &issues[left_index];
275            let right_issue = &issues[right_index];
276            if left_issue.normalized_status() == "tombstone"
277                || right_issue.normalized_status() == "tombstone"
278            {
279                continue;
280            }
281
282            if left_issue.is_closed_like() != right_issue.is_closed_like() {
283                continue;
284            }
285
286            let common = intersect_keywords(&keyword_sets[left_index], &keyword_sets[right_index]);
287            let union_count = keyword_sets[left_index]
288                .len()
289                .saturating_add(keyword_sets[right_index].len())
290                .saturating_sub(common.len());
291            if union_count == 0 {
292                continue;
293            }
294
295            let similarity = ratio(common.len(), union_count);
296            if similarity < DUPLICATE_JACCARD_THRESHOLD {
297                continue;
298            }
299
300            let mut suggestion = base_suggestion(
301                generated_at,
302                SuggestionType::PotentialDuplicate,
303                left_issue.id.clone(),
304                format!("Potential duplicate of {}", right_issue.id),
305                format!(
306                    "{:.0}% keyword similarity; common: {}",
307                    similarity * 100.0,
308                    common
309                        .iter()
310                        .take(5)
311                        .cloned()
312                        .collect::<Vec<_>>()
313                        .join(", ")
314                ),
315                similarity,
316            );
317            suggestion.related_bead = Some(right_issue.id.clone());
318            if left_issue.is_open_like() && right_issue.is_open_like() {
319                suggestion.action_command = Some(format!(
320                    "br dep add {} {} --type=related",
321                    left_issue.id, right_issue.id
322                ));
323            }
324            suggestion
325                .metadata
326                .insert("method".to_string(), json!("jaccard"));
327            suggestion
328                .metadata
329                .insert("common_keywords".to_string(), json!(common));
330            suggestions.push(suggestion);
331        }
332    }
333
334    sort_suggestions(&mut suggestions);
335    if suggestions.len() > DUPLICATE_MAX_SUGGESTIONS {
336        suggestions.truncate(DUPLICATE_MAX_SUGGESTIONS);
337    }
338    suggestions
339}
340
341fn detect_missing_dependencies(issues: &[Issue], generated_at: &str) -> Vec<Suggestion> {
342    if issues.len() < 2 {
343        return Vec::new();
344    }
345
346    let keyword_sets = issues
347        .iter()
348        .map(|issue| extract_keywords(&issue.title, &issue.description))
349        .collect::<Vec<_>>();
350
351    let label_sets = issues
352        .iter()
353        .map(|issue| {
354            issue
355                .labels
356                .iter()
357                .map(|value| value.to_ascii_lowercase())
358                .collect::<BTreeSet<_>>()
359        })
360        .collect::<Vec<_>>();
361
362    let mut suggestions = Vec::<Suggestion>::new();
363    for left_index in 0..issues.len() {
364        for right_index in (left_index + 1)..issues.len() {
365            let left_issue = &issues[left_index];
366            let right_issue = &issues[right_index];
367            if left_issue.is_closed_like() || right_issue.is_closed_like() {
368                continue;
369            }
370            if has_dependency_between(left_issue, right_issue) {
371                continue;
372            }
373
374            let shared_keywords =
375                intersect_keywords(&keyword_sets[left_index], &keyword_sets[right_index]);
376            if shared_keywords.len() < DEPENDENCY_MIN_KEYWORD_OVERLAP {
377                continue;
378            }
379
380            let shared_labels = label_sets[left_index]
381                .intersection(&label_sets[right_index])
382                .cloned()
383                .collect::<Vec<_>>();
384
385            let mut confidence = (usize_to_f64(shared_keywords.len()) * 0.1).min(0.5);
386            if mentions_issue_id(left_issue, right_issue)
387                || mentions_issue_id(right_issue, left_issue)
388            {
389                confidence += 0.3;
390            }
391            if title_keyword_overlap(right_issue, &keyword_sets[left_index]) {
392                confidence += 0.15;
393            }
394            confidence += usize_to_f64(shared_labels.len()) * 0.1;
395            confidence = confidence.min(0.95);
396            if confidence < DEPENDENCY_MIN_CONFIDENCE {
397                continue;
398            }
399
400            let (from_issue, to_issue) = dependency_direction(left_issue, right_issue);
401            let mut suggestion = base_suggestion(
402                generated_at,
403                SuggestionType::MissingDependency,
404                from_issue.id.clone(),
405                format!("May depend on {}", to_issue.id),
406                format!(
407                    "{} shared keywords{}",
408                    shared_keywords.len(),
409                    if shared_labels.is_empty() {
410                        String::new()
411                    } else {
412                        format!(", {} shared labels", shared_labels.len())
413                    }
414                ),
415                confidence,
416            );
417            suggestion.related_bead = Some(to_issue.id.clone());
418            suggestion.action_command =
419                Some(format!("br dep add {} {}", from_issue.id, to_issue.id));
420            suggestion
421                .metadata
422                .insert("shared_keywords".to_string(), json!(shared_keywords));
423            if !shared_labels.is_empty() {
424                suggestion
425                    .metadata
426                    .insert("shared_labels".to_string(), json!(shared_labels));
427            }
428            suggestions.push(suggestion);
429        }
430    }
431
432    sort_suggestions(&mut suggestions);
433    if suggestions.len() > DEPENDENCY_MAX_SUGGESTIONS {
434        suggestions.truncate(DEPENDENCY_MAX_SUGGESTIONS);
435    }
436    suggestions
437}
438
439fn detect_label_suggestions(issues: &[Issue], generated_at: &str) -> Vec<Suggestion> {
440    if issues.is_empty() {
441        return Vec::new();
442    }
443
444    let canonical_labels = canonical_project_labels(issues);
445    let all_labels = issues
446        .iter()
447        .flat_map(|issue| issue.labels.iter().map(|label| label.to_ascii_lowercase()))
448        .collect::<BTreeSet<_>>();
449    if all_labels.is_empty() {
450        return Vec::new();
451    }
452
453    let learned_mappings = learn_label_mappings(issues);
454    let mut matches = Vec::<Suggestion>::new();
455
456    for issue in issues {
457        if issue.is_closed_like() {
458            continue;
459        }
460
461        let existing_labels = issue
462            .labels
463            .iter()
464            .map(|label| label.to_ascii_lowercase())
465            .collect::<BTreeSet<_>>();
466        let keywords = extract_keywords(&issue.title, &issue.description);
467
468        let mut label_scores = BTreeMap::<String, f64>::new();
469        let mut label_reasons = BTreeMap::<String, BTreeSet<String>>::new();
470
471        for keyword in &keywords {
472            for &(mapping_keyword, labels) in BUILTIN_LABEL_MAPPINGS {
473                if mapping_keyword != keyword {
474                    continue;
475                }
476
477                for &label in labels {
478                    let candidate = label.to_string();
479                    if existing_labels.contains(&candidate) || !all_labels.contains(&candidate) {
480                        continue;
481                    }
482                    *label_scores.entry(candidate.clone()).or_insert(0.0) += 0.3;
483                    label_reasons
484                        .entry(candidate)
485                        .or_default()
486                        .insert(keyword.clone());
487                }
488            }
489
490            if let Some(label_counts) = learned_mappings.get(keyword) {
491                for (label, count) in label_counts {
492                    if existing_labels.contains(label) || !all_labels.contains(label) {
493                        continue;
494                    }
495                    let learned_bonus = (0.1 + (usize_to_f64(*count) * 0.05)).min(0.4);
496                    *label_scores.entry(label.clone()).or_insert(0.0) += learned_bonus;
497                    label_reasons
498                        .entry(label.clone())
499                        .or_default()
500                        .insert(keyword.clone());
501                }
502            }
503        }
504
505        let mut candidates = label_scores.into_iter().collect::<Vec<(String, f64)>>();
506        candidates.sort_by(|left, right| {
507            right
508                .1
509                .total_cmp(&left.1)
510                .then_with(|| left.0.cmp(&right.0))
511        });
512
513        for (index, (label, score)) in candidates.into_iter().enumerate() {
514            if index >= LABEL_MAX_PER_ISSUE || score < LABEL_MIN_CONFIDENCE {
515                continue;
516            }
517
518            let display_label = canonical_labels
519                .get(&label)
520                .cloned()
521                .unwrap_or(label.clone());
522
523            let matched_keywords = label_reasons
524                .get(&label)
525                .map(|values| values.iter().cloned().collect::<Vec<_>>())
526                .unwrap_or_default();
527            let reason = format!("keywords: {}", matched_keywords.join(", "));
528
529            let mut suggestion = base_suggestion(
530                generated_at,
531                SuggestionType::LabelSuggestion,
532                issue.id.clone(),
533                format!("Consider adding label '{display_label}'"),
534                reason,
535                score.min(0.95),
536            );
537            suggestion.action_command = Some(format!(
538                "br update {} --add-label={display_label}",
539                issue.id
540            ));
541            suggestion
542                .metadata
543                .insert("suggested_label".to_string(), json!(display_label));
544            suggestion
545                .metadata
546                .insert("matched_keywords".to_string(), json!(matched_keywords));
547            matches.push(suggestion);
548        }
549    }
550
551    sort_suggestions(&mut matches);
552    if matches.len() > LABEL_MAX_TOTAL {
553        matches.truncate(LABEL_MAX_TOTAL);
554    }
555    matches
556}
557
558fn detect_cycle_warnings(metrics: &GraphMetrics, generated_at: &str) -> Vec<Suggestion> {
559    let mut suggestions = Vec::<Suggestion>::new();
560
561    for cycle in metrics.cycles.iter().take(CYCLE_MAX) {
562        if cycle.is_empty() {
563            continue;
564        }
565
566        let cycle_length = cycle.len();
567        let distance_from_shortest = cycle_length.saturating_sub(2);
568        let confidence = (1.0 - (usize_to_f64(distance_from_shortest) * 0.1)).clamp(0.5, 1.0);
569
570        let summary = if cycle_length == 1 {
571            format!("Self-loop: {} depends on itself", cycle[0])
572        } else if cycle_length == 2 {
573            format!("Direct cycle between {} and {}", cycle[0], cycle[1])
574        } else {
575            format!("Dependency cycle of {cycle_length} issues")
576        };
577
578        let mut cycle_path = cycle.clone();
579        if cycle_length > 1 {
580            cycle_path.push(cycle[0].clone());
581        }
582
583        let mut suggestion = base_suggestion(
584            generated_at,
585            SuggestionType::CycleWarning,
586            cycle[0].clone(),
587            summary,
588            format!("Cycle path: {}", cycle_path.join(" -> ")),
589            confidence,
590        );
591        if cycle_length >= 2 {
592            let last = cycle[cycle_length - 1].clone();
593            let first = cycle[0].clone();
594            suggestion.action_command = Some(format!("br dep remove {last} {first}"));
595            suggestion.related_bead = Some(cycle[1].clone());
596        }
597        suggestion
598            .metadata
599            .insert("cycle_length".to_string(), json!(cycle_length));
600        suggestion
601            .metadata
602            .insert("cycle_path".to_string(), json!(cycle));
603        suggestions.push(suggestion);
604    }
605
606    suggestions
607}
608
609fn detect_stale_cleanup(
610    issues: &[Issue],
611    metrics: &GraphMetrics,
612    generated_at: &str,
613) -> Vec<Suggestion> {
614    let now = Utc::now();
615
616    // Compute the PageRank threshold at the given percentile of open issues.
617    let mut open_pageranks: Vec<f64> = issues
618        .iter()
619        .filter(|i| i.is_open_like())
620        .map(|i| metrics.pagerank.get(&i.id).copied().unwrap_or(0.0))
621        .collect();
622    open_pageranks.sort_by(f64::total_cmp);
623    let pagerank_threshold = if open_pageranks.is_empty() {
624        0.0
625    } else {
626        let idx = ((open_pageranks.len() - 1) as f64 * STALE_PAGERANK_PERCENTILE).floor() as usize;
627        open_pageranks[idx]
628    };
629
630    let mut suggestions = Vec::new();
631
632    for issue in issues.iter().filter(|i| i.is_open_like()) {
633        let updated = issue.updated_at;
634        let days_stale = match updated {
635            Some(ts) => (now - ts).num_days(),
636            None => {
637                // Fall back to created_at; if neither exists, skip.
638                match issue.created_at {
639                    Some(ts) => (now - ts).num_days(),
640                    None => continue,
641                }
642            }
643        };
644
645        if days_stale < STALE_DAYS_THRESHOLD {
646            continue;
647        }
648
649        let pagerank = metrics.pagerank.get(&issue.id).copied().unwrap_or(0.0);
650        if pagerank > pagerank_threshold {
651            continue;
652        }
653
654        // Higher confidence for older, lower-impact issues.
655        let age_factor = ((days_stale as f64 - STALE_DAYS_THRESHOLD as f64) / 180.0).min(1.0);
656        let confidence = (0.4 + 0.3 * age_factor).clamp(0.0, 1.0);
657
658        let mut suggestion = base_suggestion(
659            generated_at,
660            SuggestionType::StaleCleanup,
661            issue.id.clone(),
662            format!(
663                "{} has been stale for {} days with low graph impact",
664                issue.id, days_stale
665            ),
666            format!(
667                "Last updated {} days ago, PageRank {:.4} (below threshold {:.4})",
668                days_stale, pagerank, pagerank_threshold
669            ),
670            confidence,
671        );
672        suggestion.action_command =
673            Some(format!("br close {} --reason \"stale cleanup\"", issue.id));
674        suggestion
675            .metadata
676            .insert("days_stale".to_string(), json!(days_stale));
677        suggestion
678            .metadata
679            .insert("pagerank".to_string(), json!(pagerank));
680        suggestions.push(suggestion);
681    }
682
683    suggestions.sort_by(|a, b| {
684        b.confidence
685            .total_cmp(&a.confidence)
686            .then_with(|| a.target_bead.cmp(&b.target_bead))
687    });
688    suggestions.truncate(STALE_MAX_SUGGESTIONS);
689    suggestions
690}
691
692fn base_suggestion(
693    generated_at: &str,
694    suggestion_type: SuggestionType,
695    target_bead: String,
696    summary: String,
697    reason: String,
698    confidence: f64,
699) -> Suggestion {
700    Suggestion {
701        suggestion_type,
702        target_bead,
703        related_bead: None,
704        summary,
705        reason,
706        confidence,
707        action_command: None,
708        generated_at: generated_at.to_string(),
709        metadata: BTreeMap::new(),
710    }
711}
712
713fn dependency_direction<'a>(left: &'a Issue, right: &'a Issue) -> (&'a Issue, &'a Issue) {
714    let left_created = left.created_at;
715    let right_created = right.created_at;
716
717    let priority_cmp = left.priority.cmp(&right.priority);
718    let time_cmp = match (left_created, right_created) {
719        (Some(l), Some(r)) => l.cmp(&r),
720        (Some(_), None) => std::cmp::Ordering::Less,
721        (None, Some(_)) => std::cmp::Ordering::Greater,
722        (None, None) => std::cmp::Ordering::Equal,
723    };
724
725    let left_is_blocker = match priority_cmp {
726        std::cmp::Ordering::Less => true,
727        std::cmp::Ordering::Greater => false,
728        std::cmp::Ordering::Equal => match time_cmp {
729            std::cmp::Ordering::Less => true,
730            std::cmp::Ordering::Greater => false,
731            std::cmp::Ordering::Equal => left.id < right.id,
732        },
733    };
734
735    if left_is_blocker {
736        (right, left)
737    } else {
738        (left, right)
739    }
740}
741
742fn learn_label_mappings(issues: &[Issue]) -> BTreeMap<String, BTreeMap<String, usize>> {
743    let mut mappings = BTreeMap::<String, BTreeMap<String, usize>>::new();
744
745    for issue in issues {
746        if issue.labels.is_empty() {
747            continue;
748        }
749
750        let keywords = extract_keywords(&issue.title, &issue.description);
751        for keyword in keywords {
752            let label_counts = mappings.entry(keyword).or_default();
753            for label in &issue.labels {
754                let label = label.to_ascii_lowercase();
755                *label_counts.entry(label).or_insert(0) += 1;
756            }
757        }
758    }
759
760    mappings
761}
762
763fn canonical_project_labels(issues: &[Issue]) -> BTreeMap<String, String> {
764    let mut variants = BTreeMap::<String, BTreeMap<String, usize>>::new();
765
766    for issue in issues {
767        for label in &issue.labels {
768            let normalized = label.to_ascii_lowercase();
769            *variants
770                .entry(normalized)
771                .or_default()
772                .entry(label.clone())
773                .or_insert(0) += 1;
774        }
775    }
776
777    variants
778        .into_iter()
779        .filter_map(|(normalized, counts)| {
780            counts
781                .into_iter()
782                .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.0.cmp(&left.0)))
783                .map(|(canonical, _)| (normalized, canonical))
784        })
785        .collect()
786}
787
788fn has_dependency_between(left: &Issue, right: &Issue) -> bool {
789    left.dependencies
790        .iter()
791        .any(|dep| dep.depends_on_id == right.id)
792        || right
793            .dependencies
794            .iter()
795            .any(|dep| dep.depends_on_id == left.id)
796}
797
798fn mentions_issue_id(primary: &Issue, other: &Issue) -> bool {
799    let primary_text = primary.description.to_ascii_lowercase();
800    let other_id = other.id.to_ascii_lowercase();
801    !other_id.is_empty() && primary_text.contains(&other_id)
802}
803
804fn title_keyword_overlap(other: &Issue, primary_keywords: &[String]) -> bool {
805    let other_title = other.title.to_ascii_lowercase();
806    primary_keywords
807        .iter()
808        .any(|keyword| keyword.len() >= 5 && other_title.contains(keyword))
809}
810
811fn matches_filters(suggestion: &Suggestion, options: &SuggestOptions) -> bool {
812    if options.min_confidence > 0.0 && suggestion.confidence < options.min_confidence {
813        return false;
814    }
815
816    if options
817        .filter_type
818        .is_some_and(|filter_type| suggestion.suggestion_type != filter_type)
819    {
820        return false;
821    }
822
823    let bead_filter = options
824        .filter_bead
825        .as_ref()
826        .map(|value| value.trim())
827        .filter(|value| !value.is_empty());
828    if let Some(bead_id) = bead_filter
829        && suggestion.target_bead != bead_id
830        && suggestion.related_bead.as_deref() != Some(bead_id)
831    {
832        return false;
833    }
834
835    true
836}
837
838fn sort_suggestions(suggestions: &mut [Suggestion]) {
839    suggestions.sort_by(|left, right| {
840        right
841            .confidence
842            .total_cmp(&left.confidence)
843            .then_with(|| {
844                left.suggestion_type
845                    .as_str()
846                    .cmp(right.suggestion_type.as_str())
847            })
848            .then_with(|| left.target_bead.cmp(&right.target_bead))
849            .then_with(|| left.related_bead.cmp(&right.related_bead))
850    });
851}
852
853fn compute_stats(suggestions: &[Suggestion]) -> SuggestionStats {
854    let mut by_type = BTreeMap::<String, usize>::new();
855    let mut by_confidence = BTreeMap::<String, usize>::new();
856    let mut high_confidence_count = 0usize;
857    let mut actionable_count = 0usize;
858
859    for suggestion in suggestions {
860        *by_type
861            .entry(suggestion.suggestion_type.as_str().to_string())
862            .or_insert(0) += 1;
863
864        let level = confidence_level(suggestion.confidence);
865        *by_confidence.entry(level.as_str().to_string()).or_insert(0) += 1;
866        if suggestion.confidence >= HIGH_CONFIDENCE_THRESHOLD {
867            high_confidence_count += 1;
868        }
869        if suggestion.action_command.is_some() {
870            actionable_count += 1;
871        }
872    }
873
874    SuggestionStats {
875        total: suggestions.len(),
876        by_type,
877        by_confidence,
878        high_confidence_count,
879        actionable_count,
880    }
881}
882
883fn confidence_level(confidence: f64) -> SuggestionConfidenceLevel {
884    if confidence < LOW_CONFIDENCE_THRESHOLD {
885        return SuggestionConfidenceLevel::Low;
886    }
887    if confidence >= HIGH_CONFIDENCE_THRESHOLD {
888        return SuggestionConfidenceLevel::High;
889    }
890    SuggestionConfidenceLevel::Medium
891}
892
893fn extract_keywords(title: &str, description: &str) -> Vec<String> {
894    let normalized = normalize_text(&format!("{title} {description}"));
895    let mut keywords = BTreeSet::<String>::new();
896
897    for word in normalized.split_whitespace() {
898        if word.len() < 3 || STOP_WORDS.contains(&word) {
899            continue;
900        }
901        keywords.insert(word.to_string());
902    }
903
904    keywords.into_iter().collect()
905}
906
907fn normalize_text(input: &str) -> String {
908    input
909        .chars()
910        .map(|ch| {
911            if ch.is_ascii_alphanumeric() || ch.is_ascii_whitespace() {
912                ch.to_ascii_lowercase()
913            } else {
914                ' '
915            }
916        })
917        .collect()
918}
919
920fn intersect_keywords(left: &[String], right: &[String]) -> Vec<String> {
921    let right_set = right.iter().cloned().collect::<BTreeSet<_>>();
922    left.iter()
923        .filter(|word| right_set.contains(*word))
924        .cloned()
925        .collect::<BTreeSet<_>>()
926        .into_iter()
927        .collect()
928}
929
930fn ratio(numerator: usize, denominator: usize) -> f64 {
931    if denominator == 0 {
932        return 0.0;
933    }
934    usize_to_f64(numerator) / usize_to_f64(denominator)
935}
936
937fn usize_to_f64(value: usize) -> f64 {
938    value as f64
939}
940
941#[cfg(test)]
942mod tests {
943    use super::*;
944
945    fn make_suggestion(
946        suggestion_type: SuggestionType,
947        confidence: f64,
948        target: &str,
949    ) -> Suggestion {
950        Suggestion {
951            suggestion_type,
952            target_bead: target.to_string(),
953            related_bead: None,
954            summary: String::new(),
955            reason: String::new(),
956            confidence,
957            action_command: None,
958            generated_at: String::new(),
959            metadata: BTreeMap::new(),
960        }
961    }
962
963    #[test]
964    fn sort_by_confidence_descending() {
965        let mut suggestions = vec![
966            make_suggestion(SuggestionType::MissingDependency, 0.5, "bd-a"),
967            make_suggestion(SuggestionType::MissingDependency, 0.9, "bd-b"),
968            make_suggestion(SuggestionType::MissingDependency, 0.7, "bd-c"),
969        ];
970        sort_suggestions(&mut suggestions);
971        assert_eq!(suggestions[0].target_bead, "bd-b"); // 0.9
972        assert_eq!(suggestions[1].target_bead, "bd-c"); // 0.7
973        assert_eq!(suggestions[2].target_bead, "bd-a"); // 0.5
974    }
975
976    #[test]
977    fn sort_tiebreak_by_type_alphabetical() {
978        // All same confidence — should sort alphabetically by type string
979        let mut suggestions = vec![
980            make_suggestion(SuggestionType::PotentialDuplicate, 0.8, "bd-a"), // "potential_duplicate"
981            make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-b"),       // "cycle_warning"
982            make_suggestion(SuggestionType::MissingDependency, 0.8, "bd-c"), // "missing_dependency"
983            make_suggestion(SuggestionType::LabelSuggestion, 0.8, "bd-d"),   // "label_suggestion"
984        ];
985        sort_suggestions(&mut suggestions);
986        // Alphabetical: cycle_warning < label_suggestion < missing_dependency < potential_duplicate
987        assert_eq!(suggestions[0].suggestion_type.as_str(), "cycle_warning");
988        assert_eq!(suggestions[1].suggestion_type.as_str(), "label_suggestion");
989        assert_eq!(
990            suggestions[2].suggestion_type.as_str(),
991            "missing_dependency"
992        );
993        assert_eq!(
994            suggestions[3].suggestion_type.as_str(),
995            "potential_duplicate"
996        );
997    }
998
999    #[test]
1000    fn sort_tiebreak_by_target_bead() {
1001        // Same confidence and type — should sort by target_bead
1002        let mut suggestions = vec![
1003            make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-z"),
1004            make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-a"),
1005            make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-m"),
1006        ];
1007        sort_suggestions(&mut suggestions);
1008        assert_eq!(suggestions[0].target_bead, "bd-a");
1009        assert_eq!(suggestions[1].target_bead, "bd-m");
1010        assert_eq!(suggestions[2].target_bead, "bd-z");
1011    }
1012
1013    fn make_issue_with_dates(id: &str, status: &str, updated_days_ago: i64) -> Issue {
1014        let updated = Utc::now() - chrono::Duration::days(updated_days_ago);
1015        Issue {
1016            id: id.to_string(),
1017            title: format!("Issue {id}"),
1018            status: status.to_string(),
1019            issue_type: "task".to_string(),
1020            priority: 3,
1021            updated_at: Some(updated),
1022            created_at: Some(updated),
1023            ..Issue::default()
1024        }
1025    }
1026
1027    fn make_issue_with_dep(id: &str, title: &str, status: &str, depends_on: &str) -> Issue {
1028        let mut issue = make_issue_with_dates(id, status, 30);
1029        issue.title = title.to_string();
1030        issue.dependencies.push(crate::model::Dependency {
1031            issue_id: id.to_string(),
1032            depends_on_id: depends_on.to_string(),
1033            dep_type: "blocks".to_string(),
1034            ..crate::model::Dependency::default()
1035        });
1036        issue
1037    }
1038
1039    #[test]
1040    fn stale_cleanup_detects_old_low_impact_issues() {
1041        use crate::analysis::graph::IssueGraph;
1042
1043        let issues = vec![
1044            make_issue_with_dates("A", "open", 120),
1045            make_issue_with_dates("B", "open", 10),
1046        ];
1047        let graph = IssueGraph::build(&issues);
1048        let metrics = graph.compute_metrics();
1049        let now = Utc::now().to_rfc3339();
1050
1051        let results = detect_stale_cleanup(&issues, &metrics, &now);
1052        // A is stale (120 days > 90), B is fresh
1053        assert!(
1054            results.iter().any(|s| s.target_bead == "A"),
1055            "should detect stale issue A"
1056        );
1057        assert!(
1058            !results.iter().any(|s| s.target_bead == "B"),
1059            "should not flag fresh issue B"
1060        );
1061    }
1062
1063    #[test]
1064    fn stale_cleanup_skips_closed_issues() {
1065        use crate::analysis::graph::IssueGraph;
1066
1067        let issues = vec![make_issue_with_dates("A", "closed", 200)];
1068        let graph = IssueGraph::build(&issues);
1069        let metrics = graph.compute_metrics();
1070        let now = Utc::now().to_rfc3339();
1071
1072        let results = detect_stale_cleanup(&issues, &metrics, &now);
1073        assert!(results.is_empty(), "closed issues should not be flagged");
1074    }
1075
1076    #[test]
1077    fn stale_cleanup_has_action_command() {
1078        use crate::analysis::graph::IssueGraph;
1079
1080        let issues = vec![make_issue_with_dates("X", "open", 100)];
1081        let graph = IssueGraph::build(&issues);
1082        let metrics = graph.compute_metrics();
1083        let now = Utc::now().to_rfc3339();
1084
1085        let results = detect_stale_cleanup(&issues, &metrics, &now);
1086        assert!(!results.is_empty());
1087        assert_eq!(
1088            results[0].action_command.as_deref(),
1089            Some("br close X --reason \"stale cleanup\"")
1090        );
1091        assert_eq!(results[0].suggestion_type, SuggestionType::StaleCleanup);
1092    }
1093
1094    #[test]
1095    fn stale_cleanup_empty_issues() {
1096        use crate::analysis::graph::IssueGraph;
1097
1098        let issues: Vec<Issue> = vec![];
1099        let graph = IssueGraph::build(&issues);
1100        let metrics = graph.compute_metrics();
1101        let now = Utc::now().to_rfc3339();
1102
1103        let results = detect_stale_cleanup(&issues, &metrics, &now);
1104        assert!(results.is_empty());
1105    }
1106
1107    #[test]
1108    fn stale_cleanup_does_not_flag_high_impact_issue_at_small_n() {
1109        use crate::analysis::graph::IssueGraph;
1110
1111        let mut blocker = make_issue_with_dates("A", "open", 120);
1112        let blocked_one = make_issue_with_dep("B", "frontend follow-up", "open", "A");
1113        let blocked_two = make_issue_with_dep("C", "ops follow-up", "open", "A");
1114        blocker.title = "Core blocker".to_string();
1115
1116        let issues = vec![blocker, blocked_one, blocked_two];
1117        let graph = IssueGraph::build(&issues);
1118        let metrics = graph.compute_metrics();
1119        let now = Utc::now().to_rfc3339();
1120
1121        let results = detect_stale_cleanup(&issues, &metrics, &now);
1122        assert!(
1123            !results
1124                .iter()
1125                .any(|suggestion| suggestion.target_bead == "A"),
1126            "the highest-impact stale issue should not be classified as low-impact"
1127        );
1128    }
1129
1130    // ── detect_potential_duplicates ──────────────────────────────────
1131
1132    fn make_issue(id: &str, title: &str, description: &str, status: &str) -> Issue {
1133        Issue {
1134            id: id.to_string(),
1135            title: title.to_string(),
1136            description: description.to_string(),
1137            status: status.to_string(),
1138            issue_type: "task".to_string(),
1139            priority: 2,
1140            ..Issue::default()
1141        }
1142    }
1143
1144    #[test]
1145    fn duplicates_detected_for_high_keyword_overlap() {
1146        // Need high Jaccard similarity (>= 0.7) with at least 2 keywords each.
1147        // Use nearly identical keywords so intersection/union ratio is high.
1148        let issues = vec![
1149            make_issue(
1150                "bd-1",
1151                "database migration schema upgrade rollback",
1152                "database migration schema upgrade rollback procedure",
1153                "open",
1154            ),
1155            make_issue(
1156                "bd-2",
1157                "database migration schema upgrade rollback",
1158                "database migration schema upgrade rollback implementation",
1159                "open",
1160            ),
1161        ];
1162        let now = Utc::now().to_rfc3339();
1163        let results = detect_potential_duplicates(&issues, &now);
1164        assert!(
1165            !results.is_empty(),
1166            "highly similar issues should be flagged as duplicates"
1167        );
1168        assert_eq!(
1169            results[0].suggestion_type,
1170            SuggestionType::PotentialDuplicate
1171        );
1172        assert!(results[0].related_bead.is_some());
1173        assert!(results[0].action_command.is_some());
1174        assert_eq!(
1175            results[0].metadata.get("method").and_then(|v| v.as_str()),
1176            Some("jaccard")
1177        );
1178    }
1179
1180    #[test]
1181    fn duplicates_not_detected_for_dissimilar_issues() {
1182        let issues = vec![
1183            make_issue(
1184                "bd-1",
1185                "Database migration system",
1186                "Handle schema changes",
1187                "open",
1188            ),
1189            make_issue(
1190                "bd-2",
1191                "Frontend styling improvements",
1192                "Update CSS grid layout for responsive design",
1193                "open",
1194            ),
1195        ];
1196        let now = Utc::now().to_rfc3339();
1197        let results = detect_potential_duplicates(&issues, &now);
1198        assert!(
1199            results.is_empty(),
1200            "dissimilar issues should not be flagged"
1201        );
1202    }
1203
1204    #[test]
1205    fn duplicates_skip_tombstone_issues() {
1206        let issues = vec![
1207            make_issue(
1208                "bd-1",
1209                "Database migration system upgrade",
1210                "Schema migration for database upgrades",
1211                "tombstone",
1212            ),
1213            make_issue(
1214                "bd-2",
1215                "Database migration system upgrade needed",
1216                "Schema migration for database upgrade process",
1217                "open",
1218            ),
1219        ];
1220        let now = Utc::now().to_rfc3339();
1221        let results = detect_potential_duplicates(&issues, &now);
1222        assert!(results.is_empty(), "tombstone issues should be skipped");
1223    }
1224
1225    #[test]
1226    fn duplicates_skip_mixed_open_closed_status() {
1227        let issues = vec![
1228            make_issue(
1229                "bd-1",
1230                "Database migration system upgrade",
1231                "Schema migration for database upgrades",
1232                "open",
1233            ),
1234            make_issue(
1235                "bd-2",
1236                "Database migration system upgrade needed",
1237                "Schema migration for database upgrade process",
1238                "closed",
1239            ),
1240        ];
1241        let now = Utc::now().to_rfc3339();
1242        let results = detect_potential_duplicates(&issues, &now);
1243        assert!(
1244            results.is_empty(),
1245            "one open + one closed should not be flagged"
1246        );
1247    }
1248
1249    #[test]
1250    fn duplicates_single_issue_returns_empty() {
1251        let issues = vec![make_issue(
1252            "bd-1",
1253            "Something important",
1254            "Details here",
1255            "open",
1256        )];
1257        let now = Utc::now().to_rfc3339();
1258        let results = detect_potential_duplicates(&issues, &now);
1259        assert!(results.is_empty());
1260    }
1261
1262    #[test]
1263    fn duplicates_empty_issues_returns_empty() {
1264        let results = detect_potential_duplicates(&[], &Utc::now().to_rfc3339());
1265        assert!(results.is_empty());
1266    }
1267
1268    #[test]
1269    fn duplicates_both_closed_still_detected() {
1270        let issues = vec![
1271            make_issue(
1272                "bd-1",
1273                "Database migration system upgrade",
1274                "Schema migration for database upgrades process",
1275                "closed",
1276            ),
1277            make_issue(
1278                "bd-2",
1279                "Database migration system upgrade needed",
1280                "Schema migration for database upgrade process",
1281                "closed",
1282            ),
1283        ];
1284        let now = Utc::now().to_rfc3339();
1285        let results = detect_potential_duplicates(&issues, &now);
1286        // Both closed → same is_closed_like → should be detected
1287        assert!(
1288            !results.is_empty(),
1289            "two closed issues with high overlap should still be flagged"
1290        );
1291    }
1292
1293    // ── detect_missing_dependencies ─────────────────────────────────
1294
1295    #[test]
1296    fn missing_deps_detected_for_keyword_overlap() {
1297        let issues = vec![
1298            make_issue(
1299                "bd-1",
1300                "Implement authentication service",
1301                "Build the authentication backend service layer",
1302                "open",
1303            ),
1304            make_issue(
1305                "bd-2",
1306                "Authentication integration testing",
1307                "Test the authentication service integration bd-1",
1308                "open",
1309            ),
1310        ];
1311        let now = Utc::now().to_rfc3339();
1312        let results = detect_missing_dependencies(&issues, &now);
1313        assert!(
1314            !results.is_empty(),
1315            "issues with shared keywords + ID mention should suggest dependency"
1316        );
1317        assert_eq!(
1318            results[0].suggestion_type,
1319            SuggestionType::MissingDependency
1320        );
1321        assert!(results[0].related_bead.is_some());
1322        assert!(
1323            results[0]
1324                .action_command
1325                .as_deref()
1326                .unwrap()
1327                .starts_with("br dep add")
1328        );
1329    }
1330
1331    #[test]
1332    fn missing_deps_skip_closed_issues() {
1333        let issues = vec![
1334            make_issue(
1335                "bd-1",
1336                "Authentication service implementation",
1337                "Build authentication backend service",
1338                "closed",
1339            ),
1340            make_issue(
1341                "bd-2",
1342                "Authentication integration testing",
1343                "Test authentication service integration",
1344                "open",
1345            ),
1346        ];
1347        let now = Utc::now().to_rfc3339();
1348        let results = detect_missing_dependencies(&issues, &now);
1349        assert!(
1350            results.is_empty(),
1351            "closed issues should not get dep suggestions"
1352        );
1353    }
1354
1355    #[test]
1356    fn missing_deps_skip_existing_dependency() {
1357        use crate::model::Dependency;
1358
1359        let mut issue1 = make_issue(
1360            "bd-1",
1361            "Authentication service implementation",
1362            "Build authentication backend service",
1363            "open",
1364        );
1365        issue1.dependencies.push(Dependency {
1366            depends_on_id: "bd-2".to_string(),
1367            ..Dependency::default()
1368        });
1369        let issue2 = make_issue(
1370            "bd-2",
1371            "Authentication service testing",
1372            "Test authentication backend service",
1373            "open",
1374        );
1375        let now = Utc::now().to_rfc3339();
1376        let results = detect_missing_dependencies(&[issue1, issue2], &now);
1377        assert!(
1378            results.is_empty(),
1379            "issues with existing dep should not be suggested"
1380        );
1381    }
1382
1383    #[test]
1384    fn missing_deps_shared_labels_boost_confidence() {
1385        let mut issue1 = make_issue(
1386            "bd-1",
1387            "Authentication service implementation",
1388            "Build authentication backend service layer",
1389            "open",
1390        );
1391        issue1.labels = vec!["backend".to_string(), "auth".to_string()];
1392
1393        let mut issue2 = make_issue(
1394            "bd-2",
1395            "Authentication endpoint testing",
1396            "Test authentication backend endpoints layer",
1397            "open",
1398        );
1399        issue2.labels = vec!["backend".to_string(), "auth".to_string()];
1400
1401        let now = Utc::now().to_rfc3339();
1402        let results = detect_missing_dependencies(&[issue1, issue2], &now);
1403        if !results.is_empty() {
1404            // Shared labels should appear in metadata
1405            assert!(
1406                results[0].metadata.get("shared_labels").is_some(),
1407                "shared labels should be in metadata"
1408            );
1409        }
1410    }
1411
1412    #[test]
1413    fn missing_deps_empty_and_single_return_empty() {
1414        let now = Utc::now().to_rfc3339();
1415        assert!(detect_missing_dependencies(&[], &now).is_empty());
1416        assert!(
1417            detect_missing_dependencies(
1418                &[make_issue("bd-1", "Something", "Details", "open")],
1419                &now
1420            )
1421            .is_empty()
1422        );
1423    }
1424
1425    // ── detect_label_suggestions ────────────────────────────────────
1426
1427    #[test]
1428    fn label_suggestion_from_builtin_mapping() {
1429        // Issue has "database" keyword; project has "database" label on another issue
1430        let mut labeled = make_issue(
1431            "bd-1",
1432            "Old database work",
1433            "Previous db migration",
1434            "closed",
1435        );
1436        labeled.labels = vec!["database".to_string()];
1437
1438        let unlabeled = make_issue(
1439            "bd-2",
1440            "New database migration needed",
1441            "Handle the database schema changes",
1442            "open",
1443        );
1444
1445        let now = Utc::now().to_rfc3339();
1446        let results = detect_label_suggestions(&[labeled, unlabeled], &now);
1447        assert!(
1448            !results.is_empty(),
1449            "should suggest 'database' label for issue with database keyword"
1450        );
1451        assert_eq!(results[0].suggestion_type, SuggestionType::LabelSuggestion);
1452        assert_eq!(results[0].target_bead, "bd-2");
1453        let suggested = results[0]
1454            .metadata
1455            .get("suggested_label")
1456            .and_then(|v| v.as_str());
1457        assert_eq!(suggested, Some("database"));
1458    }
1459
1460    #[test]
1461    fn label_suggestion_skips_already_labeled() {
1462        let mut issue1 = make_issue(
1463            "bd-1",
1464            "Database migration work",
1465            "Previous effort",
1466            "closed",
1467        );
1468        issue1.labels = vec!["database".to_string()];
1469
1470        let mut issue2 = make_issue(
1471            "bd-2",
1472            "New database migration",
1473            "Database schema changes",
1474            "open",
1475        );
1476        issue2.labels = vec!["database".to_string()]; // already has it
1477
1478        let now = Utc::now().to_rfc3339();
1479        let results = detect_label_suggestions(&[issue1, issue2], &now);
1480        // Should not suggest "database" since bd-2 already has it
1481        let db_suggestions: Vec<_> = results
1482            .iter()
1483            .filter(|s| {
1484                s.target_bead == "bd-2"
1485                    && s.metadata.get("suggested_label").and_then(|v| v.as_str())
1486                        == Some("database")
1487            })
1488            .collect();
1489        assert!(
1490            db_suggestions.is_empty(),
1491            "should not suggest label the issue already has"
1492        );
1493    }
1494
1495    #[test]
1496    fn label_suggestion_skips_closed_issues() {
1497        let mut issue1 = make_issue("bd-1", "Database work", "DB migration", "open");
1498        issue1.labels = vec!["database".to_string()];
1499
1500        let issue2 = make_issue(
1501            "bd-2",
1502            "Database migration",
1503            "Database schema changes",
1504            "closed",
1505        );
1506
1507        let now = Utc::now().to_rfc3339();
1508        let results = detect_label_suggestions(&[issue1, issue2], &now);
1509        let closed_suggestions: Vec<_> =
1510            results.iter().filter(|s| s.target_bead == "bd-2").collect();
1511        assert!(
1512            closed_suggestions.is_empty(),
1513            "closed issues should not get label suggestions"
1514        );
1515    }
1516
1517    #[test]
1518    fn label_suggestion_preserves_canonical_project_label_casing() {
1519        let mut labeled = make_issue(
1520            "bd-1",
1521            "Backend auth work",
1522            "Previous backend login change",
1523            "closed",
1524        );
1525        labeled.labels = vec!["Backend".to_string()];
1526
1527        let unlabeled = make_issue(
1528            "bd-2",
1529            "Backend login endpoint",
1530            "Fix backend auth flow",
1531            "open",
1532        );
1533
1534        let now = Utc::now().to_rfc3339();
1535        let results = detect_label_suggestions(&[labeled, unlabeled], &now);
1536        let suggestion = results
1537            .iter()
1538            .find(|item| item.target_bead == "bd-2")
1539            .expect("expected a backend label suggestion");
1540        assert_eq!(
1541            suggestion
1542                .metadata
1543                .get("suggested_label")
1544                .and_then(|value| value.as_str()),
1545            Some("Backend")
1546        );
1547        assert_eq!(
1548            suggestion.action_command.as_deref(),
1549            Some("br update bd-2 --add-label=Backend")
1550        );
1551        assert!(suggestion.summary.contains("'Backend'"));
1552    }
1553
1554    #[test]
1555    fn label_suggestion_empty_labels_returns_empty() {
1556        // No issue has any label → no labels in the project → no suggestions
1557        let issues = vec![
1558            make_issue("bd-1", "Database migration", "Schema changes", "open"),
1559            make_issue(
1560                "bd-2",
1561                "Another database task",
1562                "More database work",
1563                "open",
1564            ),
1565        ];
1566        let now = Utc::now().to_rfc3339();
1567        let results = detect_label_suggestions(&issues, &now);
1568        assert!(
1569            results.is_empty(),
1570            "no labels in project means no suggestions"
1571        );
1572    }
1573
1574    #[test]
1575    fn label_suggestion_empty_issues() {
1576        let results = detect_label_suggestions(&[], &Utc::now().to_rfc3339());
1577        assert!(results.is_empty());
1578    }
1579
1580    #[test]
1581    fn label_suggestion_learned_mapping() {
1582        // Multiple shared keywords between closed labeled issues and the open target.
1583        // Each keyword contributes learned_bonus = 0.1 + count*0.05.
1584        // Need total score >= LABEL_MIN_CONFIDENCE (0.5).
1585        // 4 keywords each seen 2x → bonus = 4 * (0.1 + 2*0.05) = 4 * 0.2 = 0.8 ≥ 0.5
1586        let mut i1 = make_issue(
1587            "bd-1",
1588            "webhook handler retry payload",
1589            "Process webhook retry payload events",
1590            "closed",
1591        );
1592        i1.labels = vec!["integration".to_string()];
1593        let mut i2 = make_issue(
1594            "bd-2",
1595            "webhook handler retry payload",
1596            "Retry failed webhook payload handler",
1597            "closed",
1598        );
1599        i2.labels = vec!["integration".to_string()];
1600        let i3 = make_issue(
1601            "bd-3",
1602            "webhook handler retry payload",
1603            "Validate incoming webhook handler retry payload",
1604            "open",
1605        );
1606
1607        let now = Utc::now().to_rfc3339();
1608        let results = detect_label_suggestions(&[i1, i2, i3], &now);
1609        let integration_suggestions: Vec<_> = results
1610            .iter()
1611            .filter(|s| {
1612                s.target_bead == "bd-3"
1613                    && s.metadata.get("suggested_label").and_then(|v| v.as_str())
1614                        == Some("integration")
1615            })
1616            .collect();
1617        assert!(
1618            !integration_suggestions.is_empty(),
1619            "learned mapping from multiple shared keywords should suggest label"
1620        );
1621    }
1622
1623    #[test]
1624    fn canonical_project_labels_prefers_most_common_variant() {
1625        let mut issue1 = make_issue("bd-1", "One", "Desc", "open");
1626        issue1.labels = vec!["Backend".to_string()];
1627        let mut issue2 = make_issue("bd-2", "Two", "Desc", "open");
1628        issue2.labels = vec!["backend".to_string()];
1629        let mut issue3 = make_issue("bd-3", "Three", "Desc", "open");
1630        issue3.labels = vec!["Backend".to_string()];
1631
1632        let labels = canonical_project_labels(&[issue1, issue2, issue3]);
1633        assert_eq!(labels.get("backend").map(String::as_str), Some("Backend"));
1634    }
1635
1636    // ── detect_cycle_warnings ───────────────────────────────────────
1637
1638    fn empty_metrics() -> GraphMetrics {
1639        use crate::analysis::graph::IssueGraph;
1640        let graph = IssueGraph::build(&[]);
1641        graph.compute_metrics()
1642    }
1643
1644    #[test]
1645    fn cycle_warning_self_loop() {
1646        let mut metrics = empty_metrics();
1647        metrics.cycles.push(vec!["bd-1".to_string()]);
1648
1649        let now = Utc::now().to_rfc3339();
1650        let results = detect_cycle_warnings(&metrics, &now);
1651        assert_eq!(results.len(), 1);
1652        assert_eq!(results[0].suggestion_type, SuggestionType::CycleWarning);
1653        assert!(results[0].summary.contains("Self-loop"));
1654        assert_eq!(results[0].target_bead, "bd-1");
1655        // Self-loop has no action_command (need cycle_length >= 2)
1656        assert!(results[0].action_command.is_none());
1657    }
1658
1659    #[test]
1660    fn cycle_warning_two_node_cycle() {
1661        let mut metrics = empty_metrics();
1662        metrics
1663            .cycles
1664            .push(vec!["bd-1".to_string(), "bd-2".to_string()]);
1665
1666        let now = Utc::now().to_rfc3339();
1667        let results = detect_cycle_warnings(&metrics, &now);
1668        assert_eq!(results.len(), 1);
1669        assert!(results[0].summary.contains("Direct cycle"));
1670        assert!(results[0].summary.contains("bd-1"));
1671        assert!(results[0].summary.contains("bd-2"));
1672        assert_eq!(results[0].related_bead.as_deref(), Some("bd-2"));
1673        // Action suggests removing the edge from last→first
1674        assert_eq!(
1675            results[0].action_command.as_deref(),
1676            Some("br dep remove bd-2 bd-1")
1677        );
1678        assert_eq!(
1679            results[0]
1680                .metadata
1681                .get("cycle_length")
1682                .and_then(|v| v.as_u64()),
1683            Some(2)
1684        );
1685    }
1686
1687    #[test]
1688    fn cycle_warning_large_cycle() {
1689        let mut metrics = empty_metrics();
1690        metrics.cycles.push(vec![
1691            "bd-1".to_string(),
1692            "bd-2".to_string(),
1693            "bd-3".to_string(),
1694            "bd-4".to_string(),
1695        ]);
1696
1697        let now = Utc::now().to_rfc3339();
1698        let results = detect_cycle_warnings(&metrics, &now);
1699        assert_eq!(results.len(), 1);
1700        assert!(results[0].summary.contains("4 issues"));
1701        // Longer cycles get lower confidence
1702        assert!(results[0].confidence < 1.0);
1703        assert!(results[0].confidence >= 0.5);
1704        // Reason should contain cycle path
1705        assert!(
1706            results[0]
1707                .reason
1708                .contains("bd-1 -> bd-2 -> bd-3 -> bd-4 -> bd-1")
1709        );
1710    }
1711
1712    #[test]
1713    fn cycle_warning_empty_cycles() {
1714        let metrics = empty_metrics();
1715        let results = detect_cycle_warnings(&metrics, &Utc::now().to_rfc3339());
1716        assert!(results.is_empty());
1717    }
1718
1719    #[test]
1720    fn cycle_warning_skips_empty_cycle_vec() {
1721        let mut metrics = empty_metrics();
1722        metrics.cycles.push(vec![]); // empty cycle should be skipped
1723        metrics
1724            .cycles
1725            .push(vec!["bd-1".to_string(), "bd-2".to_string()]);
1726
1727        let now = Utc::now().to_rfc3339();
1728        let results = detect_cycle_warnings(&metrics, &now);
1729        assert_eq!(results.len(), 1, "empty cycle should be skipped");
1730        assert!(results[0].summary.contains("Direct cycle"));
1731    }
1732
1733    #[test]
1734    fn cycle_warning_capped_at_max() {
1735        let mut metrics = empty_metrics();
1736        for i in 0..15 {
1737            metrics
1738                .cycles
1739                .push(vec![format!("bd-{i}a"), format!("bd-{i}b")]);
1740        }
1741
1742        let now = Utc::now().to_rfc3339();
1743        let results = detect_cycle_warnings(&metrics, &now);
1744        assert_eq!(
1745            results.len(),
1746            CYCLE_MAX,
1747            "should cap at CYCLE_MAX={CYCLE_MAX}"
1748        );
1749    }
1750
1751    // ── extract_keywords / helpers ──────────────────────────────────
1752
1753    #[test]
1754    fn extract_keywords_filters_stop_words_and_short() {
1755        let keywords = extract_keywords("The quick fix", "and the bug was not here");
1756        // "the", "and", "was", "not" are stop words; "fix" and "bug" are 3 chars (kept)
1757        assert!(!keywords.contains(&"the".to_string()));
1758        assert!(!keywords.contains(&"and".to_string()));
1759        assert!(!keywords.contains(&"was".to_string()));
1760        assert!(keywords.contains(&"quick".to_string()));
1761        assert!(keywords.contains(&"fix".to_string()));
1762        assert!(keywords.contains(&"bug".to_string()));
1763    }
1764
1765    #[test]
1766    fn extract_keywords_normalizes_case_and_punctuation() {
1767        let keywords = extract_keywords("Database-Migration", "Schema_Upgrade!");
1768        assert!(keywords.contains(&"database".to_string()));
1769        assert!(keywords.contains(&"migration".to_string()));
1770        assert!(keywords.contains(&"schema".to_string()));
1771        assert!(keywords.contains(&"upgrade".to_string()));
1772    }
1773
1774    #[test]
1775    fn intersect_keywords_returns_common() {
1776        let left = vec![
1777            "database".to_string(),
1778            "migration".to_string(),
1779            "schema".to_string(),
1780        ];
1781        let right = vec![
1782            "migration".to_string(),
1783            "testing".to_string(),
1784            "schema".to_string(),
1785        ];
1786        let common = intersect_keywords(&left, &right);
1787        assert!(common.contains(&"migration".to_string()));
1788        assert!(common.contains(&"schema".to_string()));
1789        assert!(!common.contains(&"database".to_string()));
1790        assert!(!common.contains(&"testing".to_string()));
1791    }
1792
1793    #[test]
1794    fn ratio_handles_zero_denominator() {
1795        assert_eq!(ratio(5, 0), 0.0);
1796        assert!((ratio(3, 4) - 0.75).abs() < 0.001);
1797    }
1798
1799    // ── compute_stats ───────────────────────────────────────────────
1800
1801    #[test]
1802    fn compute_stats_counts_correctly() {
1803        let suggestions = vec![
1804            make_suggestion(SuggestionType::PotentialDuplicate, 0.9, "bd-1"),
1805            make_suggestion(SuggestionType::CycleWarning, 0.3, "bd-2"),
1806            make_suggestion(SuggestionType::MissingDependency, 0.6, "bd-3"),
1807        ];
1808        let stats = compute_stats(&suggestions);
1809        assert_eq!(stats.total, 3);
1810        assert_eq!(stats.by_type.get("potential_duplicate"), Some(&1));
1811        assert_eq!(stats.by_type.get("cycle_warning"), Some(&1));
1812        assert_eq!(stats.by_type.get("missing_dependency"), Some(&1));
1813        assert_eq!(stats.high_confidence_count, 1); // only 0.9 >= 0.7
1814        assert_eq!(stats.by_confidence.get("high"), Some(&1));
1815        assert_eq!(stats.by_confidence.get("medium"), Some(&1)); // 0.6
1816        assert_eq!(stats.by_confidence.get("low"), Some(&1)); // 0.3
1817    }
1818
1819    #[test]
1820    fn compute_stats_empty() {
1821        let stats = compute_stats(&[]);
1822        assert_eq!(stats.total, 0);
1823        assert!(stats.by_type.is_empty());
1824        assert_eq!(stats.high_confidence_count, 0);
1825        assert_eq!(stats.actionable_count, 0);
1826    }
1827
1828    // ── matches_filters ─────────────────────────────────────────────
1829
1830    #[test]
1831    fn matches_filters_by_min_confidence() {
1832        let suggestion = make_suggestion(SuggestionType::CycleWarning, 0.5, "bd-1");
1833        let mut options = SuggestOptions::default();
1834        options.min_confidence = 0.3;
1835        assert!(matches_filters(&suggestion, &options));
1836        options.min_confidence = 0.8;
1837        assert!(!matches_filters(&suggestion, &options));
1838    }
1839
1840    #[test]
1841    fn matches_filters_by_type() {
1842        let suggestion = make_suggestion(SuggestionType::CycleWarning, 0.8, "bd-1");
1843        let mut options = SuggestOptions::default();
1844        options.filter_type = Some(SuggestionType::CycleWarning);
1845        assert!(matches_filters(&suggestion, &options));
1846        options.filter_type = Some(SuggestionType::PotentialDuplicate);
1847        assert!(!matches_filters(&suggestion, &options));
1848    }
1849
1850    #[test]
1851    fn matches_filters_by_bead_id() {
1852        let mut suggestion = make_suggestion(SuggestionType::MissingDependency, 0.7, "bd-1");
1853        suggestion.related_bead = Some("bd-2".to_string());
1854        let mut options = SuggestOptions::default();
1855
1856        options.filter_bead = Some("bd-1".to_string());
1857        assert!(matches_filters(&suggestion, &options), "target match");
1858
1859        options.filter_bead = Some("bd-2".to_string());
1860        assert!(matches_filters(&suggestion, &options), "related match");
1861
1862        options.filter_bead = Some("bd-99".to_string());
1863        assert!(!matches_filters(&suggestion, &options), "no match");
1864    }
1865
1866    // ── confidence_level ────────────────────────────────────────────
1867
1868    #[test]
1869    fn confidence_level_boundaries() {
1870        assert_eq!(confidence_level(0.0).as_str(), "low");
1871        assert_eq!(confidence_level(0.39).as_str(), "low");
1872        assert_eq!(confidence_level(0.4).as_str(), "medium");
1873        assert_eq!(confidence_level(0.69).as_str(), "medium");
1874        assert_eq!(confidence_level(0.7).as_str(), "high");
1875        assert_eq!(confidence_level(1.0).as_str(), "high");
1876    }
1877}