Skip to main content

tldr_cli/commands/bugbot/
types.rs

1//! Types for bugbot analysis reports
2//!
3//! All types derive Serialize and Deserialize for JSON output compatibility
4//! with the OutputWriter system.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9use std::path::PathBuf;
10
11use super::tools::ToolResult;
12
13/// Exit status from bugbot check, used to propagate exit codes without
14/// calling `process::exit` (which skips Drop destructors and is untestable).
15#[derive(Debug)]
16pub enum BugbotExitError {
17    /// Findings were detected and `--no-fail` was not set.
18    FindingsDetected {
19        /// Number of findings in the report.
20        count: usize,
21    },
22    /// Critical findings detected — highest priority, exit code 3.
23    CriticalFindings {
24        /// Number of critical findings in the report.
25        count: usize,
26    },
27    /// Analysis pipeline encountered errors but produced no findings.
28    /// A broken pipeline should not report "clean."
29    AnalysisErrors {
30        /// Number of non-fatal errors encountered.
31        count: usize,
32    },
33}
34
35impl BugbotExitError {
36    /// Return the process exit code for this error.
37    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/// Top-level report output from bugbot check
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct BugbotCheckReport {
67    /// Always "bugbot"
68    pub tool: String,
69    /// Always "check"
70    pub mode: String,
71    /// Language detected or specified
72    pub language: String,
73    /// Git base reference (e.g. "HEAD", "main")
74    pub base_ref: String,
75    /// How changes were detected (e.g. "git:uncommitted", "git:staged")
76    pub detection_method: String,
77    /// ISO 8601 timestamp
78    pub timestamp: String,
79    /// Files that had changes
80    pub changed_files: Vec<PathBuf>,
81    /// The actual findings
82    pub findings: Vec<BugbotFinding>,
83    /// Summary statistics
84    pub summary: BugbotSummary,
85    /// Pipeline timing in milliseconds
86    pub elapsed_ms: u64,
87    /// Non-fatal errors encountered
88    pub errors: Vec<String>,
89    /// Informational notes (e.g. "stub_implementation", "no_changes_detected", "truncated_to_50")
90    pub notes: Vec<String>,
91    /// Tool execution results (L1)
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub tool_results: Vec<ToolResult>,
94    /// Tools that were available to run
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub tools_available: Vec<String>,
97    /// Tools that were not found
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub tools_missing: Vec<String>,
100    /// L2 engine execution results
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub l2_engine_results: Vec<L2AnalyzerResult>,
103}
104
105/// A single finding from bugbot analysis
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct BugbotFinding {
108    /// e.g. "signature-regression", "born-dead"
109    pub finding_type: String,
110    /// "high", "medium", "low"
111    pub severity: String,
112    /// File path (relative to project root)
113    pub file: PathBuf,
114    /// Function/method name
115    pub function: String,
116    /// Line number in current file
117    pub line: usize,
118    /// Human-readable description
119    pub message: String,
120    /// Type-specific evidence
121    pub evidence: serde_json::Value,
122    /// Confidence level (L2/L3 only). L1 findings leave this as None.
123    /// Values: "CONFIRMED", "LIKELY", "POSSIBLE", "FALSE_POSITIVE"
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub confidence: Option<String>,
126    /// Deterministic finding ID for cross-run tracking.
127    /// Hash of (finding_type, file, function, line).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub finding_id: Option<String>,
130}
131
132/// Per-engine execution result for the report.
133/// Mirrors ToolResult for L1 tools, providing identical observability.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct L2AnalyzerResult {
136    /// Engine name (e.g. "DeltaEngine", "FlowEngine")
137    pub name: String,
138    /// Whether the engine completed fully
139    pub success: bool,
140    /// Execution time in milliseconds
141    pub duration_ms: u64,
142    /// Number of findings produced
143    pub finding_count: usize,
144    /// Number of functions analyzed
145    pub functions_analyzed: usize,
146    /// Number of functions skipped
147    pub functions_skipped: usize,
148    /// Engine completion status description
149    pub status: String,
150    /// Errors encountered (empty if success)
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub errors: Vec<String>,
153}
154
155/// Summary statistics
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct BugbotSummary {
158    /// Total number of findings
159    pub total_findings: usize,
160    /// Findings grouped by severity
161    pub by_severity: HashMap<String, usize>,
162    /// Findings grouped by finding type
163    pub by_type: HashMap<String, usize>,
164    /// Number of files analyzed
165    pub files_analyzed: usize,
166    /// Number of functions analyzed
167    pub functions_analyzed: usize,
168    /// L1 tool-based findings count
169    #[serde(default)]
170    pub l1_findings: usize,
171    /// L2 AST-based findings count
172    #[serde(default)]
173    pub l2_findings: usize,
174    /// Number of tools that ran
175    #[serde(default)]
176    pub tools_run: usize,
177    /// Number of tools that failed
178    #[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        // Verify the type implements std::error::Error (for anyhow compatibility)
231        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        // Verify it can be converted into anyhow::Error and downcast back
238        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        // Old JSON without tool_results, tools_available, tools_missing
246        // should deserialize with those fields defaulting to empty vecs
247        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        // Old summary JSON without l1_findings, l2_findings, tools_run, tools_failed
277        // should deserialize with those fields defaulting to 0
278        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        // Test that confidence serializes when present
299        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        // Test that None confidence is omitted
317        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        // Old JSON without confidence/finding_id should deserialize with None
336        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")); // empty vec skipped
365
366        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        // Old JSON without l2_engine_results should deserialize with empty vec
384        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    // -------------------------------------------------------------------
409    // JSON output integration tests (Phase 8.7)
410    // -------------------------------------------------------------------
411
412    /// Helper: build a minimal valid BugbotCheckReport for testing.
413    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        // Verify that l2_engine_results appears as a top-level array in
450        // the serialized JSON when populated.
451        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        // l2_engine_results must be a top-level key
480        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        // Verify each L2AnalyzerResult entry has exactly the fields from the spec:
492        //   name, success, duration_ms, finding_count, functions_analyzed, functions_skipped
493        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        // All spec-required fields present with correct values
511        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        // status is also serialized (implementation detail beyond spec minimum)
519        assert_eq!(entry["status"], "complete");
520
521        // errors should be absent when empty (skip_serializing_if)
522        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        // Verify that errors array appears when non-empty
531        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        // Verify that l2_engine_results is omitted from JSON when the vec is empty
557        // (skip_serializing_if = "Vec::is_empty")
558        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        // Verify that confidence and finding_id appear in the finding JSON
570        // when set (top-level on finding, not inside evidence)
571        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        // confidence is top-level on finding (not inside evidence)
593        assert_eq!(finding_json["confidence"], "POSSIBLE");
594        assert_eq!(finding_json["finding_id"], "a3f8c2d1");
595
596        // Verify it's NOT nested inside evidence
597        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        // Verify that None confidence/finding_id are omitted from JSON
610        // (skip_serializing_if = "Option::is_none")
611        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        // End-to-end: build a report matching the spec example and verify
640        // the JSON shape matches the spec at spec.md lines 2109-2143.
641        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        // Top-level report structure
673        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        // Finding shape matches spec
679        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        // L2 engine result shape matches spec
698        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        // Verify serialize -> deserialize roundtrip preserves all L2 fields
710        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        // L2 engine results roundtrip
750        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        // Finding roundtrip with confidence and finding_id
763        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        // Verify all defined confidence levels serialize correctly
777        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        // finding_id should serialize as a JSON string (hex hash), never as a number
804        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}