Skip to main content

batuta/bug_hunter/
pmat_quality.rs

1//! PMAT Quality Integration for Bug Hunter (BH-21 to BH-25)
2//!
3//! Integrates PMAT function-level quality metrics into the bug hunting pipeline:
4//! - BH-21: Quality-weighted suspiciousness scoring
5//! - BH-22: Smart target scoping by quality
6//! - BH-23: SATD-enriched findings from PMAT data
7//! - BH-24: Regression risk scoring
8//! - BH-25: Spec claim quality gates
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use super::types::{DefectCategory, Finding, FindingEvidence, FindingSeverity, HuntMode};
14use crate::tools;
15
16/// Lightweight PMAT query result for bug-hunter integration.
17///
18/// Mirrors the fields we need from `pmat query --format json` output,
19/// avoiding cross-crate dependency on the CLI module.
20#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
21pub struct PmatFunctionInfo {
22    pub file_path: String,
23    pub function_name: String,
24    #[serde(default)]
25    pub start_line: usize,
26    #[serde(default)]
27    pub end_line: usize,
28    #[serde(default)]
29    pub tdg_score: f64,
30    #[serde(default)]
31    pub tdg_grade: String,
32    #[serde(default)]
33    pub complexity: u32,
34    #[serde(default)]
35    pub satd_count: u32,
36}
37
38/// Quality index: maps file paths to their PMAT function-level results.
39pub type PmatQualityIndex = HashMap<PathBuf, Vec<PmatFunctionInfo>>;
40
41// ============================================================================
42// Availability
43// ============================================================================
44
45/// Check if PMAT tool is available on the system.
46pub fn pmat_available() -> bool {
47    tools::ToolRegistry::detect().pmat.is_some()
48}
49
50// ============================================================================
51// Index construction
52// ============================================================================
53
54/// Run `pmat query` and parse the JSON output.
55fn run_pmat_query_raw(
56    project_path: &Path,
57    query: &str,
58    limit: usize,
59) -> Option<Vec<PmatFunctionInfo>> {
60    let limit_str = limit.to_string();
61    let args: Vec<&str> = vec!["query", query, "--format", "json", "--limit", &limit_str];
62    let output = tools::run_tool("pmat", &args, Some(project_path)).ok()?;
63    let results: Vec<PmatFunctionInfo> = serde_json::from_str(&output).ok()?;
64    Some(results)
65}
66
67/// Build a quality index by running `pmat query` against the project.
68///
69/// Returns `None` if pmat is unavailable or the query returns no results.
70pub fn build_quality_index(
71    project_path: &Path,
72    query: &str,
73    limit: usize,
74) -> Option<PmatQualityIndex> {
75    if !pmat_available() {
76        return None;
77    }
78
79    let results = run_pmat_query_raw(project_path, query, limit)?;
80    if results.is_empty() {
81        return None;
82    }
83
84    Some(index_from_results(results))
85}
86
87/// Group PMAT query results by file path, sorting each group by start_line.
88pub fn index_from_results(results: Vec<PmatFunctionInfo>) -> PmatQualityIndex {
89    let mut index: PmatQualityIndex = HashMap::new();
90    for result in results {
91        let path = PathBuf::from(&result.file_path);
92        index.entry(path).or_default().push(result);
93    }
94    // Sort each file's functions by start_line
95    for functions in index.values_mut() {
96        functions.sort_by_key(|f: &PmatFunctionInfo| f.start_line);
97    }
98    index
99}
100
101/// Look up the PMAT result for a specific file and line number.
102///
103/// First tries exact span match (start_line <= line <= end_line),
104/// then falls back to nearest function by start_line.
105pub fn lookup_quality<'a>(
106    index: &'a PmatQualityIndex,
107    file: &Path,
108    line: usize,
109) -> Option<&'a PmatFunctionInfo> {
110    let functions = index.get(file)?;
111
112    // Exact span match
113    if let Some(f) = functions.iter().find(|f| line >= f.start_line && line <= f.end_line) {
114        return Some(f);
115    }
116
117    // Fallback: nearest function by start_line
118    functions.iter().min_by_key(|f| (f.start_line as isize - line as isize).unsigned_abs())
119}
120
121// ============================================================================
122// BH-21: Quality-adjusted suspiciousness
123// ============================================================================
124
125/// Adjust a base suspiciousness score using TDG quality data.
126///
127/// Formula: `(base * (1 + weight * (0.5 - tdg/100))).clamp(0, 1)`
128///
129/// TDG 50 = baseline (no change). Low-quality code (TDG < 50) gets boosted
130/// suspiciousness; high-quality code (TDG > 50) gets reduced suspiciousness.
131pub fn quality_adjusted_suspiciousness(base: f64, tdg_score: f64, weight: f64) -> f64 {
132    let quality_factor = 0.5 - (tdg_score / 100.0);
133    (base * (1.0 + weight * quality_factor)).clamp(0.0, 1.0)
134}
135
136/// Apply quality weights to findings in-place, adding quality evidence.
137pub fn apply_quality_weights(findings: &mut [Finding], index: &PmatQualityIndex, weight: f64) {
138    for finding in findings.iter_mut() {
139        if let Some(pmat) = lookup_quality(index, &finding.file, finding.line) {
140            let original = finding.suspiciousness;
141            finding.suspiciousness =
142                quality_adjusted_suspiciousness(original, pmat.tdg_score, weight);
143            finding.evidence.push(FindingEvidence::quality_metrics(
144                &pmat.tdg_grade,
145                pmat.tdg_score,
146                pmat.complexity,
147            ));
148        }
149    }
150}
151
152// ============================================================================
153// BH-24: Regression risk scoring
154// ============================================================================
155
156/// Compute regression risk from PMAT quality data.
157///
158/// Formula: `0.5 * (1 - tdg/100) + 0.3 * (complexity/50).min(1) + 0.2 * (satd/5).min(1)`
159///
160/// Returns a value in [0.0, 1.0] where higher means greater regression risk.
161pub fn compute_regression_risk(pmat: &PmatFunctionInfo) -> f64 {
162    let tdg_factor = 1.0 - (pmat.tdg_score / 100.0);
163    let cx_factor = (pmat.complexity as f64 / 50.0).min(1.0);
164    let satd_factor = (pmat.satd_count as f64 / 5.0).min(1.0);
165    let risk: f64 = 0.5 * tdg_factor + 0.3 * cx_factor + 0.2 * satd_factor;
166    risk.clamp(0.0, 1.0)
167}
168
169/// Apply regression risk scores to findings in-place.
170pub fn apply_regression_risk(findings: &mut [Finding], index: &PmatQualityIndex) {
171    for finding in findings.iter_mut() {
172        if let Some(pmat) = lookup_quality(index, &finding.file, finding.line) {
173            finding.regression_risk = Some(compute_regression_risk(pmat));
174        }
175    }
176}
177
178// ============================================================================
179// BH-22: Smart target scoping
180// ============================================================================
181
182/// Scope targets to files with worst quality, returning paths sorted by TDG ascending.
183pub fn scope_targets_by_quality(
184    project_path: &Path,
185    query: &str,
186    limit: usize,
187) -> Option<Vec<PathBuf>> {
188    if !pmat_available() {
189        return None;
190    }
191
192    let results = run_pmat_query_raw(project_path, query, limit)?;
193    if results.is_empty() {
194        return None;
195    }
196
197    // Group by file, compute average TDG per file
198    let mut file_scores: HashMap<PathBuf, (f64, usize)> = HashMap::new();
199    for r in &results {
200        let path = PathBuf::from(&r.file_path);
201        let entry = file_scores.entry(path).or_insert((0.0, 0));
202        entry.0 += r.tdg_score;
203        entry.1 += 1;
204    }
205
206    // Sort by average TDG ascending (worst quality first)
207    let mut files: Vec<(PathBuf, f64)> =
208        file_scores.into_iter().map(|(path, (sum, count))| (path, sum / count as f64)).collect();
209    files.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
210
211    Some(files.into_iter().map(|(path, _)| path).collect())
212}
213
214// ============================================================================
215// BH-23: SATD-enriched findings
216// ============================================================================
217
218/// Generate findings from PMAT SATD (self-admitted technical debt) data.
219pub fn generate_satd_findings(project_path: &Path, index: &PmatQualityIndex) -> Vec<Finding> {
220    // GH-47: project_path reserved for future use (e.g. resolving relative paths in findings)
221    let _ = project_path;
222    let mut findings = Vec::new();
223    let mut id_counter = 0u32;
224
225    for (file, functions) in index {
226        for func in functions {
227            if func.satd_count > 0 {
228                id_counter += 1;
229                let severity = match func.satd_count {
230                    1 => FindingSeverity::Low,
231                    2..=3 => FindingSeverity::Medium,
232                    _ => FindingSeverity::High,
233                };
234                let suspiciousness = match func.satd_count {
235                    1 => 0.3,
236                    2..=3 => 0.5,
237                    _ => 0.7,
238                };
239                findings.push(
240                    Finding::new(
241                        format!("BH-SATD-{:04}", id_counter),
242                        file,
243                        func.start_line,
244                        format!(
245                            "PMAT: {} SATD markers in `{}`",
246                            func.satd_count, func.function_name
247                        ),
248                    )
249                    .with_description(format!(
250                        "Function `{}` (grade {}, complexity {}) has {} self-admitted technical debt markers",
251                        func.function_name, func.tdg_grade, func.complexity, func.satd_count
252                    ))
253                    .with_severity(severity)
254                    .with_category(DefectCategory::LogicErrors)
255                    .with_suspiciousness(suspiciousness)
256                    .with_discovered_by(HuntMode::Analyze)
257                    .with_evidence(FindingEvidence::quality_metrics(
258                        &func.tdg_grade,
259                        func.tdg_score,
260                        func.complexity,
261                    )),
262                );
263            }
264        }
265    }
266
267    findings
268}
269
270// ============================================================================
271// Tests
272// ============================================================================
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    // =========================================================================
279    // BH-PMAT-001: pmat_available
280    // =========================================================================
281
282    #[test]
283    fn test_pmat_available_returns_bool() {
284        let _ = pmat_available();
285    }
286
287    // =========================================================================
288    // BH-PMAT-002: quality_adjusted_suspiciousness
289    // =========================================================================
290
291    #[test]
292    fn test_quality_adjusted_high_quality_reduces() {
293        let adjusted = quality_adjusted_suspiciousness(0.5, 95.0, 0.5);
294        assert!(adjusted < 0.5, "High quality should reduce suspiciousness: got {}", adjusted);
295    }
296
297    #[test]
298    fn test_quality_adjusted_low_quality_boosts() {
299        let adjusted = quality_adjusted_suspiciousness(0.5, 20.0, 0.5);
300        assert!(adjusted > 0.5, "Low quality should boost suspiciousness: got {}", adjusted);
301    }
302
303    #[test]
304    fn test_quality_adjusted_baseline_no_change() {
305        // TDG 50 is baseline, quality_factor = 0, so no change
306        let adjusted = quality_adjusted_suspiciousness(0.5, 50.0, 0.5);
307        assert!((adjusted - 0.5).abs() < 0.001);
308    }
309
310    #[test]
311    fn test_quality_adjusted_clamping() {
312        let adjusted = quality_adjusted_suspiciousness(0.9, 10.0, 1.0);
313        assert!(adjusted <= 1.0, "Should clamp to 1.0: got {}", adjusted);
314
315        let adjusted = quality_adjusted_suspiciousness(0.0, 10.0, 1.0);
316        assert!((adjusted - 0.0).abs() < 0.001);
317    }
318
319    #[test]
320    fn test_quality_adjusted_weight_zero() {
321        let adjusted = quality_adjusted_suspiciousness(0.5, 20.0, 0.0);
322        assert!((adjusted - 0.5).abs() < 0.001);
323    }
324
325    // =========================================================================
326    // BH-PMAT-003: index_from_results
327    // =========================================================================
328
329    #[test]
330    fn test_index_from_results_groups_by_file() {
331        let results = vec![
332            PmatFunctionInfo {
333                file_path: "src/lib.rs".into(),
334                function_name: "alpha".into(),
335                start_line: 10,
336                end_line: 20,
337                tdg_score: 80.0,
338                tdg_grade: "B".into(),
339                complexity: 5,
340                satd_count: 0,
341            },
342            PmatFunctionInfo {
343                file_path: "src/lib.rs".into(),
344                function_name: "beta".into(),
345                start_line: 30,
346                end_line: 50,
347                tdg_score: 60.0,
348                tdg_grade: "C".into(),
349                complexity: 12,
350                satd_count: 2,
351            },
352            PmatFunctionInfo {
353                file_path: "src/main.rs".into(),
354                function_name: "main".into(),
355                start_line: 1,
356                end_line: 10,
357                tdg_score: 95.0,
358                tdg_grade: "A".into(),
359                complexity: 2,
360                satd_count: 0,
361            },
362        ];
363
364        let index = index_from_results(results);
365        assert_eq!(index.len(), 2);
366        assert_eq!(index[&PathBuf::from("src/lib.rs")].len(), 2);
367        assert_eq!(index[&PathBuf::from("src/main.rs")].len(), 1);
368        let lib_fns = &index[&PathBuf::from("src/lib.rs")];
369        assert!(lib_fns[0].start_line < lib_fns[1].start_line);
370    }
371
372    // =========================================================================
373    // BH-PMAT-004: lookup_quality
374    // =========================================================================
375
376    #[test]
377    fn test_lookup_quality_exact_span() {
378        let results = vec![PmatFunctionInfo {
379            file_path: "src/lib.rs".into(),
380            function_name: "process".into(),
381            start_line: 10,
382            end_line: 30,
383            tdg_score: 75.0,
384            tdg_grade: "C".into(),
385            complexity: 0,
386            satd_count: 0,
387        }];
388        let index = index_from_results(results);
389
390        let result = lookup_quality(&index, Path::new("src/lib.rs"), 20);
391        assert!(result.is_some());
392        assert_eq!(result.expect("operation failed").function_name, "process");
393    }
394
395    #[test]
396    fn test_lookup_quality_nearest_fallback() {
397        let results = vec![
398            PmatFunctionInfo {
399                file_path: "src/lib.rs".into(),
400                function_name: "alpha".into(),
401                start_line: 10,
402                end_line: 20,
403                tdg_score: 0.0,
404                tdg_grade: String::new(),
405                complexity: 0,
406                satd_count: 0,
407            },
408            PmatFunctionInfo {
409                file_path: "src/lib.rs".into(),
410                function_name: "beta".into(),
411                start_line: 50,
412                end_line: 70,
413                tdg_score: 0.0,
414                tdg_grade: String::new(),
415                complexity: 0,
416                satd_count: 0,
417            },
418        ];
419        let index = index_from_results(results);
420
421        let result = lookup_quality(&index, Path::new("src/lib.rs"), 35);
422        assert!(result.is_some());
423    }
424
425    #[test]
426    fn test_lookup_quality_no_file() {
427        let index: PmatQualityIndex = HashMap::new();
428        let result = lookup_quality(&index, Path::new("nonexistent.rs"), 10);
429        assert!(result.is_none());
430    }
431
432    // =========================================================================
433    // BH-PMAT-005: compute_regression_risk
434    // =========================================================================
435
436    #[test]
437    fn test_regression_risk_high_quality() {
438        let pmat = PmatFunctionInfo {
439            file_path: String::new(),
440            function_name: String::new(),
441            start_line: 0,
442            end_line: 0,
443            tdg_score: 95.0,
444            tdg_grade: "A".into(),
445            complexity: 3,
446            satd_count: 0,
447        };
448        let risk = compute_regression_risk(&pmat);
449        assert!(risk < 0.1, "High quality should have low risk: got {}", risk);
450    }
451
452    #[test]
453    fn test_regression_risk_low_quality() {
454        let pmat = PmatFunctionInfo {
455            file_path: String::new(),
456            function_name: String::new(),
457            start_line: 0,
458            end_line: 0,
459            tdg_score: 20.0,
460            tdg_grade: "F".into(),
461            complexity: 40,
462            satd_count: 8,
463        };
464        let risk = compute_regression_risk(&pmat);
465        assert!(risk > 0.7, "Low quality should have high risk: got {}", risk);
466    }
467
468    #[test]
469    fn test_regression_risk_clamped() {
470        let pmat = PmatFunctionInfo {
471            file_path: String::new(),
472            function_name: String::new(),
473            start_line: 0,
474            end_line: 0,
475            tdg_score: 0.0,
476            tdg_grade: "F".into(),
477            complexity: 100,
478            satd_count: 20,
479        };
480        let risk = compute_regression_risk(&pmat);
481        assert!(risk <= 1.0);
482        assert!(risk >= 0.0);
483    }
484
485    // =========================================================================
486    // BH-PMAT-006: generate_satd_findings
487    // =========================================================================
488
489    #[test]
490    fn test_generate_satd_findings_produces_findings() {
491        let results = vec![
492            PmatFunctionInfo {
493                file_path: "src/lib.rs".into(),
494                function_name: "messy_fn".into(),
495                start_line: 10,
496                end_line: 50,
497                tdg_score: 40.0,
498                tdg_grade: "D".into(),
499                complexity: 15,
500                satd_count: 3,
501            },
502            PmatFunctionInfo {
503                file_path: "src/lib.rs".into(),
504                function_name: "clean_fn".into(),
505                start_line: 60,
506                end_line: 70,
507                tdg_score: 95.0,
508                tdg_grade: "A".into(),
509                complexity: 2,
510                satd_count: 0,
511            },
512        ];
513        let index = index_from_results(results);
514        let findings = generate_satd_findings(Path::new("."), &index);
515
516        assert_eq!(findings.len(), 1, "Should only generate for functions with SATD");
517        assert!(findings[0].id.starts_with("BH-SATD-"));
518        assert!(findings[0].title.contains("messy_fn"));
519        assert_eq!(findings[0].severity, FindingSeverity::Medium);
520    }
521
522    #[test]
523    fn test_generate_satd_findings_severity_scaling() {
524        let results = vec![
525            PmatFunctionInfo {
526                file_path: "a.rs".into(),
527                function_name: "low".into(),
528                start_line: 1,
529                end_line: 10,
530                tdg_score: 0.0,
531                tdg_grade: String::new(),
532                complexity: 0,
533                satd_count: 1,
534            },
535            PmatFunctionInfo {
536                file_path: "b.rs".into(),
537                function_name: "high".into(),
538                start_line: 1,
539                end_line: 10,
540                tdg_score: 0.0,
541                tdg_grade: String::new(),
542                complexity: 0,
543                satd_count: 5,
544            },
545        ];
546        let index = index_from_results(results);
547        let findings = generate_satd_findings(Path::new("."), &index);
548
549        assert_eq!(findings.len(), 2);
550        let low_finding =
551            findings.iter().find(|f| f.title.contains("low")).expect("element not found");
552        let high_finding =
553            findings.iter().find(|f| f.title.contains("high")).expect("element not found");
554        assert_eq!(low_finding.severity, FindingSeverity::Low);
555        assert_eq!(high_finding.severity, FindingSeverity::High);
556    }
557
558    #[test]
559    fn test_generate_satd_findings_empty_index() {
560        let index: PmatQualityIndex = HashMap::new();
561        let findings = generate_satd_findings(Path::new("."), &index);
562        assert!(findings.is_empty());
563    }
564
565    // =========================================================================
566    // BH-PMAT-007: apply_quality_weights
567    // =========================================================================
568
569    #[test]
570    fn test_apply_quality_weights_adjusts_scores() {
571        let results = vec![PmatFunctionInfo {
572            file_path: "src/lib.rs".into(),
573            function_name: "buggy".into(),
574            start_line: 10,
575            end_line: 30,
576            tdg_score: 30.0,
577            tdg_grade: "D".into(),
578            complexity: 20,
579            satd_count: 0,
580        }];
581        let index = index_from_results(results);
582
583        let mut findings =
584            vec![Finding::new("F-001", "src/lib.rs", 15, "Test finding").with_suspiciousness(0.5)];
585
586        apply_quality_weights(&mut findings, &index, 0.5);
587
588        assert!(findings[0].suspiciousness > 0.5);
589        assert!(findings[0].evidence.iter().any(|e| matches!(
590            e.evidence_type,
591            crate::bug_hunter::types::EvidenceKind::QualityMetrics
592        )));
593    }
594
595    // =========================================================================
596    // BH-PMAT-008: apply_regression_risk
597    // =========================================================================
598
599    #[test]
600    fn test_apply_regression_risk_sets_risk() {
601        let results = vec![PmatFunctionInfo {
602            file_path: "src/lib.rs".into(),
603            function_name: "risky".into(),
604            start_line: 10,
605            end_line: 30,
606            tdg_score: 30.0,
607            tdg_grade: "D".into(),
608            complexity: 25,
609            satd_count: 4,
610        }];
611        let index = index_from_results(results);
612
613        let mut findings = vec![Finding::new("F-001", "src/lib.rs", 15, "Test finding")];
614
615        apply_regression_risk(&mut findings, &index);
616        assert!(findings[0].regression_risk.is_some());
617        assert!(findings[0].regression_risk.expect("unexpected failure") > 0.3);
618    }
619
620    // =========================================================================
621    // BH-PMAT-009: Extreme TDG scores
622    // =========================================================================
623
624    #[test]
625    fn test_quality_adjusted_tdg_zero() {
626        // TDG=0 means worst quality: quality_factor = 0.5 - 0/100 = 0.5
627        let adjusted = quality_adjusted_suspiciousness(0.5, 0.0, 1.0);
628        // base * (1 + 1.0 * 0.5) = 0.5 * 1.5 = 0.75
629        assert!((adjusted - 0.75).abs() < 0.001);
630    }
631
632    #[test]
633    fn test_quality_adjusted_tdg_100() {
634        // TDG=100 means best quality: quality_factor = 0.5 - 100/100 = -0.5
635        let adjusted = quality_adjusted_suspiciousness(0.5, 100.0, 1.0);
636        // base * (1 + 1.0 * (-0.5)) = 0.5 * 0.5 = 0.25
637        assert!((adjusted - 0.25).abs() < 0.001);
638    }
639
640    #[test]
641    fn test_quality_adjusted_tdg_over_100() {
642        // TDG > 100 (edge case): quality_factor = 0.5 - 150/100 = -1.0
643        // base * (1 + 1.0 * (-1.0)) = 0.5 * 0.0 = 0.0
644        let adjusted = quality_adjusted_suspiciousness(0.5, 150.0, 1.0);
645        assert!((adjusted - 0.0).abs() < 0.001);
646    }
647
648    #[test]
649    fn test_quality_adjusted_negative_tdg() {
650        // Negative TDG (edge case): quality_factor = 0.5 - (-50/100) = 1.0
651        // base * (1 + 1.0 * 1.0) = 0.5 * 2.0 = 1.0 (clamped)
652        let adjusted = quality_adjusted_suspiciousness(0.5, -50.0, 1.0);
653        assert!((adjusted - 1.0).abs() < 0.001);
654    }
655
656    #[test]
657    fn test_quality_adjusted_base_one() {
658        // Base=1.0 with low quality
659        let adjusted = quality_adjusted_suspiciousness(1.0, 0.0, 1.0);
660        // 1.0 * (1 + 1.0 * 0.5) = 1.5 -> clamped to 1.0
661        assert!((adjusted - 1.0).abs() < 0.001);
662    }
663
664    #[test]
665    fn test_quality_adjusted_large_weight() {
666        let adjusted = quality_adjusted_suspiciousness(0.5, 20.0, 5.0);
667        // quality_factor = 0.5 - 0.2 = 0.3
668        // 0.5 * (1 + 5.0 * 0.3) = 0.5 * 2.5 = 1.25 -> clamped to 1.0
669        assert!((adjusted - 1.0).abs() < 0.001);
670    }
671
672    #[test]
673    fn test_quality_adjusted_negative_weight() {
674        // Negative weight inverts the effect
675        let adjusted = quality_adjusted_suspiciousness(0.5, 20.0, -1.0);
676        // quality_factor = 0.3
677        // 0.5 * (1 + (-1.0) * 0.3) = 0.5 * 0.7 = 0.35
678        assert!((adjusted - 0.35).abs() < 0.001);
679    }
680
681    // =========================================================================
682    // BH-PMAT-010: Regression risk edge cases
683    // =========================================================================
684
685    #[test]
686    fn test_regression_risk_tdg_exactly_100() {
687        let pmat = PmatFunctionInfo {
688            file_path: String::new(),
689            function_name: String::new(),
690            start_line: 0,
691            end_line: 0,
692            tdg_score: 100.0,
693            tdg_grade: "A+".into(),
694            complexity: 0,
695            satd_count: 0,
696        };
697        let risk = compute_regression_risk(&pmat);
698        // 0.5*(1-1.0) + 0.3*(0/50).min(1) + 0.2*(0/5).min(1) = 0.0
699        assert!((risk - 0.0).abs() < 0.001);
700    }
701
702    #[test]
703    fn test_regression_risk_tdg_zero_max_complexity_max_satd() {
704        let pmat = PmatFunctionInfo {
705            file_path: String::new(),
706            function_name: String::new(),
707            start_line: 0,
708            end_line: 0,
709            tdg_score: 0.0,
710            tdg_grade: "F".into(),
711            complexity: 100,
712            satd_count: 100,
713        };
714        let risk = compute_regression_risk(&pmat);
715        // 0.5*(1-0) + 0.3*min(100/50,1) + 0.2*min(100/5,1) = 0.5+0.3+0.2 = 1.0
716        assert!((risk - 1.0).abs() < 0.001);
717    }
718
719    #[test]
720    fn test_regression_risk_complexity_at_threshold() {
721        let pmat = PmatFunctionInfo {
722            file_path: String::new(),
723            function_name: String::new(),
724            start_line: 0,
725            end_line: 0,
726            tdg_score: 50.0,
727            tdg_grade: "C".into(),
728            complexity: 50,
729            satd_count: 5,
730        };
731        let risk = compute_regression_risk(&pmat);
732        // 0.5*(1-0.5) + 0.3*min(50/50,1) + 0.2*min(5/5,1) = 0.25+0.3+0.2 = 0.75
733        assert!((risk - 0.75).abs() < 0.001);
734    }
735
736    #[test]
737    fn test_regression_risk_complexity_below_threshold() {
738        let pmat = PmatFunctionInfo {
739            file_path: String::new(),
740            function_name: String::new(),
741            start_line: 0,
742            end_line: 0,
743            tdg_score: 50.0,
744            tdg_grade: "C".into(),
745            complexity: 25,
746            satd_count: 0,
747        };
748        let risk = compute_regression_risk(&pmat);
749        // 0.5*(0.5) + 0.3*(25/50) + 0.2*0.0 = 0.25+0.15+0.0 = 0.40
750        assert!((risk - 0.40).abs() < 0.001);
751    }
752
753    // =========================================================================
754    // BH-PMAT-011: apply_quality_weights edge cases
755    // =========================================================================
756
757    #[test]
758    fn test_apply_quality_weights_no_match() {
759        let index: PmatQualityIndex = HashMap::new();
760        let mut findings =
761            vec![Finding::new("F-001", "nonexistent.rs", 10, "Test").with_suspiciousness(0.5)];
762
763        apply_quality_weights(&mut findings, &index, 0.5);
764
765        // No match, score unchanged
766        assert!((findings[0].suspiciousness - 0.5).abs() < 0.001);
767        assert!(findings[0].evidence.is_empty());
768    }
769
770    #[test]
771    fn test_apply_quality_weights_empty_findings() {
772        let results = vec![PmatFunctionInfo {
773            file_path: "src/lib.rs".into(),
774            function_name: "f".into(),
775            start_line: 1,
776            end_line: 10,
777            tdg_score: 50.0,
778            tdg_grade: "C".into(),
779            complexity: 5,
780            satd_count: 0,
781        }];
782        let index = index_from_results(results);
783        let mut findings: Vec<Finding> = vec![];
784        apply_quality_weights(&mut findings, &index, 0.5);
785        assert!(findings.is_empty());
786    }
787
788    #[test]
789    fn test_apply_quality_weights_multiple_findings() {
790        let results = vec![
791            PmatFunctionInfo {
792                file_path: "src/lib.rs".into(),
793                function_name: "f1".into(),
794                start_line: 1,
795                end_line: 20,
796                tdg_score: 20.0,
797                tdg_grade: "F".into(),
798                complexity: 30,
799                satd_count: 0,
800            },
801            PmatFunctionInfo {
802                file_path: "src/lib.rs".into(),
803                function_name: "f2".into(),
804                start_line: 30,
805                end_line: 50,
806                tdg_score: 90.0,
807                tdg_grade: "A".into(),
808                complexity: 2,
809                satd_count: 0,
810            },
811        ];
812        let index = index_from_results(results);
813
814        let mut findings = vec![
815            Finding::new("F-001", "src/lib.rs", 10, "Low quality").with_suspiciousness(0.5),
816            Finding::new("F-002", "src/lib.rs", 40, "High quality").with_suspiciousness(0.5),
817        ];
818
819        apply_quality_weights(&mut findings, &index, 0.5);
820
821        // Low quality (TDG=20) should be boosted, high quality (TDG=90) reduced
822        assert!(findings[0].suspiciousness > 0.5);
823        assert!(findings[1].suspiciousness < 0.5);
824    }
825
826    // =========================================================================
827    // BH-PMAT-012: apply_regression_risk edge cases
828    // =========================================================================
829
830    #[test]
831    fn test_apply_regression_risk_no_match() {
832        let index: PmatQualityIndex = HashMap::new();
833        let mut findings = vec![Finding::new("F-001", "nonexistent.rs", 10, "Test")];
834        apply_regression_risk(&mut findings, &index);
835        assert!(findings[0].regression_risk.is_none());
836    }
837
838    #[test]
839    fn test_apply_regression_risk_empty_findings() {
840        let results = vec![PmatFunctionInfo {
841            file_path: "src/lib.rs".into(),
842            function_name: "f".into(),
843            start_line: 1,
844            end_line: 10,
845            tdg_score: 50.0,
846            tdg_grade: "C".into(),
847            complexity: 5,
848            satd_count: 0,
849        }];
850        let index = index_from_results(results);
851        let mut findings: Vec<Finding> = vec![];
852        apply_regression_risk(&mut findings, &index);
853        assert!(findings.is_empty());
854    }
855
856    #[test]
857    fn test_apply_regression_risk_multiple_findings() {
858        let results = vec![PmatFunctionInfo {
859            file_path: "src/lib.rs".into(),
860            function_name: "f".into(),
861            start_line: 1,
862            end_line: 50,
863            tdg_score: 30.0,
864            tdg_grade: "D".into(),
865            complexity: 40,
866            satd_count: 3,
867        }];
868        let index = index_from_results(results);
869
870        let mut findings = vec![
871            Finding::new("F-001", "src/lib.rs", 10, "First"),
872            Finding::new("F-002", "src/lib.rs", 20, "Second"),
873        ];
874
875        apply_regression_risk(&mut findings, &index);
876        assert!(findings[0].regression_risk.is_some());
877        assert!(findings[1].regression_risk.is_some());
878        // Both should have same risk (same function)
879        assert!(
880            (findings[0].regression_risk.expect("unexpected failure")
881                - findings[1].regression_risk.expect("unexpected failure"))
882            .abs()
883                < 0.001
884        );
885    }
886
887    // =========================================================================
888    // BH-PMAT-013: lookup_quality edge cases
889    // =========================================================================
890
891    #[test]
892    fn test_lookup_quality_exact_start_boundary() {
893        let results = vec![PmatFunctionInfo {
894            file_path: "src/lib.rs".into(),
895            function_name: "bounded".into(),
896            start_line: 10,
897            end_line: 20,
898            tdg_score: 80.0,
899            tdg_grade: "B".into(),
900            complexity: 0,
901            satd_count: 0,
902        }];
903        let index = index_from_results(results);
904        // Exact start_line should match
905        let result = lookup_quality(&index, Path::new("src/lib.rs"), 10);
906        assert!(result.is_some());
907        assert_eq!(result.expect("operation failed").function_name, "bounded");
908    }
909
910    #[test]
911    fn test_lookup_quality_exact_end_boundary() {
912        let results = vec![PmatFunctionInfo {
913            file_path: "src/lib.rs".into(),
914            function_name: "bounded".into(),
915            start_line: 10,
916            end_line: 20,
917            tdg_score: 80.0,
918            tdg_grade: "B".into(),
919            complexity: 0,
920            satd_count: 0,
921        }];
922        let index = index_from_results(results);
923        // Exact end_line should match
924        let result = lookup_quality(&index, Path::new("src/lib.rs"), 20);
925        assert!(result.is_some());
926        assert_eq!(result.expect("operation failed").function_name, "bounded");
927    }
928
929    #[test]
930    fn test_lookup_quality_between_functions_nearest() {
931        let results = vec![
932            PmatFunctionInfo {
933                file_path: "src/lib.rs".into(),
934                function_name: "alpha".into(),
935                start_line: 10,
936                end_line: 20,
937                tdg_score: 0.0,
938                tdg_grade: String::new(),
939                complexity: 0,
940                satd_count: 0,
941            },
942            PmatFunctionInfo {
943                file_path: "src/lib.rs".into(),
944                function_name: "beta".into(),
945                start_line: 50,
946                end_line: 70,
947                tdg_score: 0.0,
948                tdg_grade: String::new(),
949                complexity: 0,
950                satd_count: 0,
951            },
952        ];
953        let index = index_from_results(results);
954
955        // Line 25 is closer to alpha (start=10) than beta (start=50)
956        let result = lookup_quality(&index, Path::new("src/lib.rs"), 25);
957        assert!(result.is_some());
958        assert_eq!(result.expect("operation failed").function_name, "alpha");
959
960        // Line 45 is closer to beta (start=50) than alpha (start=10)
961        let result = lookup_quality(&index, Path::new("src/lib.rs"), 45);
962        assert!(result.is_some());
963        assert_eq!(result.expect("operation failed").function_name, "beta");
964    }
965
966    // =========================================================================
967    // BH-PMAT-014: index_from_results edge cases
968    // =========================================================================
969
970    #[test]
971    fn test_index_from_results_empty() {
972        let index = index_from_results(vec![]);
973        assert!(index.is_empty());
974    }
975
976    #[test]
977    fn test_index_from_results_unsorted_input() {
978        let results = vec![
979            PmatFunctionInfo {
980                file_path: "src/lib.rs".into(),
981                function_name: "last".into(),
982                start_line: 100,
983                end_line: 120,
984                tdg_score: 0.0,
985                tdg_grade: String::new(),
986                complexity: 0,
987                satd_count: 0,
988            },
989            PmatFunctionInfo {
990                file_path: "src/lib.rs".into(),
991                function_name: "first".into(),
992                start_line: 5,
993                end_line: 15,
994                tdg_score: 0.0,
995                tdg_grade: String::new(),
996                complexity: 0,
997                satd_count: 0,
998            },
999            PmatFunctionInfo {
1000                file_path: "src/lib.rs".into(),
1001                function_name: "middle".into(),
1002                start_line: 50,
1003                end_line: 60,
1004                tdg_score: 0.0,
1005                tdg_grade: String::new(),
1006                complexity: 0,
1007                satd_count: 0,
1008            },
1009        ];
1010        let index = index_from_results(results);
1011        let fns = &index[&PathBuf::from("src/lib.rs")];
1012        assert_eq!(fns.len(), 3);
1013        // Should be sorted by start_line
1014        assert_eq!(fns[0].function_name, "first");
1015        assert_eq!(fns[1].function_name, "middle");
1016        assert_eq!(fns[2].function_name, "last");
1017    }
1018
1019    // =========================================================================
1020    // BH-PMAT-015: generate_satd_findings edge cases
1021    // =========================================================================
1022
1023    #[test]
1024    fn test_generate_satd_findings_medium_satd() {
1025        let results = vec![PmatFunctionInfo {
1026            file_path: "src/lib.rs".into(),
1027            function_name: "medium".into(),
1028            start_line: 1,
1029            end_line: 10,
1030            tdg_score: 50.0,
1031            tdg_grade: "C".into(),
1032            complexity: 10,
1033            satd_count: 2,
1034        }];
1035        let index = index_from_results(results);
1036        let findings = generate_satd_findings(Path::new("."), &index);
1037        assert_eq!(findings.len(), 1);
1038        assert_eq!(findings[0].severity, FindingSeverity::Medium);
1039        assert!((findings[0].suspiciousness - 0.5).abs() < 0.001);
1040    }
1041
1042    #[test]
1043    fn test_generate_satd_findings_high_satd() {
1044        let results = vec![PmatFunctionInfo {
1045            file_path: "src/lib.rs".into(),
1046            function_name: "terrible".into(),
1047            start_line: 1,
1048            end_line: 10,
1049            tdg_score: 10.0,
1050            tdg_grade: "F".into(),
1051            complexity: 50,
1052            satd_count: 10,
1053        }];
1054        let index = index_from_results(results);
1055        let findings = generate_satd_findings(Path::new("."), &index);
1056        assert_eq!(findings.len(), 1);
1057        assert_eq!(findings[0].severity, FindingSeverity::High);
1058        assert!((findings[0].suspiciousness - 0.7).abs() < 0.001);
1059    }
1060
1061    #[test]
1062    fn test_generate_satd_findings_description_format() {
1063        let results = vec![PmatFunctionInfo {
1064            file_path: "src/lib.rs".into(),
1065            function_name: "check_fn".into(),
1066            start_line: 5,
1067            end_line: 15,
1068            tdg_score: 60.0,
1069            tdg_grade: "C".into(),
1070            complexity: 8,
1071            satd_count: 1,
1072        }];
1073        let index = index_from_results(results);
1074        let findings = generate_satd_findings(Path::new("."), &index);
1075        assert_eq!(findings.len(), 1);
1076        let f = &findings[0];
1077        assert!(f.title.contains("check_fn"));
1078        assert!(f.title.contains("1 SATD"));
1079        assert!(f.description.contains("grade C"));
1080        assert!(f.description.contains("complexity 8"));
1081    }
1082
1083    #[test]
1084    fn test_generate_satd_findings_multiple_files() {
1085        let results = vec![
1086            PmatFunctionInfo {
1087                file_path: "src/a.rs".into(),
1088                function_name: "fn_a".into(),
1089                start_line: 1,
1090                end_line: 10,
1091                tdg_score: 0.0,
1092                tdg_grade: String::new(),
1093                complexity: 0,
1094                satd_count: 2,
1095            },
1096            PmatFunctionInfo {
1097                file_path: "src/b.rs".into(),
1098                function_name: "fn_b".into(),
1099                start_line: 1,
1100                end_line: 10,
1101                tdg_score: 0.0,
1102                tdg_grade: String::new(),
1103                complexity: 0,
1104                satd_count: 3,
1105            },
1106            PmatFunctionInfo {
1107                file_path: "src/c.rs".into(),
1108                function_name: "fn_c".into(),
1109                start_line: 1,
1110                end_line: 10,
1111                tdg_score: 0.0,
1112                tdg_grade: String::new(),
1113                complexity: 0,
1114                satd_count: 0,
1115            },
1116        ];
1117        let index = index_from_results(results);
1118        let findings = generate_satd_findings(Path::new("."), &index);
1119        // Only fn_a and fn_b have SATD
1120        assert_eq!(findings.len(), 2);
1121    }
1122
1123    #[test]
1124    fn test_generate_satd_findings_id_counter() {
1125        let results = vec![
1126            PmatFunctionInfo {
1127                file_path: "src/lib.rs".into(),
1128                function_name: "f1".into(),
1129                start_line: 1,
1130                end_line: 10,
1131                tdg_score: 0.0,
1132                tdg_grade: String::new(),
1133                complexity: 0,
1134                satd_count: 1,
1135            },
1136            PmatFunctionInfo {
1137                file_path: "src/lib.rs".into(),
1138                function_name: "f2".into(),
1139                start_line: 20,
1140                end_line: 30,
1141                tdg_score: 0.0,
1142                tdg_grade: String::new(),
1143                complexity: 0,
1144                satd_count: 1,
1145            },
1146        ];
1147        let index = index_from_results(results);
1148        let findings = generate_satd_findings(Path::new("."), &index);
1149        assert_eq!(findings.len(), 2);
1150        // IDs should be sequential
1151        assert!(findings.iter().any(|f| f.id.contains("0001")));
1152        assert!(findings.iter().any(|f| f.id.contains("0002")));
1153    }
1154
1155    #[test]
1156    fn test_generate_satd_findings_category_and_mode() {
1157        let results = vec![PmatFunctionInfo {
1158            file_path: "src/lib.rs".into(),
1159            function_name: "f".into(),
1160            start_line: 1,
1161            end_line: 10,
1162            tdg_score: 0.0,
1163            tdg_grade: String::new(),
1164            complexity: 0,
1165            satd_count: 1,
1166        }];
1167        let index = index_from_results(results);
1168        let findings = generate_satd_findings(Path::new("."), &index);
1169        assert_eq!(findings.len(), 1);
1170        assert_eq!(findings[0].category, DefectCategory::LogicErrors);
1171        assert_eq!(findings[0].discovered_by, HuntMode::Analyze);
1172    }
1173
1174    // =========================================================================
1175    // BH-PMAT-016: build_quality_index exercised (external tool dependency)
1176    // =========================================================================
1177
1178    #[test]
1179    fn test_build_quality_index_returns_option() {
1180        // This exercises build_quality_index's pmat_available check and
1181        // run_pmat_query_raw call. In CI without pmat, returns None at line 76.
1182        // With pmat installed, it may return None from run_pmat_query_raw.
1183        let result = build_quality_index(Path::new("."), "nonexistent_query_xyz", 1);
1184        // Either None or Some — both are valid depending on environment
1185        let _ = result;
1186    }
1187
1188    #[test]
1189    fn test_scope_targets_by_quality_returns_option() {
1190        // Exercise scope_targets_by_quality path
1191        let result = scope_targets_by_quality(Path::new("."), "nonexistent_query_xyz", 1);
1192        let _ = result;
1193    }
1194
1195    #[test]
1196    fn test_scope_targets_by_quality_with_real_query() {
1197        // Try a real query that pmat might return results for
1198        let result = scope_targets_by_quality(Path::new("."), "cache", 5);
1199        // If pmat available and returns results, should get Some
1200        // If not, None is also valid
1201        if let Some(paths) = result {
1202            // Paths should be sorted by TDG ascending
1203            assert!(!paths.is_empty());
1204        }
1205    }
1206
1207    #[test]
1208    fn test_build_quality_index_with_real_query() {
1209        // Try a real query that pmat might return results for
1210        let result = build_quality_index(Path::new("."), "cache", 5);
1211        if let Some(index) = result {
1212            assert!(!index.is_empty());
1213            // Verify functions are sorted by start_line within each file
1214            for functions in index.values() {
1215                for w in functions.windows(2) {
1216                    assert!(w[0].start_line <= w[1].start_line);
1217                }
1218            }
1219        }
1220    }
1221}