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!(
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/// Top-level report output from bugbot check
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct BugbotCheckReport {
71    /// Always "bugbot"
72    pub tool: String,
73    /// Always "check"
74    pub mode: String,
75    /// Language detected or specified
76    pub language: String,
77    /// Git base reference (e.g. "HEAD", "main")
78    pub base_ref: String,
79    /// How changes were detected (e.g. "git:uncommitted", "git:staged")
80    pub detection_method: String,
81    /// ISO 8601 timestamp
82    pub timestamp: String,
83    /// Files that had changes
84    pub changed_files: Vec<PathBuf>,
85    /// The actual findings
86    pub findings: Vec<BugbotFinding>,
87    /// Summary statistics
88    pub summary: BugbotSummary,
89    /// Pipeline timing in milliseconds
90    pub elapsed_ms: u64,
91    /// Non-fatal errors encountered
92    pub errors: Vec<String>,
93    /// Informational notes (e.g. "stub_implementation", "no_changes_detected", "truncated_to_50")
94    pub notes: Vec<String>,
95    /// Tool execution results (L1)
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub tool_results: Vec<ToolResult>,
98    /// Tools that were available to run
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub tools_available: Vec<String>,
101    /// Tools that were not found
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub tools_missing: Vec<String>,
104    /// L2 engine execution results
105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
106    pub l2_engine_results: Vec<L2AnalyzerResult>,
107}
108
109/// A single finding from bugbot analysis
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct BugbotFinding {
112    /// e.g. "signature-regression", "born-dead"
113    pub finding_type: String,
114    /// "high", "medium", "low"
115    pub severity: String,
116    /// File path (relative to project root)
117    pub file: PathBuf,
118    /// Function/method name
119    pub function: String,
120    /// Line number in current file
121    pub line: usize,
122    /// Human-readable description
123    pub message: String,
124    /// Type-specific evidence
125    pub evidence: serde_json::Value,
126    /// Confidence level (L2/L3 only). L1 findings leave this as None.
127    /// Values: "CONFIRMED", "LIKELY", "POSSIBLE", "FALSE_POSITIVE"
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub confidence: Option<String>,
130    /// Deterministic finding ID for cross-run tracking.
131    /// Hash of (finding_type, file, function, line).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub finding_id: Option<String>,
134}
135
136/// Per-engine execution result for the report.
137/// Mirrors ToolResult for L1 tools, providing identical observability.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct L2AnalyzerResult {
140    /// Engine name (e.g. "DeltaEngine", "FlowEngine")
141    pub name: String,
142    /// Whether the engine completed fully
143    pub success: bool,
144    /// Execution time in milliseconds
145    pub duration_ms: u64,
146    /// Number of findings produced
147    pub finding_count: usize,
148    /// Number of functions analyzed
149    pub functions_analyzed: usize,
150    /// Number of functions skipped
151    pub functions_skipped: usize,
152    /// Engine completion status description
153    pub status: String,
154    /// Errors encountered (empty if success)
155    #[serde(default, skip_serializing_if = "Vec::is_empty")]
156    pub errors: Vec<String>,
157}
158
159/// Summary statistics
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct BugbotSummary {
162    /// Total number of findings
163    pub total_findings: usize,
164    /// Findings grouped by severity
165    pub by_severity: HashMap<String, usize>,
166    /// Findings grouped by finding type
167    pub by_type: HashMap<String, usize>,
168    /// Number of files analyzed
169    pub files_analyzed: usize,
170    /// Number of functions analyzed
171    pub functions_analyzed: usize,
172    /// L1 tool-based findings count
173    #[serde(default)]
174    pub l1_findings: usize,
175    /// L2 AST-based findings count
176    #[serde(default)]
177    pub l2_findings: usize,
178    /// Number of tools that ran
179    #[serde(default)]
180    pub tools_run: usize,
181    /// Number of tools that failed
182    #[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        // Verify the type implements std::error::Error (for anyhow compatibility)
235        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        // Verify it can be converted into anyhow::Error and downcast back
242        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        // Old JSON without tool_results, tools_available, tools_missing
250        // should deserialize with those fields defaulting to empty vecs
251        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        // Old summary JSON without l1_findings, l2_findings, tools_run, tools_failed
281        // should deserialize with those fields defaulting to 0
282        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        // Test that confidence serializes when present
303        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        // Test that None confidence is omitted
321        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        // Old JSON without confidence/finding_id should deserialize with None
340        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")); // empty vec skipped
369
370        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        // Old JSON without l2_engine_results should deserialize with empty vec
388        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    // -------------------------------------------------------------------
413    // JSON output integration tests (Phase 8.7)
414    // -------------------------------------------------------------------
415
416    /// Helper: build a minimal valid BugbotCheckReport for testing.
417    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        // Verify that l2_engine_results appears as a top-level array in
454        // the serialized JSON when populated.
455        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        // l2_engine_results must be a top-level key
484        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        // Verify each L2AnalyzerResult entry has exactly the fields from the spec:
496        //   name, success, duration_ms, finding_count, functions_analyzed, functions_skipped
497        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        // All spec-required fields present with correct values
515        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        // status is also serialized (implementation detail beyond spec minimum)
523        assert_eq!(entry["status"], "complete");
524
525        // errors should be absent when empty (skip_serializing_if)
526        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        // Verify that errors array appears when non-empty
535        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        // Verify that l2_engine_results is omitted from JSON when the vec is empty
561        // (skip_serializing_if = "Vec::is_empty")
562        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        // Verify that confidence and finding_id appear in the finding JSON
574        // when set (top-level on finding, not inside evidence)
575        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        // confidence is top-level on finding (not inside evidence)
596        assert_eq!(finding_json["confidence"], "POSSIBLE");
597        assert_eq!(finding_json["finding_id"], "a3f8c2d1");
598
599        // Verify it's NOT nested inside evidence
600        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        // Verify that None confidence/finding_id are omitted from JSON
613        // (skip_serializing_if = "Option::is_none")
614        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        // End-to-end: build a report matching the spec example and verify
643        // the JSON shape matches the spec at spec.md lines 2109-2143.
644        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        // Top-level report structure
675        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        // Finding shape matches spec
681        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        // L2 engine result shape matches spec
700        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        // Verify serialize -> deserialize roundtrip preserves all L2 fields
712        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        // L2 engine results roundtrip
752        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        // Finding roundtrip with confidence and finding_id
765        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        // Verify all defined confidence levels serialize correctly
779        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        // finding_id should serialize as a JSON string (hex hash), never as a number
806        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}