1use std::collections::HashMap;
23use std::path::Path;
24
25#[derive(Debug, Default, Clone)]
27pub struct LineCoverage {
28 pub passed_executions: usize,
30 pub failed_executions: usize,
32}
33
34#[derive(Debug, Default)]
36pub struct TarantulaReport {
37 pub file: String,
39 pub line_scores: HashMap<usize, f64>,
41 pub total_passed: usize,
43 pub total_failed: usize,
45}
46
47impl TarantulaReport {
48 #[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 #[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#[derive(Debug, Default)]
87pub struct TarantulaEngine {
88 coverage: HashMap<String, HashMap<usize, LineCoverage>>,
90 total_passed: usize,
92 total_failed: usize,
94}
95
96impl TarantulaEngine {
97 #[must_use]
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 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 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 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 #[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 #[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 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 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 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 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 engine.record_execution("test.rs", 20, false);
248
249 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 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); 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)); assert!(filtered.contains_key(&4)); assert!(!filtered.contains_key(&2)); assert!(!filtered.contains_key(&5)); }
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 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 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 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 report.line_scores.insert(100, 0.95); report.line_scores.insert(200, 0.65); report.line_scores.insert(300, 0.25); 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 engine.record_execution("test.rs", 10, true);
392 engine.record_test_run(true);
393 engine.record_test_run(true);
394
395 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 engine.record_execution("test.rs", 10, false);
406 engine.record_test_run(false);
407 engine.record_test_run(false);
408
409 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 engine.record_test_run(true);
420 engine.record_test_run(false);
421
422 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 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 let result = engine.parse_lcov(&lcov_path, true);
456 assert!(result.is_ok());
457
458 let result = engine.parse_lcov(&lcov_path, false);
460 assert!(result.is_ok());
461
462 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 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 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 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 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); engine.record_test_run(true);
520 engine.record_test_run(false);
521
522 let reports = engine.generate_all_reports();
523
524 assert!(!reports.is_empty());
527
528 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 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 report.line_scores.insert(12345, 0.567);
591
592 let output = report.format_hotspot_report();
593
594 assert!(output.contains("Line | Suspiciousness | Status"));
596 assert!(output.contains("------|----------------|--------"));
597 assert!(output.contains("0.567"));
599 }
600}