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