1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::path::PathBuf;
10
11use super::tools::ToolResult;
12
13#[derive(Debug)]
16pub enum BugbotExitError {
17 FindingsDetected {
19 count: usize,
21 },
22 CriticalFindings {
24 count: usize,
26 },
27 AnalysisErrors {
30 count: usize,
32 },
33}
34
35impl BugbotExitError {
36 pub fn exit_code(&self) -> u8 {
38 match self {
39 Self::FindingsDetected { .. } => 1,
40 Self::AnalysisErrors { .. } => 2,
41 Self::CriticalFindings { .. } => 3,
42 }
43 }
44}
45
46impl fmt::Display for BugbotExitError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::FindingsDetected { count } => {
50 write!(f, "bugbot: {} finding(s) detected", count)
51 }
52 Self::CriticalFindings { count } => {
53 write!(f, "bugbot: {} CRITICAL finding(s) detected", count)
54 }
55 Self::AnalysisErrors { count } => {
56 write!(
57 f,
58 "bugbot: analysis had {} error(s) with no findings",
59 count
60 )
61 }
62 }
63 }
64}
65
66impl std::error::Error for BugbotExitError {}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct BugbotCheckReport {
71 pub tool: String,
73 pub mode: String,
75 pub language: String,
77 pub base_ref: String,
79 pub detection_method: String,
81 pub timestamp: String,
83 pub changed_files: Vec<PathBuf>,
85 pub findings: Vec<BugbotFinding>,
87 pub summary: BugbotSummary,
89 pub elapsed_ms: u64,
91 pub errors: Vec<String>,
93 pub notes: Vec<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub tool_results: Vec<ToolResult>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub tools_available: Vec<String>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub tools_missing: Vec<String>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub l2_engine_results: Vec<L2AnalyzerResult>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct BugbotFinding {
112 pub finding_type: String,
114 pub severity: String,
116 pub file: PathBuf,
118 pub function: String,
120 pub line: usize,
122 pub message: String,
124 pub evidence: serde_json::Value,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub confidence: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub finding_id: Option<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct L2AnalyzerResult {
140 pub name: String,
142 pub success: bool,
144 pub duration_ms: u64,
146 pub finding_count: usize,
148 pub functions_analyzed: usize,
150 pub functions_skipped: usize,
152 pub status: String,
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 pub errors: Vec<String>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct BugbotSummary {
162 pub total_findings: usize,
164 pub by_severity: HashMap<String, usize>,
166 pub by_type: HashMap<String, usize>,
168 pub files_analyzed: usize,
170 pub functions_analyzed: usize,
172 #[serde(default)]
174 pub l1_findings: usize,
175 #[serde(default)]
177 pub l2_findings: usize,
178 #[serde(default)]
180 pub tools_run: usize,
181 #[serde(default)]
183 pub tools_failed: usize,
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn test_bugbot_exit_error_findings_detected_exit_code() {
192 let err = BugbotExitError::FindingsDetected { count: 3 };
193 assert_eq!(err.exit_code(), 1);
194 }
195
196 #[test]
197 fn test_bugbot_exit_error_analysis_errors_exit_code() {
198 let err = BugbotExitError::AnalysisErrors { count: 2 };
199 assert_eq!(err.exit_code(), 2);
200 }
201
202 #[test]
203 fn test_bugbot_exit_error_critical_exit_code() {
204 let err = BugbotExitError::CriticalFindings { count: 1 };
205 assert_eq!(err.exit_code(), 3);
206 }
207
208 #[test]
209 fn test_bugbot_exit_error_critical_display() {
210 let err = BugbotExitError::CriticalFindings { count: 2 };
211 let msg = format!("{}", err);
212 assert!(msg.contains("CRITICAL"));
213 assert!(msg.contains("2"));
214 }
215
216 #[test]
217 fn test_bugbot_exit_error_findings_detected_display() {
218 let err = BugbotExitError::FindingsDetected { count: 5 };
219 let msg = format!("{}", err);
220 assert!(msg.contains("5"), "should contain count");
221 assert!(msg.contains("finding"), "should describe findings");
222 }
223
224 #[test]
225 fn test_bugbot_exit_error_analysis_errors_display() {
226 let err = BugbotExitError::AnalysisErrors { count: 1 };
227 let msg = format!("{}", err);
228 assert!(msg.contains("1"), "should contain count");
229 assert!(msg.contains("error"), "should describe errors");
230 }
231
232 #[test]
233 fn test_bugbot_exit_error_is_std_error() {
234 let err = BugbotExitError::FindingsDetected { count: 1 };
236 let _: &dyn std::error::Error = &err;
237 }
238
239 #[test]
240 fn test_bugbot_exit_error_into_anyhow() {
241 let err: anyhow::Error = BugbotExitError::FindingsDetected { count: 7 }.into();
243 let downcast = err.downcast_ref::<BugbotExitError>().unwrap();
244 assert_eq!(downcast.exit_code(), 1);
245 }
246
247 #[test]
248 fn test_report_backward_compat_no_tool_fields() {
249 let json = r#"{
252 "tool": "bugbot",
253 "mode": "check",
254 "language": "rust",
255 "base_ref": "HEAD",
256 "detection_method": "git:uncommitted",
257 "timestamp": "2026-02-27T00:00:00Z",
258 "changed_files": [],
259 "findings": [],
260 "summary": {
261 "total_findings": 0,
262 "by_severity": {},
263 "by_type": {},
264 "files_analyzed": 0,
265 "functions_analyzed": 0
266 },
267 "elapsed_ms": 100,
268 "errors": [],
269 "notes": []
270 }"#;
271
272 let report: BugbotCheckReport = serde_json::from_str(json).unwrap();
273 assert!(report.tool_results.is_empty());
274 assert!(report.tools_available.is_empty());
275 assert!(report.tools_missing.is_empty());
276 }
277
278 #[test]
279 fn test_summary_backward_compat() {
280 let json = r#"{
283 "total_findings": 5,
284 "by_severity": {"high": 2, "low": 3},
285 "by_type": {"signature-regression": 2, "born-dead": 3},
286 "files_analyzed": 10,
287 "functions_analyzed": 42
288 }"#;
289
290 let summary: BugbotSummary = serde_json::from_str(json).unwrap();
291 assert_eq!(summary.total_findings, 5);
292 assert_eq!(summary.files_analyzed, 10);
293 assert_eq!(summary.functions_analyzed, 42);
294 assert_eq!(summary.l1_findings, 0);
295 assert_eq!(summary.l2_findings, 0);
296 assert_eq!(summary.tools_run, 0);
297 assert_eq!(summary.tools_failed, 0);
298 }
299
300 #[test]
301 fn test_bugbot_finding_confidence_serde() {
302 let finding = BugbotFinding {
304 finding_type: "test".to_string(),
305 severity: "high".to_string(),
306 file: PathBuf::from("test.rs"),
307 function: "foo".to_string(),
308 line: 1,
309 message: "test".to_string(),
310 evidence: serde_json::json!({}),
311 confidence: Some("LIKELY".to_string()),
312 finding_id: Some("abc123".to_string()),
313 };
314 let json = serde_json::to_string(&finding).unwrap();
315 assert!(json.contains("confidence"));
316 assert!(json.contains("LIKELY"));
317 assert!(json.contains("finding_id"));
318 assert!(json.contains("abc123"));
319
320 let finding_no_conf = BugbotFinding {
322 finding_type: "test".to_string(),
323 severity: "high".to_string(),
324 file: PathBuf::from("test.rs"),
325 function: "foo".to_string(),
326 line: 1,
327 message: "test".to_string(),
328 evidence: serde_json::json!({}),
329 confidence: None,
330 finding_id: None,
331 };
332 let json2 = serde_json::to_string(&finding_no_conf).unwrap();
333 assert!(!json2.contains("confidence"));
334 assert!(!json2.contains("finding_id"));
335 }
336
337 #[test]
338 fn test_bugbot_finding_backward_compat_deserialize() {
339 let json = r#"{
341 "finding_type": "test",
342 "severity": "high",
343 "file": "test.rs",
344 "function": "foo",
345 "line": 1,
346 "message": "test",
347 "evidence": {}
348 }"#;
349 let finding: BugbotFinding = serde_json::from_str(json).unwrap();
350 assert!(finding.confidence.is_none());
351 assert!(finding.finding_id.is_none());
352 }
353
354 #[test]
355 fn test_l2_analyzer_result_serde() {
356 let result = L2AnalyzerResult {
357 name: "FlowEngine".to_string(),
358 success: true,
359 duration_ms: 42,
360 finding_count: 3,
361 functions_analyzed: 10,
362 functions_skipped: 2,
363 status: "Complete".to_string(),
364 errors: vec![],
365 };
366 let json = serde_json::to_string(&result).unwrap();
367 assert!(json.contains("FlowEngine"));
368 assert!(!json.contains("errors")); let result_with_errors = L2AnalyzerResult {
371 name: "DeltaEngine".to_string(),
372 success: false,
373 duration_ms: 100,
374 finding_count: 0,
375 functions_analyzed: 5,
376 functions_skipped: 5,
377 status: "Partial".to_string(),
378 errors: vec!["timeout on foo()".to_string()],
379 };
380 let json2 = serde_json::to_string(&result_with_errors).unwrap();
381 assert!(json2.contains("errors"));
382 assert!(json2.contains("timeout on foo()"));
383 }
384
385 #[test]
386 fn test_report_backward_compat_no_l2_engine_results() {
387 let json = r#"{
389 "tool": "bugbot",
390 "mode": "check",
391 "language": "rust",
392 "base_ref": "HEAD",
393 "detection_method": "git:uncommitted",
394 "timestamp": "2026-02-27T00:00:00Z",
395 "changed_files": [],
396 "findings": [],
397 "summary": {
398 "total_findings": 0,
399 "by_severity": {},
400 "by_type": {},
401 "files_analyzed": 0,
402 "functions_analyzed": 0
403 },
404 "elapsed_ms": 100,
405 "errors": [],
406 "notes": []
407 }"#;
408 let report: BugbotCheckReport = serde_json::from_str(json).unwrap();
409 assert!(report.l2_engine_results.is_empty());
410 }
411
412 fn make_test_report(
418 findings: Vec<BugbotFinding>,
419 l2_engine_results: Vec<L2AnalyzerResult>,
420 ) -> BugbotCheckReport {
421 BugbotCheckReport {
422 tool: "bugbot".to_string(),
423 mode: "check".to_string(),
424 language: "rust".to_string(),
425 base_ref: "HEAD".to_string(),
426 detection_method: "git:uncommitted".to_string(),
427 timestamp: "2026-03-02T00:00:00Z".to_string(),
428 changed_files: vec![PathBuf::from("src/api.rs")],
429 findings,
430 summary: BugbotSummary {
431 total_findings: 0,
432 by_severity: HashMap::new(),
433 by_type: HashMap::new(),
434 files_analyzed: 1,
435 functions_analyzed: 5,
436 l1_findings: 0,
437 l2_findings: 0,
438 tools_run: 0,
439 tools_failed: 0,
440 },
441 elapsed_ms: 500,
442 errors: Vec::new(),
443 notes: Vec::new(),
444 tool_results: Vec::new(),
445 tools_available: Vec::new(),
446 tools_missing: Vec::new(),
447 l2_engine_results,
448 }
449 }
450
451 #[test]
452 fn test_json_output_l2_engine_results_present_in_full_report() {
453 let report = make_test_report(
456 vec![],
457 vec![
458 L2AnalyzerResult {
459 name: "FlowEngine".to_string(),
460 success: true,
461 duration_ms: 1203,
462 finding_count: 4,
463 functions_analyzed: 48,
464 functions_skipped: 2,
465 status: "complete".to_string(),
466 errors: vec![],
467 },
468 L2AnalyzerResult {
469 name: "DeltaEngine".to_string(),
470 success: true,
471 duration_ms: 350,
472 finding_count: 1,
473 functions_analyzed: 20,
474 functions_skipped: 0,
475 status: "complete".to_string(),
476 errors: vec![],
477 },
478 ],
479 );
480
481 let json_val = serde_json::to_value(&report).unwrap();
482
483 assert!(
485 json_val.get("l2_engine_results").is_some(),
486 "l2_engine_results must be present in report JSON"
487 );
488
489 let l2_arr = json_val["l2_engine_results"].as_array().unwrap();
490 assert_eq!(l2_arr.len(), 2, "should have 2 engine results");
491 }
492
493 #[test]
494 fn test_json_output_l2_engine_result_fields_match_spec() {
495 let report = make_test_report(
498 vec![],
499 vec![L2AnalyzerResult {
500 name: "FlowEngine".to_string(),
501 success: true,
502 duration_ms: 1203,
503 finding_count: 4,
504 functions_analyzed: 48,
505 functions_skipped: 2,
506 status: "complete".to_string(),
507 errors: vec![],
508 }],
509 );
510
511 let json_val = serde_json::to_value(&report).unwrap();
512 let entry = &json_val["l2_engine_results"][0];
513
514 assert_eq!(entry["name"], "FlowEngine");
516 assert_eq!(entry["success"], serde_json::Value::Bool(true));
517 assert_eq!(entry["duration_ms"], 1203);
518 assert_eq!(entry["finding_count"], 4);
519 assert_eq!(entry["functions_analyzed"], 48);
520 assert_eq!(entry["functions_skipped"], 2);
521
522 assert_eq!(entry["status"], "complete");
524
525 assert!(
527 entry.get("errors").is_none(),
528 "empty errors vec should be omitted from JSON"
529 );
530 }
531
532 #[test]
533 fn test_json_output_l2_engine_result_with_errors() {
534 let report = make_test_report(
536 vec![],
537 vec![L2AnalyzerResult {
538 name: "DeltaEngine".to_string(),
539 success: false,
540 duration_ms: 100,
541 finding_count: 0,
542 functions_analyzed: 5,
543 functions_skipped: 5,
544 status: "partial (timeout on complex_fn)".to_string(),
545 errors: vec!["timeout on complex_fn()".to_string()],
546 }],
547 );
548
549 let json_val = serde_json::to_value(&report).unwrap();
550 let entry = &json_val["l2_engine_results"][0];
551
552 assert_eq!(entry["success"], serde_json::Value::Bool(false));
553 let errors = entry["errors"].as_array().unwrap();
554 assert_eq!(errors.len(), 1);
555 assert_eq!(errors[0], "timeout on complex_fn()");
556 }
557
558 #[test]
559 fn test_json_output_l2_engine_results_omitted_when_empty() {
560 let report = make_test_report(vec![], vec![]);
563
564 let json_val = serde_json::to_value(&report).unwrap();
565 assert!(
566 json_val.get("l2_engine_results").is_none(),
567 "l2_engine_results should be omitted when empty"
568 );
569 }
570
571 #[test]
572 fn test_json_output_finding_with_confidence_and_finding_id() {
573 let finding = BugbotFinding {
576 finding_type: "taint-flow".to_string(),
577 severity: "high".to_string(),
578 file: PathBuf::from("src/api.rs"),
579 function: "handle_request".to_string(),
580 line: 42,
581 message: "Taint flow: user_input reaches sql_query without sanitization".to_string(),
582 evidence: serde_json::json!({
583 "source": "user_input",
584 "sink": "sql_query"
585 }),
586 confidence: Some("POSSIBLE".to_string()),
587 finding_id: Some("a3f8c2d1".to_string()),
588 };
589
590 let report = make_test_report(vec![finding], vec![]);
591
592 let json_val = serde_json::to_value(&report).unwrap();
593 let finding_json = &json_val["findings"][0];
594
595 assert_eq!(finding_json["confidence"], "POSSIBLE");
597 assert_eq!(finding_json["finding_id"], "a3f8c2d1");
598
599 assert!(
601 finding_json["evidence"].get("confidence").is_none(),
602 "confidence must be top-level on finding, not inside evidence"
603 );
604 assert!(
605 finding_json["evidence"].get("finding_id").is_none(),
606 "finding_id must be top-level on finding, not inside evidence"
607 );
608 }
609
610 #[test]
611 fn test_json_output_finding_omits_none_confidence_and_finding_id() {
612 let finding = BugbotFinding {
615 finding_type: "signature-regression".to_string(),
616 severity: "medium".to_string(),
617 file: PathBuf::from("src/lib.rs"),
618 function: "process".to_string(),
619 line: 10,
620 message: "Signature changed".to_string(),
621 evidence: serde_json::json!({}),
622 confidence: None,
623 finding_id: None,
624 };
625
626 let report = make_test_report(vec![finding], vec![]);
627 let json_val = serde_json::to_value(&report).unwrap();
628 let finding_json = &json_val["findings"][0];
629
630 assert!(
631 finding_json.get("confidence").is_none(),
632 "None confidence must be omitted from JSON"
633 );
634 assert!(
635 finding_json.get("finding_id").is_none(),
636 "None finding_id must be omitted from JSON"
637 );
638 }
639
640 #[test]
641 fn test_json_output_full_report_matches_spec_shape() {
642 let findings = vec![BugbotFinding {
645 finding_type: "taint-flow".to_string(),
646 severity: "high".to_string(),
647 file: PathBuf::from("src/api.rs"),
648 function: "handle_request".to_string(),
649 line: 42,
650 message: "Taint flow: user_input reaches sql_query without sanitization".to_string(),
651 evidence: serde_json::json!({
652 "source": "user_input",
653 "sink": "sql_query",
654 "hops": 3
655 }),
656 confidence: Some("POSSIBLE".to_string()),
657 finding_id: Some("a3f8c2d1".to_string()),
658 }];
659
660 let l2_results = vec![L2AnalyzerResult {
661 name: "FlowEngine".to_string(),
662 success: true,
663 duration_ms: 1203,
664 finding_count: 4,
665 functions_analyzed: 48,
666 functions_skipped: 2,
667 status: "complete".to_string(),
668 errors: vec![],
669 }];
670
671 let report = make_test_report(findings, l2_results);
672 let json_val = serde_json::to_value(&report).unwrap();
673
674 assert!(json_val.is_object());
676 assert!(json_val.get("findings").unwrap().is_array());
677 assert!(json_val.get("l2_engine_results").unwrap().is_array());
678 assert!(json_val.get("summary").unwrap().is_object());
679
680 let f = &json_val["findings"][0];
682 assert_eq!(f["finding_type"], "taint-flow");
683 assert_eq!(f["severity"], "high");
684 assert_eq!(f["file"], "src/api.rs");
685 assert_eq!(f["function"], "handle_request");
686 assert_eq!(f["line"], 42);
687 assert!(
688 f["message"]
689 .as_str()
690 .unwrap()
691 .to_lowercase()
692 .contains("taint"),
693 "message should mention taint flow"
694 );
695 assert_eq!(f["confidence"], "POSSIBLE");
696 assert_eq!(f["finding_id"], "a3f8c2d1");
697 assert!(f["evidence"].is_object());
698
699 let e = &json_val["l2_engine_results"][0];
701 assert_eq!(e["name"], "FlowEngine");
702 assert_eq!(e["success"], serde_json::Value::Bool(true));
703 assert_eq!(e["duration_ms"], 1203);
704 assert_eq!(e["finding_count"], 4);
705 assert_eq!(e["functions_analyzed"], 48);
706 assert_eq!(e["functions_skipped"], 2);
707 }
708
709 #[test]
710 fn test_json_output_roundtrip_with_l2_engine_results() {
711 let report = make_test_report(
713 vec![BugbotFinding {
714 finding_type: "born-dead".to_string(),
715 severity: "low".to_string(),
716 file: PathBuf::from("src/utils.rs"),
717 function: "unused_helper".to_string(),
718 line: 99,
719 message: "Function is never called".to_string(),
720 evidence: serde_json::json!({}),
721 confidence: Some("CONFIRMED".to_string()),
722 finding_id: Some("deadbeef".to_string()),
723 }],
724 vec![
725 L2AnalyzerResult {
726 name: "FlowEngine".to_string(),
727 success: true,
728 duration_ms: 500,
729 finding_count: 0,
730 functions_analyzed: 30,
731 functions_skipped: 1,
732 status: "complete".to_string(),
733 errors: vec![],
734 },
735 L2AnalyzerResult {
736 name: "ContractEngine".to_string(),
737 success: false,
738 duration_ms: 200,
739 finding_count: 0,
740 functions_analyzed: 10,
741 functions_skipped: 20,
742 status: "partial (unsupported patterns)".to_string(),
743 errors: vec!["unsupported patterns".to_string()],
744 },
745 ],
746 );
747
748 let json_str = serde_json::to_string(&report).unwrap();
749 let deserialized: BugbotCheckReport = serde_json::from_str(&json_str).unwrap();
750
751 assert_eq!(deserialized.l2_engine_results.len(), 2);
753 assert_eq!(deserialized.l2_engine_results[0].name, "FlowEngine");
754 assert!(deserialized.l2_engine_results[0].success);
755 assert_eq!(deserialized.l2_engine_results[0].duration_ms, 500);
756 assert_eq!(deserialized.l2_engine_results[0].finding_count, 0);
757 assert_eq!(deserialized.l2_engine_results[0].functions_analyzed, 30);
758 assert_eq!(deserialized.l2_engine_results[0].functions_skipped, 1);
759
760 assert_eq!(deserialized.l2_engine_results[1].name, "ContractEngine");
761 assert!(!deserialized.l2_engine_results[1].success);
762 assert_eq!(deserialized.l2_engine_results[1].errors.len(), 1);
763
764 assert_eq!(deserialized.findings.len(), 1);
766 assert_eq!(
767 deserialized.findings[0].confidence,
768 Some("CONFIRMED".to_string())
769 );
770 assert_eq!(
771 deserialized.findings[0].finding_id,
772 Some("deadbeef".to_string())
773 );
774 }
775
776 #[test]
777 fn test_json_output_multiple_confidence_levels() {
778 let levels = vec!["CONFIRMED", "LIKELY", "POSSIBLE", "FALSE_POSITIVE"];
780
781 for level in &levels {
782 let finding = BugbotFinding {
783 finding_type: "test".to_string(),
784 severity: "medium".to_string(),
785 file: PathBuf::from("test.rs"),
786 function: "test_fn".to_string(),
787 line: 1,
788 message: "test".to_string(),
789 evidence: serde_json::json!({}),
790 confidence: Some(level.to_string()),
791 finding_id: Some("id123".to_string()),
792 };
793 let json_val = serde_json::to_value(&finding).unwrap();
794 assert_eq!(
795 json_val["confidence"].as_str().unwrap(),
796 *level,
797 "confidence level '{}' should serialize correctly",
798 level
799 );
800 }
801 }
802
803 #[test]
804 fn test_json_output_finding_id_is_string_not_number() {
805 let finding = BugbotFinding {
807 finding_type: "test".to_string(),
808 severity: "low".to_string(),
809 file: PathBuf::from("test.rs"),
810 function: "f".to_string(),
811 line: 1,
812 message: "test".to_string(),
813 evidence: serde_json::json!({}),
814 confidence: None,
815 finding_id: Some("a3f8c2d1".to_string()),
816 };
817
818 let json_val = serde_json::to_value(&finding).unwrap();
819 assert!(
820 json_val["finding_id"].is_string(),
821 "finding_id must serialize as a JSON string"
822 );
823 assert_eq!(json_val["finding_id"].as_str().unwrap(), "a3f8c2d1");
824 }
825}