Skip to main content

jugar_probar/comply/
tarantula.rs

1//! Tarantula Spectrum-Based Fault Localization (PROBAR-WASM-003)
2//!
3//! Implements the Tarantula algorithm for identifying suspicious lines of code
4//! based on test coverage data.
5//!
6//! ## Algorithm
7//!
8//! The suspiciousness score for a line is calculated as:
9//!
10//! ```text
11//! suspiciousness = (failed(line) / total_failed) /
12//!                  ((failed(line) / total_failed) + (passed(line) / total_passed))
13//! ```
14//!
15//! Lines with higher suspiciousness scores are more likely to contain bugs.
16//!
17//! ## Integration
18//!
19//! This module consumes `.lcov` or `profraw` coverage artifacts to calculate
20//! suspiciousness scores when proptest or other property-based tests fail.
21
22use std::collections::HashMap;
23use std::path::Path;
24
25/// Coverage data for a single line
26#[derive(Debug, Default, Clone)]
27pub struct LineCoverage {
28    /// Number of times this line was executed in passing tests
29    pub passed_executions: usize,
30    /// Number of times this line was executed in failing tests
31    pub failed_executions: usize,
32}
33
34/// Tarantula suspiciousness report for a file
35#[derive(Debug, Default)]
36pub struct TarantulaReport {
37    /// File path
38    pub file: String,
39    /// Line suspiciousness scores (line number -> score)
40    pub line_scores: HashMap<usize, f64>,
41    /// Total passing tests
42    pub total_passed: usize,
43    /// Total failing tests
44    pub total_failed: usize,
45}
46
47impl TarantulaReport {
48    /// Get the top N most suspicious lines
49    #[must_use]
50    pub fn top_suspicious(&self, n: usize) -> Vec<(usize, f64)> {
51        let mut scores: Vec<_> = self.line_scores.iter().map(|(&l, &s)| (l, s)).collect();
52        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
53        scores.truncate(n);
54        scores
55    }
56
57    /// Format as a hotspot report
58    #[must_use]
59    pub fn format_hotspot_report(&self) -> String {
60        let mut output = String::new();
61        output.push_str(&format!("🎯 Tarantula Hotspot Report: {}\n", self.file));
62        output.push_str(&format!(
63            "   Tests: {} passed, {} failed\n\n",
64            self.total_passed, self.total_failed
65        ));
66
67        output.push_str("   Line  | Suspiciousness | Status\n");
68        output.push_str("   ------|----------------|--------\n");
69
70        for (line, score) in self.top_suspicious(10) {
71            let status = if score > 0.8 {
72                "🔴 HIGH"
73            } else if score > 0.5 {
74                "🟡 MEDIUM"
75            } else {
76                "🟢 LOW"
77            };
78            output.push_str(&format!("   {:5} | {:14.3} | {}\n", line, score, status));
79        }
80
81        output
82    }
83}
84
85/// Tarantula fault localization engine
86#[derive(Debug, Default)]
87pub struct TarantulaEngine {
88    /// Coverage data per file per line
89    coverage: HashMap<String, HashMap<usize, LineCoverage>>,
90    /// Total passing tests recorded
91    total_passed: usize,
92    /// Total failing tests recorded
93    total_failed: usize,
94}
95
96impl TarantulaEngine {
97    /// Create a new Tarantula engine
98    #[must_use]
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Record a test execution
104    ///
105    /// # Arguments
106    /// * `file` - Source file path
107    /// * `line` - Line number
108    /// * `passed` - Whether the test passed
109    pub fn record_execution(&mut self, file: &str, line: usize, passed: bool) {
110        let file_coverage = self.coverage.entry(file.to_string()).or_default();
111        let line_coverage = file_coverage.entry(line).or_default();
112
113        if passed {
114            line_coverage.passed_executions += 1;
115        } else {
116            line_coverage.failed_executions += 1;
117        }
118    }
119
120    /// Record a complete test run
121    pub fn record_test_run(&mut self, passed: bool) {
122        if passed {
123            self.total_passed += 1;
124        } else {
125            self.total_failed += 1;
126        }
127    }
128
129    /// Calculate suspiciousness score for a line
130    ///
131    /// Returns a value between 0.0 (not suspicious) and 1.0 (highly suspicious).
132    fn calculate_suspiciousness(&self, line: &LineCoverage) -> f64 {
133        if self.total_failed == 0 || self.total_passed == 0 {
134            return 0.0;
135        }
136
137        let failed_ratio = line.failed_executions as f64 / self.total_failed as f64;
138        let passed_ratio = line.passed_executions as f64 / self.total_passed as f64;
139
140        if failed_ratio + passed_ratio == 0.0 {
141            return 0.0;
142        }
143
144        failed_ratio / (failed_ratio + passed_ratio)
145    }
146
147    /// Generate report for a specific file
148    #[must_use]
149    pub fn report_for_file(&self, file: &str) -> Option<TarantulaReport> {
150        let file_coverage = self.coverage.get(file)?;
151
152        let mut line_scores = HashMap::new();
153        for (&line, coverage) in file_coverage {
154            let score = self.calculate_suspiciousness(coverage);
155            if score > 0.0 {
156                line_scores.insert(line, score);
157            }
158        }
159
160        Some(TarantulaReport {
161            file: file.to_string(),
162            line_scores,
163            total_passed: self.total_passed,
164            total_failed: self.total_failed,
165        })
166    }
167
168    /// Generate reports for all files with suspicious lines
169    #[must_use]
170    pub fn generate_all_reports(&self) -> Vec<TarantulaReport> {
171        self.coverage
172            .keys()
173            .filter_map(|file| self.report_for_file(file))
174            .filter(|r| !r.line_scores.is_empty())
175            .collect()
176    }
177
178    /// Parse LCOV coverage file
179    ///
180    /// # Errors
181    /// Returns error if file cannot be read or parsed
182    pub fn parse_lcov(&mut self, path: &Path, passed: bool) -> Result<(), String> {
183        let content =
184            std::fs::read_to_string(path).map_err(|e| format!("Failed to read LCOV: {e}"))?;
185
186        let mut current_file: Option<String> = None;
187
188        for line in content.lines() {
189            if let Some(file) = line.strip_prefix("SF:") {
190                current_file = Some(file.to_string());
191            } else if let Some(da) = line.strip_prefix("DA:") {
192                if let Some(ref file) = current_file {
193                    // Format: DA:line,execution_count
194                    let parts: Vec<_> = da.split(',').collect();
195                    if parts.len() >= 2 {
196                        if let Ok(line_num) = parts[0].parse::<usize>() {
197                            if let Ok(exec_count) = parts[1].parse::<usize>() {
198                                if exec_count > 0 {
199                                    self.record_execution(file, line_num, passed);
200                                }
201                            }
202                        }
203                    }
204                }
205            } else if line == "end_of_record" {
206                current_file = None;
207            }
208        }
209
210        self.record_test_run(passed);
211        Ok(())
212    }
213
214    /// Filter lines involving Rc or RefCell state updates
215    ///
216    /// Returns only lines that contain Rc/RefCell patterns, which are
217    /// relevant for WASM state sync debugging.
218    pub fn filter_state_related<'a>(&self, source: &'a str) -> HashMap<usize, &'a str> {
219        let patterns = ["Rc::", "RefCell", "borrow", "borrow_mut", "state"];
220        let mut result = HashMap::new();
221
222        for (idx, line) in source.lines().enumerate() {
223            let line_num = idx + 1;
224            if patterns.iter().any(|&p| line.contains(p)) {
225                result.insert(line_num, line);
226            }
227        }
228
229        result
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_suspiciousness_calculation() {
239        let mut engine = TarantulaEngine::new();
240
241        // Line executed in both passing and failing tests
242        engine.record_execution("test.rs", 10, true);
243        engine.record_execution("test.rs", 10, true);
244        engine.record_execution("test.rs", 10, false);
245
246        // Line executed only in failing tests
247        engine.record_execution("test.rs", 20, false);
248
249        // Record test runs
250        engine.record_test_run(true);
251        engine.record_test_run(true);
252        engine.record_test_run(false);
253
254        let report = engine.report_for_file("test.rs").unwrap();
255
256        // Line 20 (only failed) should be more suspicious than line 10 (mixed)
257        let score_10 = report.line_scores.get(&10).copied().unwrap_or(0.0);
258        let score_20 = report.line_scores.get(&20).copied().unwrap_or(0.0);
259
260        assert!(
261            score_20 > score_10,
262            "Line only in fails should be more suspicious"
263        );
264        assert!(
265            score_20 > 0.5,
266            "Line only in fails should be highly suspicious"
267        );
268    }
269
270    #[test]
271    fn test_top_suspicious() {
272        let mut report = TarantulaReport {
273            file: "test.rs".to_string(),
274            line_scores: HashMap::new(),
275            total_passed: 10,
276            total_failed: 5,
277        };
278
279        report.line_scores.insert(10, 0.3);
280        report.line_scores.insert(20, 0.9);
281        report.line_scores.insert(30, 0.6);
282
283        let top = report.top_suspicious(2);
284        assert_eq!(top.len(), 2);
285        assert_eq!(top[0].0, 20); // Most suspicious first
286        assert_eq!(top[1].0, 30);
287    }
288
289    #[test]
290    fn test_filter_state_related() {
291        let engine = TarantulaEngine::new();
292
293        let source = r#"
294let x = 5;
295let state = Rc::new(RefCell::new(0));
296*state.borrow_mut() = 10;
297println!("hello");
298"#;
299
300        let filtered = engine.filter_state_related(source);
301
302        assert!(filtered.contains_key(&3)); // Rc::new line
303        assert!(filtered.contains_key(&4)); // borrow_mut line
304        assert!(!filtered.contains_key(&2)); // let x = 5
305        assert!(!filtered.contains_key(&5)); // println
306    }
307
308    #[test]
309    fn test_format_hotspot_report_high_suspiciousness() {
310        let mut report = TarantulaReport {
311            file: "suspicious.rs".to_string(),
312            line_scores: HashMap::new(),
313            total_passed: 5,
314            total_failed: 3,
315        };
316
317        // Add HIGH suspiciousness lines (> 0.8)
318        report.line_scores.insert(10, 0.95);
319        report.line_scores.insert(20, 0.85);
320
321        let output = report.format_hotspot_report();
322
323        assert!(output.contains("🎯 Tarantula Hotspot Report: suspicious.rs"));
324        assert!(output.contains("5 passed, 3 failed"));
325        assert!(output.contains("🔴 HIGH"));
326    }
327
328    #[test]
329    fn test_format_hotspot_report_medium_suspiciousness() {
330        let mut report = TarantulaReport {
331            file: "medium.rs".to_string(),
332            line_scores: HashMap::new(),
333            total_passed: 10,
334            total_failed: 2,
335        };
336
337        // Add MEDIUM suspiciousness lines (> 0.5 but <= 0.8)
338        report.line_scores.insert(15, 0.65);
339        report.line_scores.insert(25, 0.55);
340
341        let output = report.format_hotspot_report();
342
343        assert!(output.contains("🟡 MEDIUM"));
344    }
345
346    #[test]
347    fn test_format_hotspot_report_low_suspiciousness() {
348        let mut report = TarantulaReport {
349            file: "low.rs".to_string(),
350            line_scores: HashMap::new(),
351            total_passed: 20,
352            total_failed: 1,
353        };
354
355        // Add LOW suspiciousness lines (<= 0.5)
356        report.line_scores.insert(30, 0.3);
357        report.line_scores.insert(40, 0.1);
358
359        let output = report.format_hotspot_report();
360
361        assert!(output.contains("🟢 LOW"));
362    }
363
364    #[test]
365    fn test_format_hotspot_report_all_levels() {
366        let mut report = TarantulaReport {
367            file: "mixed.rs".to_string(),
368            line_scores: HashMap::new(),
369            total_passed: 8,
370            total_failed: 4,
371        };
372
373        // Mix of all suspiciousness levels
374        report.line_scores.insert(100, 0.95); // HIGH
375        report.line_scores.insert(200, 0.65); // MEDIUM
376        report.line_scores.insert(300, 0.25); // LOW
377
378        let output = report.format_hotspot_report();
379
380        assert!(output.contains("🔴 HIGH"));
381        assert!(output.contains("🟡 MEDIUM"));
382        assert!(output.contains("🟢 LOW"));
383        assert!(output.contains("Line  | Suspiciousness | Status"));
384    }
385
386    #[test]
387    fn test_calculate_suspiciousness_no_failed_tests() {
388        let mut engine = TarantulaEngine::new();
389
390        // Only passing tests
391        engine.record_execution("test.rs", 10, true);
392        engine.record_test_run(true);
393        engine.record_test_run(true);
394
395        // With no failed tests, suspiciousness should be 0
396        let report = engine.report_for_file("test.rs");
397        assert!(report.is_none() || report.unwrap().line_scores.is_empty());
398    }
399
400    #[test]
401    fn test_calculate_suspiciousness_no_passed_tests() {
402        let mut engine = TarantulaEngine::new();
403
404        // Only failing tests
405        engine.record_execution("test.rs", 10, false);
406        engine.record_test_run(false);
407        engine.record_test_run(false);
408
409        // With no passed tests, suspiciousness should be 0
410        let report = engine.report_for_file("test.rs");
411        assert!(report.is_none() || report.unwrap().line_scores.is_empty());
412    }
413
414    #[test]
415    fn test_calculate_suspiciousness_zero_ratio_sum() {
416        let mut engine = TarantulaEngine::new();
417
418        // Record tests but no executions for specific lines
419        engine.record_test_run(true);
420        engine.record_test_run(false);
421
422        // Line with no executions should have 0 suspiciousness
423        let line = LineCoverage {
424            passed_executions: 0,
425            failed_executions: 0,
426        };
427        let score = engine.calculate_suspiciousness(&line);
428        assert_eq!(score, 0.0);
429    }
430
431    #[test]
432    fn test_parse_lcov_valid_content() {
433        use std::io::Write;
434
435        let mut engine = TarantulaEngine::new();
436
437        // Create temp LCOV file
438        let lcov_content = r#"SF:src/main.rs
439DA:1,5
440DA:2,10
441DA:3,0
442end_of_record
443SF:src/lib.rs
444DA:10,3
445DA:20,7
446end_of_record
447"#;
448
449        let temp_dir = std::env::temp_dir();
450        let lcov_path = temp_dir.join("test_tarantula.lcov");
451        let mut file = std::fs::File::create(&lcov_path).unwrap();
452        file.write_all(lcov_content.as_bytes()).unwrap();
453
454        // Parse as passing test
455        let result = engine.parse_lcov(&lcov_path, true);
456        assert!(result.is_ok());
457
458        // Parse again as failing test
459        let result = engine.parse_lcov(&lcov_path, false);
460        assert!(result.is_ok());
461
462        // Check that files were recorded
463        let report_main = engine.report_for_file("src/main.rs");
464        let report_lib = engine.report_for_file("src/lib.rs");
465
466        assert!(report_main.is_some());
467        assert!(report_lib.is_some());
468
469        // Clean up
470        let _ = std::fs::remove_file(&lcov_path);
471    }
472
473    #[test]
474    fn test_parse_lcov_nonexistent_file() {
475        let mut engine = TarantulaEngine::new();
476
477        let result = engine.parse_lcov(Path::new("/nonexistent/path.lcov"), true);
478        assert!(result.is_err());
479        assert!(result.unwrap_err().contains("Failed to read LCOV"));
480    }
481
482    #[test]
483    fn test_parse_lcov_malformed_da_lines() {
484        use std::io::Write;
485
486        let mut engine = TarantulaEngine::new();
487
488        // LCOV with malformed DA lines
489        let lcov_content = r#"SF:src/test.rs
490DA:invalid,5
491DA:1,invalid
492DA:,
493DA:1
494end_of_record
495"#;
496
497        let temp_dir = std::env::temp_dir();
498        let lcov_path = temp_dir.join("test_malformed.lcov");
499        let mut file = std::fs::File::create(&lcov_path).unwrap();
500        file.write_all(lcov_content.as_bytes()).unwrap();
501
502        // Should not panic, just skip malformed lines
503        let result = engine.parse_lcov(&lcov_path, true);
504        assert!(result.is_ok());
505
506        let _ = std::fs::remove_file(&lcov_path);
507    }
508
509    #[test]
510    fn test_generate_all_reports() {
511        let mut engine = TarantulaEngine::new();
512
513        // Add coverage for multiple files
514        engine.record_execution("file1.rs", 10, true);
515        engine.record_execution("file1.rs", 10, false);
516        engine.record_execution("file2.rs", 20, false);
517        engine.record_execution("file3.rs", 30, true); // Only passing
518
519        engine.record_test_run(true);
520        engine.record_test_run(false);
521
522        let reports = engine.generate_all_reports();
523
524        // Should have reports for files with suspicious lines
525        // file3.rs has only passing executions so score would be 0
526        assert!(!reports.is_empty());
527
528        // Verify file names are in reports
529        let file_names: Vec<_> = reports.iter().map(|r| r.file.as_str()).collect();
530        assert!(file_names.contains(&"file1.rs"));
531        assert!(file_names.contains(&"file2.rs"));
532    }
533
534    #[test]
535    fn test_line_coverage_default() {
536        let coverage = LineCoverage::default();
537        assert_eq!(coverage.passed_executions, 0);
538        assert_eq!(coverage.failed_executions, 0);
539    }
540
541    #[test]
542    fn test_tarantula_report_default() {
543        let report = TarantulaReport::default();
544        assert!(report.file.is_empty());
545        assert!(report.line_scores.is_empty());
546        assert_eq!(report.total_passed, 0);
547        assert_eq!(report.total_failed, 0);
548    }
549
550    #[test]
551    fn test_top_suspicious_empty_scores() {
552        let report = TarantulaReport::default();
553        let top = report.top_suspicious(5);
554        assert!(top.is_empty());
555    }
556
557    #[test]
558    fn test_top_suspicious_fewer_than_n() {
559        let mut report = TarantulaReport {
560            file: "test.rs".to_string(),
561            line_scores: HashMap::new(),
562            total_passed: 5,
563            total_failed: 2,
564        };
565
566        report.line_scores.insert(1, 0.5);
567        report.line_scores.insert(2, 0.8);
568
569        // Request more than available
570        let top = report.top_suspicious(10);
571        assert_eq!(top.len(), 2);
572    }
573
574    #[test]
575    fn test_report_for_nonexistent_file() {
576        let engine = TarantulaEngine::new();
577        assert!(engine.report_for_file("nonexistent.rs").is_none());
578    }
579
580    #[test]
581    fn test_format_hotspot_report_line_formatting() {
582        let mut report = TarantulaReport {
583            file: "format_test.rs".to_string(),
584            line_scores: HashMap::new(),
585            total_passed: 3,
586            total_failed: 2,
587        };
588
589        // Line number 12345 to test formatting width
590        report.line_scores.insert(12345, 0.567);
591
592        let output = report.format_hotspot_report();
593
594        // Verify header line
595        assert!(output.contains("Line  | Suspiciousness | Status"));
596        assert!(output.contains("------|----------------|--------"));
597        // Verify score is formatted with 3 decimal places
598        assert!(output.contains("0.567"));
599    }
600}