Skip to main content

tldr_cli/commands/bugbot/
text_format.rs

1//! Text output formatter for bugbot check reports
2//!
3//! Produces human-readable text output for terminal display, as an alternative
4//! to the default JSON output. Used when `--format text` is specified.
5
6use std::fmt::Write;
7
8use super::types::{BugbotCheckReport, L2AnalyzerResult};
9
10/// Format a `BugbotCheckReport` as human-readable text.
11///
12/// Output structure:
13/// - Summary line with finding counts by severity
14/// - Stats line with files/functions analyzed and elapsed time
15/// - One block per finding with severity tag, location, message, and evidence
16/// - Optional errors section
17/// - Optional truncation note
18pub fn format_bugbot_text(report: &BugbotCheckReport) -> String {
19    let mut out = String::new();
20
21    // Summary line
22    if report.findings.is_empty() {
23        writeln!(out, "bugbot check -- no issues found").unwrap();
24    } else {
25        let severity_breakdown = format_severity_breakdown(&report.summary.by_severity);
26        writeln!(
27            out,
28            "bugbot check -- {} findings ({})",
29            report.summary.total_findings, severity_breakdown
30        )
31        .unwrap();
32    }
33
34    // Stats line
35    writeln!(
36        out,
37        "  {} files analyzed, {} functions, {}ms",
38        report.summary.files_analyzed, report.summary.functions_analyzed, report.elapsed_ms
39    )
40    .unwrap();
41
42    // Individual findings
43    for finding in &report.findings {
44        writeln!(out).unwrap(); // blank line separator
45
46        // PM-42: Critical findings use [!!!CRITICAL] marker for visibility
47        let tag = if finding.severity == "critical" {
48            "!!!CRITICAL".to_string()
49        } else {
50            finding.severity.to_uppercase()
51        };
52        writeln!(
53            out,
54            "[{}] {} in {}",
55            tag,
56            finding.finding_type,
57            finding.file.display()
58        )
59        .unwrap();
60        // PM-4: L1 findings have empty function field. Show "line N" directly
61        // instead of "functionName (line N)" when function is empty.
62        if finding.function.is_empty() {
63            writeln!(out, "  line {}", finding.line).unwrap();
64        } else {
65            writeln!(out, "  {} (line {})", finding.function, finding.line).unwrap();
66        }
67        writeln!(out, "  {}", finding.message).unwrap();
68
69        // Confidence line for L2 findings (when confidence is Some)
70        if let Some(ref confidence) = finding.confidence {
71            writeln!(out, "  Confidence: {}", confidence).unwrap();
72        }
73
74        // Evidence lines -- type-specific rendering
75        format_finding_evidence(&mut out, finding);
76    }
77
78    // Critical summary line -- appears before tools/engines sections
79    let critical_count = report
80        .findings
81        .iter()
82        .filter(|f| f.severity == "critical")
83        .count();
84    if critical_count > 0 {
85        writeln!(out).unwrap();
86        writeln!(
87            out,
88            "CRITICAL: {} finding(s) require immediate attention",
89            critical_count
90        )
91        .unwrap();
92    }
93
94    // Tool results section -- shows which L1 tools ran and their status
95    if !report.tool_results.is_empty() || !report.tools_missing.is_empty() {
96        writeln!(out).unwrap();
97        writeln!(out, "tools:").unwrap();
98        for result in &report.tool_results {
99            let status = if result.success {
100                format!("ok ({} findings, {}ms)", result.finding_count, result.duration_ms)
101            } else {
102                let err_detail = result
103                    .error
104                    .as_deref()
105                    .unwrap_or("unknown error");
106                format!("failed ({})", err_detail)
107            };
108            writeln!(out, "  {} - {}", result.name, status).unwrap();
109        }
110        for name in &report.tools_missing {
111            writeln!(out, "  {} - skipped (not installed)", name).unwrap();
112        }
113        if !report.tools_missing.is_empty() {
114            writeln!(
115                out,
116                "  hint: run `tldr doctor --install {}` to set up missing tools",
117                report.language
118            )
119            .unwrap();
120        }
121    }
122
123    // L2 engine results section
124    if !report.l2_engine_results.is_empty() {
125        writeln!(out).unwrap();
126        writeln!(out, "L2 engines:").unwrap();
127        for result in &report.l2_engine_results {
128            let status_label = format_engine_status(result);
129            writeln!(
130                out,
131                "  {} - {} ({} findings, {}ms)",
132                result.name, status_label, result.finding_count, result.duration_ms
133            )
134            .unwrap();
135            // Append partial/error detail inline
136            if !result.errors.is_empty() {
137                for err_detail in &result.errors {
138                    writeln!(out, "    [{}]", err_detail).unwrap();
139                }
140            }
141        }
142    }
143
144    // ANALYSIS GAPS section -- shown when any engine has errors
145    let engines_with_errors: Vec<&L2AnalyzerResult> = report
146        .l2_engine_results
147        .iter()
148        .filter(|r| !r.errors.is_empty())
149        .collect();
150    if !engines_with_errors.is_empty() {
151        writeln!(out).unwrap();
152        writeln!(out, "ANALYSIS GAPS ({}):", engines_with_errors.len()).unwrap();
153        for result in engines_with_errors {
154            for error in &result.errors {
155                writeln!(out, "  {}: {}", result.name, error).unwrap();
156            }
157        }
158    }
159
160    // Errors section
161    if !report.errors.is_empty() {
162        writeln!(out).unwrap();
163        writeln!(out, "errors:").unwrap();
164        for error in &report.errors {
165            writeln!(out, "  - {}", error).unwrap();
166        }
167    }
168
169    // Truncation note
170    for note in &report.notes {
171        if let Some(rest) = note.strip_prefix("truncated_to_") {
172            writeln!(out).unwrap();
173            writeln!(out, "(output truncated to {} findings)", rest).unwrap();
174        }
175    }
176
177    // Remove trailing newline to let write_text add its own
178    let trimmed = out.trim_end_matches('\n');
179    trimmed.to_string()
180}
181
182/// Format severity counts as "N high, M medium, L low", omitting zeroes.
183///
184/// Severities are always printed in high, medium, low order regardless of
185/// HashMap iteration order.
186fn format_severity_breakdown(by_severity: &std::collections::HashMap<String, usize>) -> String {
187    let mut parts = Vec::new();
188    // Known severities in descending order (PM-8: includes "info", PM-42: includes "critical")
189    for level in &["critical", "high", "medium", "low", "info"] {
190        if let Some(&count) = by_severity.get(*level) {
191            if count > 0 {
192                parts.push(format!("{} {}", count, level));
193            }
194        }
195    }
196    // Include any unknown severity levels
197    let mut keys: Vec<&String> = by_severity
198        .keys()
199        .filter(|k| !["critical", "high", "medium", "low", "info"].contains(&k.as_str()))
200        .collect();
201    keys.sort();
202    for key in keys {
203        if let Some(&count) = by_severity.get(key) {
204            if count > 0 {
205                parts.push(format!("{} {}", count, key));
206            }
207        }
208    }
209    parts.join(", ")
210}
211
212/// Format type-specific evidence lines for a finding.
213///
214/// Renders evidence differently depending on finding_type:
215/// - `signature-regression`: Before/After signature comparison
216/// - `secret-exposed`: Masked value display
217/// - `taint-flow`: Source -> Sink flow with types
218/// - `born-dead`: Reference count if available
219/// - `complexity-increase` / `maintainability-drop`: Before/after values
220/// - `resource-leak`: Sub-type and resource name
221/// - `new-clone`: Clone type and similarity percentage
222/// - `impact-blast-radius`: Caller counts
223/// - `temporal-violation`: Expected vs actual call order
224/// - `guard-removed`: Removed variable and constraint
225/// - `contract-regression`: Category, variable, and constraint
226/// - Other types: Show all evidence values (strings, numbers, booleans, arrays)
227fn format_finding_evidence(out: &mut String, finding: &super::types::BugbotFinding) {
228    match finding.finding_type.as_str() {
229        "signature-regression" => {
230            if let Some(before) = finding.evidence.get("before_signature").and_then(|v| v.as_str())
231            {
232                writeln!(out, "  Before: {}", before).unwrap();
233            }
234            if let Some(after) = finding.evidence.get("after_signature").and_then(|v| v.as_str()) {
235                writeln!(out, "  After:  {}", after).unwrap();
236            }
237        }
238        "secret-exposed" => {
239            if let Some(val) = finding.evidence.get("masked_value").and_then(|v| v.as_str()) {
240                writeln!(out, "  Value: {}", val).unwrap();
241            }
242        }
243        "taint-flow" => {
244            // Production evidence uses source_var/sink_var/source_type/sink_type keys.
245            // Legacy test evidence uses source/sink keys.
246            let source_var = finding
247                .evidence
248                .get("source_var")
249                .and_then(|v| v.as_str())
250                .or_else(|| finding.evidence.get("source").and_then(|v| v.as_str()));
251            let sink_var = finding
252                .evidence
253                .get("sink_var")
254                .and_then(|v| v.as_str())
255                .or_else(|| finding.evidence.get("sink").and_then(|v| v.as_str()));
256            let source_type = finding
257                .evidence
258                .get("source_type")
259                .and_then(|v| v.as_str());
260            let sink_type = finding.evidence.get("sink_type").and_then(|v| v.as_str());
261
262            match (source_var, sink_var) {
263                (Some(src), Some(snk)) => {
264                    let src_label = match source_type {
265                        Some(st) => format!("{} ({})", src, st),
266                        None => src.to_string(),
267                    };
268                    let snk_label = match sink_type {
269                        Some(st) => format!("{} ({})", snk, st),
270                        None => snk.to_string(),
271                    };
272                    writeln!(out, "  Flow: {} -> {}", src_label, snk_label).unwrap();
273                }
274                _ => {
275                    if let Some(src) = source_var {
276                        writeln!(out, "  Source: {}", src).unwrap();
277                    }
278                    if let Some(snk) = sink_var {
279                        writeln!(out, "  Sink: {}", snk).unwrap();
280                    }
281                }
282            }
283        }
284        "born-dead" => {
285            if let Some(count) = finding.evidence.get("ref_count").and_then(|v| v.as_u64()) {
286                writeln!(out, "  References: {}", count).unwrap();
287            }
288        }
289        "complexity-increase" | "maintainability-drop" => {
290            let before = finding.evidence.get("before").and_then(|v| v.as_u64());
291            let after = finding.evidence.get("after").and_then(|v| v.as_u64());
292            if let (Some(b), Some(a)) = (before, after) {
293                let label = if finding.finding_type == "complexity-increase" {
294                    "Complexity"
295                } else {
296                    "Maintainability"
297                };
298                writeln!(out, "  {}: {} -> {}", label, b, a).unwrap();
299            }
300        }
301        "resource-leak" => {
302            let sub_type = finding
303                .evidence
304                .get("sub_type")
305                .and_then(|v| v.as_str())
306                .unwrap_or("unknown");
307            let resource = finding
308                .evidence
309                .get("resource")
310                .and_then(|v| v.as_str())
311                .unwrap_or("unknown");
312            writeln!(out, "  Resource: {} ({})", resource, sub_type).unwrap();
313        }
314        "new-clone" => {
315            if let Some(clone_type) = finding.evidence.get("clone_type").and_then(|v| v.as_str()) {
316                writeln!(out, "  Clone type: {}", clone_type).unwrap();
317            }
318            if let Some(similarity) = finding.evidence.get("similarity").and_then(|v| v.as_f64()) {
319                writeln!(out, "  Similarity: {:.0}%", similarity * 100.0).unwrap();
320            }
321        }
322        "impact-blast-radius" => {
323            let total = finding
324                .evidence
325                .get("total_callers")
326                .and_then(|v| v.as_u64());
327            let direct = finding
328                .evidence
329                .get("direct_callers")
330                .and_then(|v| v.as_u64());
331            if let Some(t) = total {
332                writeln!(out, "  Total callers: {}", t).unwrap();
333            }
334            if let Some(d) = direct {
335                writeln!(out, "  Direct callers: {}", d).unwrap();
336            }
337        }
338        "temporal-violation" => {
339            let expected = finding.evidence.get("expected_order").and_then(|v| v.as_array());
340            let actual = finding.evidence.get("actual_order").and_then(|v| v.as_array());
341            if let Some(exp) = expected {
342                let items: Vec<&str> = exp.iter().filter_map(|v| v.as_str()).collect();
343                if !items.is_empty() {
344                    writeln!(out, "  Expected order: {}", items.join(" -> ")).unwrap();
345                }
346            }
347            if let Some(act) = actual {
348                let items: Vec<&str> = act.iter().filter_map(|v| v.as_str()).collect();
349                if !items.is_empty() {
350                    writeln!(out, "  Actual order: {}", items.join(" -> ")).unwrap();
351                }
352            }
353        }
354        "guard-removed" => {
355            let variable = finding
356                .evidence
357                .get("removed_variable")
358                .and_then(|v| v.as_str());
359            let constraint = finding
360                .evidence
361                .get("removed_constraint")
362                .and_then(|v| v.as_str());
363            if let (Some(var), Some(con)) = (variable, constraint) {
364                writeln!(out, "  Removed guard: {} {}", var, con).unwrap();
365            } else {
366                format_generic_evidence(out, &finding.evidence);
367            }
368        }
369        "contract-regression" => {
370            let category = finding
371                .evidence
372                .get("category")
373                .and_then(|v| v.as_str());
374            let variable = finding
375                .evidence
376                .get("removed_variable")
377                .and_then(|v| v.as_str());
378            let constraint = finding
379                .evidence
380                .get("removed_constraint")
381                .and_then(|v| v.as_str());
382            if let (Some(cat), Some(var), Some(con)) = (category, variable, constraint) {
383                writeln!(out, "  Removed {}: {} {}", cat, var, con).unwrap();
384            } else {
385                format_generic_evidence(out, &finding.evidence);
386            }
387        }
388        _ => {
389            format_generic_evidence(out, &finding.evidence);
390        }
391    }
392}
393
394/// Format evidence generically by showing all non-null values from a JSON object.
395///
396/// Handles strings, numbers (integer and float), booleans, and arrays of strings.
397/// Nested objects are shown as compact JSON. Null values are skipped.
398fn format_generic_evidence(out: &mut String, evidence: &serde_json::Value) {
399    if let Some(obj) = evidence.as_object() {
400        for (key, value) in obj {
401            if value.is_null() {
402                continue;
403            }
404            if let Some(s) = value.as_str() {
405                writeln!(out, "  {}: {}", key, s).unwrap();
406            } else if let Some(n) = value.as_u64() {
407                writeln!(out, "  {}: {}", key, n).unwrap();
408            } else if let Some(n) = value.as_i64() {
409                writeln!(out, "  {}: {}", key, n).unwrap();
410            } else if let Some(n) = value.as_f64() {
411                // Avoid trailing zeros for clean display
412                if n.fract() == 0.0 {
413                    writeln!(out, "  {}: {}", key, n as i64).unwrap();
414                } else {
415                    writeln!(out, "  {}: {}", key, n).unwrap();
416                }
417            } else if let Some(b) = value.as_bool() {
418                writeln!(out, "  {}: {}", key, b).unwrap();
419            } else if let Some(arr) = value.as_array() {
420                let items: Vec<String> = arr
421                    .iter()
422                    .map(|v| {
423                        if let Some(s) = v.as_str() {
424                            s.to_string()
425                        } else {
426                            v.to_string()
427                        }
428                    })
429                    .collect();
430                writeln!(out, "  {}: {}", key, items.join(", ")).unwrap();
431            } else if value.is_object() {
432                // Nested objects: show as compact JSON
433                writeln!(out, "  {}: {}", key, value).unwrap();
434            }
435        }
436    }
437}
438
439/// Format engine status label for display.
440///
441/// Returns a short lowercase status string: "complete", "partial", "skipped",
442/// or "timed out" based on the engine result's success flag and status string.
443fn format_engine_status(result: &L2AnalyzerResult) -> String {
444    if result.success {
445        "complete".to_string()
446    } else if result.status.starts_with("partial") || result.status.starts_with("Partial") {
447        "partial".to_string()
448    } else if result.status.starts_with("skipped") || result.status.starts_with("Skipped") {
449        "skipped".to_string()
450    } else if result.status.contains("timed out") || result.status.contains("TimedOut") {
451        "timed out".to_string()
452    } else {
453        "failed".to_string()
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::commands::bugbot::types::{BugbotFinding, BugbotSummary};
461    use std::collections::HashMap;
462    use std::path::PathBuf;
463
464    /// Build a minimal report with no findings for testing.
465    fn empty_report() -> BugbotCheckReport {
466        BugbotCheckReport {
467            tool: "bugbot".to_string(),
468            mode: "check".to_string(),
469            language: "rust".to_string(),
470            base_ref: "HEAD".to_string(),
471            detection_method: "git:uncommitted".to_string(),
472            timestamp: "2026-02-25T00:00:00Z".to_string(),
473            changed_files: Vec::new(),
474            findings: Vec::new(),
475            summary: BugbotSummary {
476                total_findings: 0,
477                by_severity: HashMap::new(),
478                by_type: HashMap::new(),
479                files_analyzed: 3,
480                functions_analyzed: 12,
481                l1_findings: 0,
482                l2_findings: 0,
483                tools_run: 0,
484                tools_failed: 0,
485            },
486            elapsed_ms: 42,
487            errors: Vec::new(),
488            notes: Vec::new(),
489            tool_results: vec![],
490            tools_available: vec![],
491            tools_missing: vec![],
492            l2_engine_results: vec![],
493        }
494    }
495
496    /// Build a signature-regression finding with before/after evidence.
497    fn signature_finding() -> BugbotFinding {
498        BugbotFinding {
499            finding_type: "signature-regression".to_string(),
500            severity: "high".to_string(),
501            file: PathBuf::from("src/lib.rs"),
502            function: "compute".to_string(),
503            line: 10,
504            message: "parameter removed from public function".to_string(),
505            evidence: serde_json::json!({
506                "before_signature": "fn compute(x: i32, y: i32) -> i32",
507                "after_signature": "fn compute(x: i32) -> i32",
508                "changes": [{"change_type": "param_removed", "detail": "y: i32"}]
509            }),
510            confidence: None,
511            finding_id: None,
512        }
513    }
514
515    /// Build a born-dead finding with no evidence.
516    fn born_dead_finding() -> BugbotFinding {
517        BugbotFinding {
518            finding_type: "born-dead".to_string(),
519            severity: "low".to_string(),
520            file: PathBuf::from("src/utils.rs"),
521            function: "unused_helper".to_string(),
522            line: 25,
523            message: "function has no callers in the project".to_string(),
524            evidence: serde_json::Value::Null,
525            confidence: None,
526            finding_id: None,
527        }
528    }
529
530    #[test]
531    fn test_text_format_no_findings() {
532        let report = empty_report();
533        let output = format_bugbot_text(&report);
534
535        assert!(
536            output.contains("no issues found"),
537            "Expected 'no issues found' in output, got: {}",
538            output
539        );
540        assert!(
541            output.contains("3 files analyzed"),
542            "Expected '3 files analyzed' in output, got: {}",
543            output
544        );
545        assert!(
546            output.contains("12 functions"),
547            "Expected '12 functions' in output, got: {}",
548            output
549        );
550        assert!(
551            output.contains("42ms"),
552            "Expected '42ms' in output, got: {}",
553            output
554        );
555    }
556
557    #[test]
558    fn test_text_format_summary_line() {
559        let mut report = empty_report();
560        report.findings = vec![
561            signature_finding(),
562            signature_finding(),
563            born_dead_finding(),
564        ];
565        report.summary = BugbotSummary {
566            total_findings: 3,
567            by_severity: {
568                let mut m = HashMap::new();
569                m.insert("high".to_string(), 2);
570                m.insert("low".to_string(), 1);
571                m
572            },
573            by_type: HashMap::new(),
574            files_analyzed: 5,
575            functions_analyzed: 20,
576            l1_findings: 0,
577            l2_findings: 0,
578            tools_run: 0,
579            tools_failed: 0,
580        };
581
582        let output = format_bugbot_text(&report);
583
584        assert!(
585            output.contains("3 findings"),
586            "Expected '3 findings' in output, got: {}",
587            output
588        );
589        assert!(
590            output.contains("2 high"),
591            "Expected '2 high' in output, got: {}",
592            output
593        );
594        assert!(
595            output.contains("1 low"),
596            "Expected '1 low' in output, got: {}",
597            output
598        );
599        assert!(
600            output.contains("5 files analyzed"),
601            "Expected '5 files analyzed' in output, got: {}",
602            output
603        );
604    }
605
606    #[test]
607    fn test_text_format_signature_finding() {
608        let mut report = empty_report();
609        report.findings = vec![signature_finding()];
610        report.summary = BugbotSummary {
611            total_findings: 1,
612            by_severity: {
613                let mut m = HashMap::new();
614                m.insert("high".to_string(), 1);
615                m
616            },
617            by_type: HashMap::new(),
618            files_analyzed: 1,
619            functions_analyzed: 1,
620            l1_findings: 0,
621            l2_findings: 0,
622            tools_run: 0,
623            tools_failed: 0,
624        };
625
626        let output = format_bugbot_text(&report);
627
628        assert!(
629            output.contains("Before: fn compute(x: i32, y: i32) -> i32"),
630            "Expected 'Before:' line with old signature, got: {}",
631            output
632        );
633        assert!(
634            output.contains("After:  fn compute(x: i32) -> i32"),
635            "Expected 'After:' line with new signature, got: {}",
636            output
637        );
638    }
639
640    #[test]
641    fn test_text_format_born_dead_finding() {
642        let mut report = empty_report();
643        report.findings = vec![born_dead_finding()];
644        report.summary = BugbotSummary {
645            total_findings: 1,
646            by_severity: {
647                let mut m = HashMap::new();
648                m.insert("low".to_string(), 1);
649                m
650            },
651            by_type: HashMap::new(),
652            files_analyzed: 1,
653            functions_analyzed: 1,
654            l1_findings: 0,
655            l2_findings: 0,
656            tools_run: 0,
657            tools_failed: 0,
658        };
659
660        let output = format_bugbot_text(&report);
661
662        assert!(
663            output.contains("[LOW] born-dead in src/utils.rs"),
664            "Expected '[LOW] born-dead in src/utils.rs', got: {}",
665            output
666        );
667        assert!(
668            output.contains("unused_helper (line 25)"),
669            "Expected 'unused_helper (line 25)', got: {}",
670            output
671        );
672        assert!(
673            output.contains("function has no callers in the project"),
674            "Expected message text, got: {}",
675            output
676        );
677        // born-dead should NOT have Before:/After: lines
678        assert!(
679            !output.contains("Before:"),
680            "born-dead should not have 'Before:' line, got: {}",
681            output
682        );
683        assert!(
684            !output.contains("After:"),
685            "born-dead should not have 'After:' line, got: {}",
686            output
687        );
688    }
689
690    #[test]
691    fn test_text_format_severity_tags() {
692        let mut report = empty_report();
693        let mut medium_finding = born_dead_finding();
694        medium_finding.severity = "medium".to_string();
695        medium_finding.file = PathBuf::from("src/mid.rs");
696
697        report.findings = vec![
698            signature_finding(),   // high
699            medium_finding,        // medium
700            born_dead_finding(),   // low
701        ];
702        report.summary = BugbotSummary {
703            total_findings: 3,
704            by_severity: {
705                let mut m = HashMap::new();
706                m.insert("high".to_string(), 1);
707                m.insert("medium".to_string(), 1);
708                m.insert("low".to_string(), 1);
709                m
710            },
711            by_type: HashMap::new(),
712            files_analyzed: 3,
713            functions_analyzed: 3,
714            l1_findings: 0,
715            l2_findings: 0,
716            tools_run: 0,
717            tools_failed: 0,
718        };
719
720        let output = format_bugbot_text(&report);
721
722        assert!(
723            output.contains("[HIGH]"),
724            "Expected [HIGH] tag, got: {}",
725            output
726        );
727        assert!(
728            output.contains("[MEDIUM]"),
729            "Expected [MEDIUM] tag, got: {}",
730            output
731        );
732        assert!(
733            output.contains("[LOW]"),
734            "Expected [LOW] tag, got: {}",
735            output
736        );
737    }
738
739    #[test]
740    fn test_text_format_errors_section() {
741        let mut report = empty_report();
742        report.errors = vec![
743            "diff failed for src/a.rs: parse error".to_string(),
744            "baseline error for src/b.rs: git show failed".to_string(),
745        ];
746
747        let output = format_bugbot_text(&report);
748
749        assert!(
750            output.contains("errors:"),
751            "Expected 'errors:' section header, got: {}",
752            output
753        );
754        assert!(
755            output.contains("  - diff failed for src/a.rs: parse error"),
756            "Expected first error line, got: {}",
757            output
758        );
759        assert!(
760            output.contains("  - baseline error for src/b.rs: git show failed"),
761            "Expected second error line, got: {}",
762            output
763        );
764    }
765
766    #[test]
767    fn test_text_format_truncation_note() {
768        let mut report = empty_report();
769        report.notes = vec!["truncated_to_10".to_string()];
770
771        let output = format_bugbot_text(&report);
772
773        assert!(
774            output.contains("(output truncated to 10 findings)"),
775            "Expected truncation message, got: {}",
776            output
777        );
778    }
779
780    // ===================================================================
781    // Phase 6: L1 integration tests
782    // ===================================================================
783
784    #[test]
785    fn test_text_format_empty_function_renders_file_line_only() {
786        // PM-4: L1 findings have empty function field.
787        // Should render "file:line" instead of " (line N)"
788        let mut report = empty_report();
789        report.findings = vec![BugbotFinding {
790            finding_type: "tool:clippy".to_string(),
791            severity: "medium".to_string(),
792            file: PathBuf::from("src/main.rs"),
793            function: String::new(), // empty function from L1
794            line: 42,
795            message: "unused variable `x`".to_string(),
796            evidence: serde_json::json!({
797                "tool": "clippy",
798                "category": "Linter",
799                "code": "clippy::unused_variables",
800            }),
801            confidence: None,
802            finding_id: None,
803        }];
804        report.summary = BugbotSummary {
805            total_findings: 1,
806            by_severity: {
807                let mut m = HashMap::new();
808                m.insert("medium".to_string(), 1);
809                m
810            },
811            by_type: HashMap::new(),
812            files_analyzed: 1,
813            functions_analyzed: 0,
814            l1_findings: 1,
815            l2_findings: 0,
816            tools_run: 1,
817            tools_failed: 0,
818        };
819
820        let output = format_bugbot_text(&report);
821
822        // Should show "line 42" but NOT " (line 42)" preceded by empty function
823        assert!(
824            output.contains("line 42"),
825            "Expected 'line 42' in output, got: {}",
826            output
827        );
828        // Should NOT have leading space before "(line" when function is empty
829        assert!(
830            !output.contains("  (line 42)"),
831            "PM-4: empty function should not render as '  (line 42)', got: {}",
832            output
833        );
834        // Should contain the file path
835        assert!(
836            output.contains("src/main.rs"),
837            "Expected file path in output, got: {}",
838            output
839        );
840    }
841
842    #[test]
843    fn test_text_format_nonempty_function_unchanged() {
844        // Non-empty function should still render as "function (line N)"
845        let mut report = empty_report();
846        report.findings = vec![BugbotFinding {
847            finding_type: "born-dead".to_string(),
848            severity: "low".to_string(),
849            file: PathBuf::from("src/lib.rs"),
850            function: "my_function".to_string(),
851            line: 10,
852            message: "no callers".to_string(),
853            evidence: serde_json::Value::Null,
854            confidence: None,
855            finding_id: None,
856        }];
857        report.summary = BugbotSummary {
858            total_findings: 1,
859            by_severity: {
860                let mut m = HashMap::new();
861                m.insert("low".to_string(), 1);
862                m
863            },
864            by_type: HashMap::new(),
865            files_analyzed: 1,
866            functions_analyzed: 1,
867            l1_findings: 0,
868            l2_findings: 1,
869            tools_run: 0,
870            tools_failed: 0,
871        };
872
873        let output = format_bugbot_text(&report);
874
875        assert!(
876            output.contains("my_function (line 10)"),
877            "Non-empty function should render as 'my_function (line 10)', got: {}",
878            output
879        );
880    }
881
882    #[test]
883    fn test_text_format_tool_results_section() {
884        // Tool results section should appear when tools were run
885        let mut report = empty_report();
886        report.tool_results = vec![
887            crate::commands::bugbot::tools::ToolResult {
888                name: "clippy".to_string(),
889                category: crate::commands::bugbot::tools::ToolCategory::Linter,
890                success: true,
891                duration_ms: 1500,
892                finding_count: 3,
893                error: None,
894                exit_code: Some(0),
895            },
896            crate::commands::bugbot::tools::ToolResult {
897                name: "cargo-audit".to_string(),
898                category: crate::commands::bugbot::tools::ToolCategory::SecurityScanner,
899                success: false,
900                duration_ms: 200,
901                finding_count: 0,
902                error: Some("Parse error: invalid JSON".to_string()),
903                exit_code: Some(1),
904            },
905        ];
906        report.tools_missing = vec!["pyright".to_string()];
907
908        let output = format_bugbot_text(&report);
909
910        assert!(
911            output.contains("tools:"),
912            "Expected 'tools:' section header, got: {}",
913            output
914        );
915        assert!(
916            output.contains("clippy"),
917            "Expected clippy in tool results, got: {}",
918            output
919        );
920        assert!(
921            output.contains("cargo-audit"),
922            "Expected cargo-audit in tool results, got: {}",
923            output
924        );
925        // Failed tool should show status
926        assert!(
927            output.contains("failed"),
928            "Expected 'failed' status for cargo-audit, got: {}",
929            output
930        );
931        // Missing tools should be listed
932        assert!(
933            output.contains("pyright"),
934            "Expected missing tool 'pyright' in output, got: {}",
935            output
936        );
937    }
938
939    #[test]
940    fn test_text_format_no_tool_results_no_section() {
941        // When no tools were run, the tool results section should not appear
942        let report = empty_report();
943        let output = format_bugbot_text(&report);
944
945        assert!(
946            !output.contains("tools:"),
947            "Should not have 'tools:' section when no tools ran, got: {}",
948            output
949        );
950    }
951
952    // ===================================================================
953    // Phase 8: Integration & Polish tests
954    // ===================================================================
955
956    #[test]
957    fn test_text_format_critical_finding_marker() {
958        // Critical findings should use [!!!CRITICAL] marker instead of [CRITICAL]
959        let mut report = empty_report();
960        report.findings = vec![BugbotFinding {
961            finding_type: "secret-exposed".to_string(),
962            severity: "critical".to_string(),
963            file: PathBuf::from("src/config.rs"),
964            function: "load_config".to_string(),
965            line: 42,
966            message: "API key exposed in source code".to_string(),
967            evidence: serde_json::json!({
968                "masked_value": "sk-****REDACTED****",
969            }),
970            confidence: Some("CONFIRMED".to_string()),
971            finding_id: None,
972        }];
973        report.summary = BugbotSummary {
974            total_findings: 1,
975            by_severity: {
976                let mut m = HashMap::new();
977                m.insert("critical".to_string(), 1);
978                m
979            },
980            by_type: HashMap::new(),
981            files_analyzed: 1,
982            functions_analyzed: 1,
983            l1_findings: 0,
984            l2_findings: 1,
985            tools_run: 0,
986            tools_failed: 0,
987        };
988
989        let output = format_bugbot_text(&report);
990
991        assert!(
992            output.contains("[!!!CRITICAL]"),
993            "Critical findings should use [!!!CRITICAL] marker, got: {}",
994            output
995        );
996        assert!(
997            !output.contains("[CRITICAL]") || output.contains("[!!!CRITICAL]"),
998            "Should not have bare [CRITICAL] without !!! prefix, got: {}",
999            output
1000        );
1001    }
1002
1003    #[test]
1004    fn test_text_format_confidence_display() {
1005        // L2 findings with confidence should show "Confidence: VALUE" line
1006        let mut report = empty_report();
1007        report.findings = vec![BugbotFinding {
1008            finding_type: "taint-flow".to_string(),
1009            severity: "high".to_string(),
1010            file: PathBuf::from("src/api.rs"),
1011            function: "handle_request".to_string(),
1012            line: 15,
1013            message: "Unsanitized input reaches SQL query".to_string(),
1014            evidence: serde_json::json!({
1015                "source": "request.query",
1016                "sink": "db.execute()",
1017            }),
1018            confidence: Some("POSSIBLE".to_string()),
1019            finding_id: None,
1020        }];
1021        report.summary = BugbotSummary {
1022            total_findings: 1,
1023            by_severity: {
1024                let mut m = HashMap::new();
1025                m.insert("high".to_string(), 1);
1026                m
1027            },
1028            by_type: HashMap::new(),
1029            files_analyzed: 1,
1030            functions_analyzed: 1,
1031            l1_findings: 0,
1032            l2_findings: 1,
1033            tools_run: 0,
1034            tools_failed: 0,
1035        };
1036
1037        let output = format_bugbot_text(&report);
1038
1039        assert!(
1040            output.contains("Confidence: POSSIBLE"),
1041            "L2 findings with confidence should show 'Confidence: POSSIBLE', got: {}",
1042            output
1043        );
1044    }
1045
1046    #[test]
1047    fn test_text_format_no_confidence_for_l1() {
1048        // L1 findings (confidence=None) should NOT show confidence line
1049        let mut report = empty_report();
1050        report.findings = vec![BugbotFinding {
1051            finding_type: "tool:clippy".to_string(),
1052            severity: "medium".to_string(),
1053            file: PathBuf::from("src/main.rs"),
1054            function: String::new(),
1055            line: 10,
1056            message: "unused variable".to_string(),
1057            evidence: serde_json::Value::Null,
1058            confidence: None,
1059            finding_id: None,
1060        }];
1061        report.summary = BugbotSummary {
1062            total_findings: 1,
1063            by_severity: {
1064                let mut m = HashMap::new();
1065                m.insert("medium".to_string(), 1);
1066                m
1067            },
1068            by_type: HashMap::new(),
1069            files_analyzed: 1,
1070            functions_analyzed: 0,
1071            l1_findings: 1,
1072            l2_findings: 0,
1073            tools_run: 1,
1074            tools_failed: 0,
1075        };
1076
1077        let output = format_bugbot_text(&report);
1078
1079        assert!(
1080            !output.contains("Confidence:"),
1081            "L1 findings should not show Confidence line, got: {}",
1082            output
1083        );
1084    }
1085
1086    #[test]
1087    fn test_text_format_l2_engine_results_section() {
1088        // L2 engine results should appear in output when present
1089        use crate::commands::bugbot::types::L2AnalyzerResult;
1090
1091        let mut report = empty_report();
1092        report.l2_engine_results = vec![
1093            L2AnalyzerResult {
1094                name: "TldrDifferentialEngine".to_string(),
1095                success: true,
1096                duration_ms: 23,
1097                finding_count: 5,
1098                functions_analyzed: 10,
1099                functions_skipped: 0,
1100                status: "Complete".to_string(),
1101                errors: vec![],
1102            },
1103        ];
1104
1105        let output = format_bugbot_text(&report);
1106
1107        assert!(
1108            output.contains("L2 engines:"),
1109            "Expected 'L2 engines:' section header, got: {}",
1110            output
1111        );
1112        assert!(
1113            output.contains("TldrDifferentialEngine"),
1114            "Expected TldrDifferentialEngine in L2 results, got: {}",
1115            output
1116        );
1117    }
1118
1119    #[test]
1120    fn test_text_format_analysis_gaps_section() {
1121        // When an engine has errors, ANALYSIS GAPS section should appear
1122        use crate::commands::bugbot::types::L2AnalyzerResult;
1123
1124        let mut report = empty_report();
1125        report.l2_engine_results = vec![
1126            L2AnalyzerResult {
1127                name: "DeltaEngine".to_string(),
1128                success: false,
1129                duration_ms: 500,
1130                finding_count: 2,
1131                functions_analyzed: 5,
1132                functions_skipped: 3,
1133                status: "Partial: analysis incomplete".to_string(),
1134                errors: vec!["Failed to read baseline for src/macro.rs".to_string()],
1135            },
1136        ];
1137
1138        let output = format_bugbot_text(&report);
1139
1140        assert!(
1141            output.contains("ANALYSIS GAPS"),
1142            "Expected 'ANALYSIS GAPS' section when engine has errors, got: {}",
1143            output
1144        );
1145        assert!(
1146            output.contains("DeltaEngine"),
1147            "Expected DeltaEngine in analysis gaps, got: {}",
1148            output
1149        );
1150        assert!(
1151            output.contains("Failed to read baseline"),
1152            "Expected error detail in analysis gaps, got: {}",
1153            output
1154        );
1155    }
1156
1157    #[test]
1158    fn test_text_format_no_analysis_gaps_when_all_ok() {
1159        // When no engines have errors, ANALYSIS GAPS should not appear
1160        use crate::commands::bugbot::types::L2AnalyzerResult;
1161
1162        let mut report = empty_report();
1163        report.l2_engine_results = vec![
1164            L2AnalyzerResult {
1165                name: "DeltaEngine".to_string(),
1166                success: true,
1167                duration_ms: 23,
1168                finding_count: 5,
1169                functions_analyzed: 10,
1170                functions_skipped: 0,
1171                status: "Complete".to_string(),
1172                errors: vec![],
1173            },
1174        ];
1175
1176        let output = format_bugbot_text(&report);
1177
1178        assert!(
1179            !output.contains("ANALYSIS GAPS"),
1180            "Should not have 'ANALYSIS GAPS' when all engines succeeded, got: {}",
1181            output
1182        );
1183    }
1184
1185    #[test]
1186    fn test_text_format_critical_summary_line() {
1187        // When critical findings exist, a summary line should appear
1188        let mut report = empty_report();
1189        report.findings = vec![
1190            BugbotFinding {
1191                finding_type: "secret-exposed".to_string(),
1192                severity: "critical".to_string(),
1193                file: PathBuf::from("src/config.rs"),
1194                function: "load".to_string(),
1195                line: 5,
1196                message: "exposed secret".to_string(),
1197                evidence: serde_json::Value::Null,
1198                confidence: Some("CONFIRMED".to_string()),
1199                finding_id: None,
1200            },
1201            BugbotFinding {
1202                finding_type: "taint-flow".to_string(),
1203                severity: "critical".to_string(),
1204                file: PathBuf::from("src/api.rs"),
1205                function: "handle".to_string(),
1206                line: 20,
1207                message: "SQL injection".to_string(),
1208                evidence: serde_json::Value::Null,
1209                confidence: Some("LIKELY".to_string()),
1210                finding_id: None,
1211            },
1212        ];
1213        report.summary = BugbotSummary {
1214            total_findings: 2,
1215            by_severity: {
1216                let mut m = HashMap::new();
1217                m.insert("critical".to_string(), 2);
1218                m
1219            },
1220            by_type: HashMap::new(),
1221            files_analyzed: 2,
1222            functions_analyzed: 2,
1223            l1_findings: 0,
1224            l2_findings: 2,
1225            tools_run: 0,
1226            tools_failed: 0,
1227        };
1228
1229        let output = format_bugbot_text(&report);
1230
1231        assert!(
1232            output.contains("CRITICAL: 2 finding(s) require immediate attention"),
1233            "Expected critical summary line, got: {}",
1234            output
1235        );
1236    }
1237
1238    #[test]
1239    fn test_text_format_no_critical_summary_without_critical() {
1240        // When no critical findings exist, no critical summary line
1241        let mut report = empty_report();
1242        report.findings = vec![signature_finding()]; // high, not critical
1243        report.summary = BugbotSummary {
1244            total_findings: 1,
1245            by_severity: {
1246                let mut m = HashMap::new();
1247                m.insert("high".to_string(), 1);
1248                m
1249            },
1250            by_type: HashMap::new(),
1251            files_analyzed: 1,
1252            functions_analyzed: 1,
1253            l1_findings: 0,
1254            l2_findings: 1,
1255            tools_run: 0,
1256            tools_failed: 0,
1257        };
1258
1259        let output = format_bugbot_text(&report);
1260
1261        assert!(
1262            !output.contains("CRITICAL:"),
1263            "Should not have CRITICAL summary line without critical findings, got: {}",
1264            output
1265        );
1266    }
1267
1268    #[test]
1269    fn test_text_format_critical_in_severity_breakdown() {
1270        // "critical" should appear in severity breakdown before "high"
1271        let mut report = empty_report();
1272        report.findings = vec![
1273            BugbotFinding {
1274                finding_type: "secret-exposed".to_string(),
1275                severity: "critical".to_string(),
1276                file: PathBuf::from("src/a.rs"),
1277                function: "a".to_string(),
1278                line: 1,
1279                message: "secret".to_string(),
1280                evidence: serde_json::Value::Null,
1281                confidence: None,
1282                finding_id: None,
1283            },
1284            signature_finding(), // high severity
1285        ];
1286        report.summary = BugbotSummary {
1287            total_findings: 2,
1288            by_severity: {
1289                let mut m = HashMap::new();
1290                m.insert("critical".to_string(), 1);
1291                m.insert("high".to_string(), 1);
1292                m
1293            },
1294            by_type: HashMap::new(),
1295            files_analyzed: 2,
1296            functions_analyzed: 2,
1297            l1_findings: 0,
1298            l2_findings: 2,
1299            tools_run: 0,
1300            tools_failed: 0,
1301        };
1302
1303        let output = format_bugbot_text(&report);
1304
1305        assert!(
1306            output.contains("1 critical"),
1307            "Expected '1 critical' in severity breakdown, got: {}",
1308            output
1309        );
1310        assert!(
1311            output.contains("1 high"),
1312            "Expected '1 high' in severity breakdown, got: {}",
1313            output
1314        );
1315        // critical should come before high in breakdown
1316        let crit_pos = output.find("1 critical").unwrap();
1317        let high_pos = output.find("1 high").unwrap();
1318        assert!(
1319            crit_pos < high_pos,
1320            "critical ({}) should appear before high ({}) in breakdown, got: {}",
1321            crit_pos,
1322            high_pos,
1323            output
1324        );
1325    }
1326
1327    #[test]
1328    fn test_text_format_secret_exposed_evidence() {
1329        // secret-exposed findings should show masked_value from evidence
1330        let mut report = empty_report();
1331        report.findings = vec![BugbotFinding {
1332            finding_type: "secret-exposed".to_string(),
1333            severity: "high".to_string(),
1334            file: PathBuf::from("src/config.rs"),
1335            function: String::new(),
1336            line: 10,
1337            message: "Exposed secret: AWS_KEY".to_string(),
1338            evidence: serde_json::json!({
1339                "pattern": "AWS_KEY",
1340                "masked_value": "AKIA****REDACTED",
1341            }),
1342            confidence: Some("POSSIBLE".to_string()),
1343            finding_id: None,
1344        }];
1345        report.summary = BugbotSummary {
1346            total_findings: 1,
1347            by_severity: {
1348                let mut m = HashMap::new();
1349                m.insert("high".to_string(), 1);
1350                m
1351            },
1352            by_type: HashMap::new(),
1353            files_analyzed: 1,
1354            functions_analyzed: 0,
1355            l1_findings: 0,
1356            l2_findings: 1,
1357            tools_run: 0,
1358            tools_failed: 0,
1359        };
1360
1361        let output = format_bugbot_text(&report);
1362
1363        assert!(
1364            output.contains("Value: AKIA****REDACTED"),
1365            "secret-exposed should show 'Value: <masked_value>', got: {}",
1366            output
1367        );
1368    }
1369
1370    #[test]
1371    fn test_text_format_taint_flow_evidence() {
1372        // taint-flow findings should show Source -> Sink path
1373        let mut report = empty_report();
1374        report.findings = vec![BugbotFinding {
1375            finding_type: "taint-flow".to_string(),
1376            severity: "high".to_string(),
1377            file: PathBuf::from("src/api.rs"),
1378            function: "handle_request".to_string(),
1379            line: 15,
1380            message: "Unsanitized input reaches SQL query".to_string(),
1381            evidence: serde_json::json!({
1382                "source": "request.query",
1383                "sink": "db.execute()",
1384            }),
1385            confidence: Some("POSSIBLE".to_string()),
1386            finding_id: None,
1387        }];
1388        report.summary = BugbotSummary {
1389            total_findings: 1,
1390            by_severity: {
1391                let mut m = HashMap::new();
1392                m.insert("high".to_string(), 1);
1393                m
1394            },
1395            by_type: HashMap::new(),
1396            files_analyzed: 1,
1397            functions_analyzed: 1,
1398            l1_findings: 0,
1399            l2_findings: 1,
1400            tools_run: 0,
1401            tools_failed: 0,
1402        };
1403
1404        let output = format_bugbot_text(&report);
1405
1406        assert!(
1407            output.contains("request.query"),
1408            "taint-flow should show source, got: {}",
1409            output
1410        );
1411        assert!(
1412            output.contains("db.execute()"),
1413            "taint-flow should show sink, got: {}",
1414            output
1415        );
1416    }
1417
1418    #[test]
1419    fn test_text_format_info_severity_in_breakdown() {
1420        // "info" severity should appear in the severity breakdown
1421        let mut report = empty_report();
1422        report.findings = vec![BugbotFinding {
1423            finding_type: "tool:clippy".to_string(),
1424            severity: "info".to_string(),
1425            file: PathBuf::from("src/main.rs"),
1426            function: String::new(),
1427            line: 1,
1428            message: "informational note".to_string(),
1429            evidence: serde_json::Value::Null,
1430            confidence: None,
1431            finding_id: None,
1432        }];
1433        report.summary = BugbotSummary {
1434            total_findings: 1,
1435            by_severity: {
1436                let mut m = HashMap::new();
1437                m.insert("info".to_string(), 1);
1438                m
1439            },
1440            by_type: HashMap::new(),
1441            files_analyzed: 1,
1442            functions_analyzed: 0,
1443            l1_findings: 1,
1444            l2_findings: 0,
1445            tools_run: 1,
1446            tools_failed: 0,
1447        };
1448
1449        let output = format_bugbot_text(&report);
1450
1451        assert!(
1452            output.contains("1 info"),
1453            "Expected '1 info' in severity breakdown, got: {}",
1454            output
1455        );
1456        assert!(
1457            output.contains("[INFO]"),
1458            "Expected '[INFO]' tag on finding, got: {}",
1459            output
1460        );
1461    }
1462
1463    // ===================================================================
1464    // Evidence rendering coverage for all 24 L2 finding types
1465    // ===================================================================
1466
1467    /// Helper: build a single-finding report for testing evidence rendering.
1468    fn single_finding_report(finding: BugbotFinding) -> BugbotCheckReport {
1469        let mut report = empty_report();
1470        let severity = finding.severity.clone();
1471        report.findings = vec![finding];
1472        report.summary = BugbotSummary {
1473            total_findings: 1,
1474            by_severity: {
1475                let mut m = HashMap::new();
1476                m.insert(severity, 1);
1477                m
1478            },
1479            by_type: HashMap::new(),
1480            files_analyzed: 1,
1481            functions_analyzed: 1,
1482            l1_findings: 0,
1483            l2_findings: 1,
1484            tools_run: 0,
1485            tools_failed: 0,
1486        };
1487        report
1488    }
1489
1490    // --- #17 taint-flow: evidence with actual production keys ---
1491
1492    #[test]
1493    fn test_text_format_taint_flow_production_evidence() {
1494        // The taint extractor produces keys: source_var, source_type,
1495        // sink_var, sink_type, source_line, sink_line, path_length.
1496        // The text formatter should render these meaningfully.
1497        let report = single_finding_report(BugbotFinding {
1498            finding_type: "taint-flow".to_string(),
1499            severity: "high".to_string(),
1500            file: PathBuf::from("src/api.rs"),
1501            function: "handle_request".to_string(),
1502            line: 15,
1503            message: "Taint flow detected".to_string(),
1504            evidence: serde_json::json!({
1505                "source_var": "user_input",
1506                "source_line": 5,
1507                "source_type": "UserInput",
1508                "sink_var": "query",
1509                "sink_line": 15,
1510                "sink_type": "SqlQuery",
1511                "path_length": 3,
1512            }),
1513            confidence: Some("POSSIBLE".to_string()),
1514            finding_id: None,
1515        });
1516
1517        let output = format_bugbot_text(&report);
1518
1519        // Should show source and sink variables
1520        assert!(
1521            output.contains("user_input"),
1522            "taint-flow should show source variable, got: {}",
1523            output
1524        );
1525        assert!(
1526            output.contains("query"),
1527            "taint-flow should show sink variable, got: {}",
1528            output
1529        );
1530        // Should show source and sink types
1531        assert!(
1532            output.contains("UserInput"),
1533            "taint-flow should show source type, got: {}",
1534            output
1535        );
1536        assert!(
1537            output.contains("SqlQuery"),
1538            "taint-flow should show sink type, got: {}",
1539            output
1540        );
1541    }
1542
1543    // --- #22 resource-leak: sub_type and resource fields ---
1544
1545    #[test]
1546    fn test_text_format_resource_leak_evidence() {
1547        let report = single_finding_report(BugbotFinding {
1548            finding_type: "resource-leak".to_string(),
1549            severity: "medium".to_string(),
1550            file: PathBuf::from("src/io.rs"),
1551            function: "process_file".to_string(),
1552            line: 10,
1553            message: "Resource not closed".to_string(),
1554            evidence: serde_json::json!({
1555                "sub_type": "leak",
1556                "resource": "file_handle",
1557                "open_line": 10,
1558                "paths": 2,
1559            }),
1560            confidence: Some("POSSIBLE".to_string()),
1561            finding_id: None,
1562        });
1563
1564        let output = format_bugbot_text(&report);
1565
1566        assert!(
1567            output.contains("leak"),
1568            "resource-leak should show sub_type, got: {}",
1569            output
1570        );
1571        assert!(
1572            output.contains("file_handle"),
1573            "resource-leak should show resource name, got: {}",
1574            output
1575        );
1576    }
1577
1578    #[test]
1579    fn test_text_format_resource_leak_double_close() {
1580        let report = single_finding_report(BugbotFinding {
1581            finding_type: "resource-leak".to_string(),
1582            severity: "high".to_string(),
1583            file: PathBuf::from("src/io.rs"),
1584            function: "cleanup".to_string(),
1585            line: 25,
1586            message: "Resource closed twice".to_string(),
1587            evidence: serde_json::json!({
1588                "sub_type": "double-close",
1589                "resource": "db_conn",
1590                "first_close_line": 20,
1591                "second_close_line": 25,
1592            }),
1593            confidence: Some("LIKELY".to_string()),
1594            finding_id: None,
1595        });
1596
1597        let output = format_bugbot_text(&report);
1598
1599        assert!(
1600            output.contains("double-close"),
1601            "resource-leak should show sub_type 'double-close', got: {}",
1602            output
1603        );
1604        assert!(
1605            output.contains("db_conn"),
1606            "resource-leak should show resource name, got: {}",
1607            output
1608        );
1609    }
1610
1611    #[test]
1612    fn test_text_format_resource_leak_use_after_close() {
1613        let report = single_finding_report(BugbotFinding {
1614            finding_type: "resource-leak".to_string(),
1615            severity: "high".to_string(),
1616            file: PathBuf::from("src/io.rs"),
1617            function: "read_after_close".to_string(),
1618            line: 30,
1619            message: "Resource used after close".to_string(),
1620            evidence: serde_json::json!({
1621                "sub_type": "use-after-close",
1622                "resource": "socket",
1623                "close_line": 25,
1624                "use_line": 30,
1625            }),
1626            confidence: Some("LIKELY".to_string()),
1627            finding_id: None,
1628        });
1629
1630        let output = format_bugbot_text(&report);
1631
1632        assert!(
1633            output.contains("use-after-close"),
1634            "resource-leak should show sub_type 'use-after-close', got: {}",
1635            output
1636        );
1637        assert!(
1638            output.contains("socket"),
1639            "resource-leak should show resource name, got: {}",
1640            output
1641        );
1642    }
1643
1644    // --- #9 impact-blast-radius: numeric caller counts ---
1645
1646    #[test]
1647    fn test_text_format_impact_blast_radius_evidence() {
1648        let report = single_finding_report(BugbotFinding {
1649            finding_type: "impact-blast-radius".to_string(),
1650            severity: "info".to_string(),
1651            file: PathBuf::from("src/core.rs"),
1652            function: "compute".to_string(),
1653            line: 10,
1654            message: "Function has wide impact".to_string(),
1655            evidence: serde_json::json!({
1656                "total_callers": 15,
1657                "direct_callers": 5,
1658            }),
1659            confidence: Some("POSSIBLE".to_string()),
1660            finding_id: None,
1661        });
1662
1663        let output = format_bugbot_text(&report);
1664
1665        // Should display caller counts (these are u64, generic handler drops them)
1666        assert!(
1667            output.contains("15"),
1668            "impact-blast-radius should show total_callers count, got: {}",
1669            output
1670        );
1671        assert!(
1672            output.contains("5"),
1673            "impact-blast-radius should show direct_callers count, got: {}",
1674            output
1675        );
1676    }
1677
1678    // --- #23 temporal-violation: expected/actual order ---
1679
1680    #[test]
1681    fn test_text_format_temporal_violation_evidence() {
1682        let report = single_finding_report(BugbotFinding {
1683            finding_type: "temporal-violation".to_string(),
1684            severity: "medium".to_string(),
1685            file: PathBuf::from("src/db.rs"),
1686            function: "process".to_string(),
1687            line: 20,
1688            message: "'open' should be called before 'query'".to_string(),
1689            evidence: serde_json::json!({
1690                "expected_order": ["open", "query"],
1691                "actual_order": ["query", "open"],
1692                "confidence": 0.85,
1693                "support": 12,
1694            }),
1695            confidence: Some("POSSIBLE".to_string()),
1696            finding_id: None,
1697        });
1698
1699        let output = format_bugbot_text(&report);
1700
1701        // Should show expected order
1702        assert!(
1703            output.contains("open"),
1704            "temporal-violation should show expected order, got: {}",
1705            output
1706        );
1707        assert!(
1708            output.contains("query"),
1709            "temporal-violation should show expected order, got: {}",
1710            output
1711        );
1712    }
1713
1714    // --- #3 new-clone: similarity percentage ---
1715
1716    #[test]
1717    fn test_text_format_new_clone_evidence() {
1718        let report = single_finding_report(BugbotFinding {
1719            finding_type: "new-clone".to_string(),
1720            severity: "medium".to_string(),
1721            file: PathBuf::from("src/utils.rs"),
1722            function: "helper".to_string(),
1723            line: 10,
1724            message: "New code clone detected".to_string(),
1725            evidence: serde_json::json!({
1726                "clone_type": "Type2",
1727                "similarity": 0.92,
1728                "fragment1": {
1729                    "file": "src/utils.rs",
1730                    "start_line": 10,
1731                    "end_line": 25,
1732                },
1733                "fragment2": {
1734                    "file": "src/other.rs",
1735                    "start_line": 30,
1736                    "end_line": 45,
1737                },
1738            }),
1739            confidence: Some("POSSIBLE".to_string()),
1740            finding_id: None,
1741        });
1742
1743        let output = format_bugbot_text(&report);
1744
1745        assert!(
1746            output.contains("92%") || output.contains("0.92"),
1747            "new-clone should show similarity percentage, got: {}",
1748            output
1749        );
1750        assert!(
1751            output.contains("Type2"),
1752            "new-clone should show clone type, got: {}",
1753            output
1754        );
1755    }
1756
1757    // --- #20 guard-removed: removed guard details ---
1758
1759    #[test]
1760    fn test_text_format_guard_removed_evidence() {
1761        let report = single_finding_report(BugbotFinding {
1762            finding_type: "guard-removed".to_string(),
1763            severity: "high".to_string(),
1764            file: PathBuf::from("src/validate.rs"),
1765            function: "check_input".to_string(),
1766            line: 5,
1767            message: "Guard removed".to_string(),
1768            evidence: serde_json::json!({
1769                "removed_variable": "input",
1770                "removed_constraint": "!= null",
1771                "confidence": "HIGH",
1772                "baseline_source_line": 5,
1773            }),
1774            confidence: Some("LIKELY".to_string()),
1775            finding_id: None,
1776        });
1777
1778        let output = format_bugbot_text(&report);
1779
1780        assert!(
1781            output.contains("input"),
1782            "guard-removed should show removed variable, got: {}",
1783            output
1784        );
1785        assert!(
1786            output.contains("!= null"),
1787            "guard-removed should show removed constraint, got: {}",
1788            output
1789        );
1790    }
1791
1792    // --- #21 contract-regression: contract details ---
1793
1794    #[test]
1795    fn test_text_format_contract_regression_evidence() {
1796        let report = single_finding_report(BugbotFinding {
1797            finding_type: "contract-regression".to_string(),
1798            severity: "medium".to_string(),
1799            file: PathBuf::from("src/math.rs"),
1800            function: "divide".to_string(),
1801            line: 10,
1802            message: "Contract weakened".to_string(),
1803            evidence: serde_json::json!({
1804                "category": "postcondition",
1805                "removed_variable": "result",
1806                "removed_constraint": "> 0",
1807                "confidence": "HIGH",
1808                "baseline_source_line": 10,
1809            }),
1810            confidence: Some("LIKELY".to_string()),
1811            finding_id: None,
1812        });
1813
1814        let output = format_bugbot_text(&report);
1815
1816        assert!(
1817            output.contains("postcondition"),
1818            "contract-regression should show category, got: {}",
1819            output
1820        );
1821        assert!(
1822            output.contains("result"),
1823            "contract-regression should show removed variable, got: {}",
1824            output
1825        );
1826        assert!(
1827            output.contains("> 0"),
1828            "contract-regression should show removed constraint, got: {}",
1829            output
1830        );
1831    }
1832
1833    // --- #8 architecture-violation: directory pair ---
1834
1835    #[test]
1836    fn test_text_format_architecture_violation_evidence() {
1837        let report = single_finding_report(BugbotFinding {
1838            finding_type: "architecture-violation".to_string(),
1839            severity: "medium".to_string(),
1840            file: PathBuf::from("src/api"),
1841            function: String::new(),
1842            line: 0,
1843            message: "Circular dependency".to_string(),
1844            evidence: serde_json::json!({
1845                "dir_a": "src/api",
1846                "dir_b": "src/db",
1847            }),
1848            confidence: Some("POSSIBLE".to_string()),
1849            finding_id: None,
1850        });
1851
1852        let output = format_bugbot_text(&report);
1853
1854        assert!(
1855            output.contains("src/api"),
1856            "architecture-violation should show dir_a, got: {}",
1857            output
1858        );
1859        assert!(
1860            output.contains("src/db"),
1861            "architecture-violation should show dir_b, got: {}",
1862            output
1863        );
1864    }
1865
1866    // --- #24 api-misuse: rule details and fix suggestion ---
1867
1868    #[test]
1869    fn test_text_format_api_misuse_evidence() {
1870        let report = single_finding_report(BugbotFinding {
1871            finding_type: "api-misuse".to_string(),
1872            severity: "medium".to_string(),
1873            file: PathBuf::from("src/http.py"),
1874            function: String::new(),
1875            line: 5,
1876            message: "API misuse: missing timeout".to_string(),
1877            evidence: serde_json::json!({
1878                "rule_id": "PY-HTTP-001",
1879                "rule_name": "missing-timeout",
1880                "category": "Reliability",
1881                "api_call": "requests.get",
1882                "fix_suggestion": "Add timeout=30 parameter",
1883                "correct_usage": "requests.get(url, timeout=30)",
1884            }),
1885            confidence: Some("POSSIBLE".to_string()),
1886            finding_id: None,
1887        });
1888
1889        let output = format_bugbot_text(&report);
1890
1891        assert!(
1892            output.contains("requests.get"),
1893            "api-misuse should show api_call, got: {}",
1894            output
1895        );
1896        assert!(
1897            output.contains("Add timeout=30 parameter"),
1898            "api-misuse should show fix_suggestion, got: {}",
1899            output
1900        );
1901    }
1902
1903    // --- #5 complexity-increase: before/after with delta ---
1904
1905    #[test]
1906    fn test_text_format_complexity_increase_evidence() {
1907        let report = single_finding_report(BugbotFinding {
1908            finding_type: "complexity-increase".to_string(),
1909            severity: "medium".to_string(),
1910            file: PathBuf::from("src/parser.rs"),
1911            function: "parse_expr".to_string(),
1912            line: 50,
1913            message: "Complexity increased".to_string(),
1914            evidence: serde_json::json!({
1915                "before": 8,
1916                "after": 15,
1917            }),
1918            confidence: Some("POSSIBLE".to_string()),
1919            finding_id: None,
1920        });
1921
1922        let output = format_bugbot_text(&report);
1923
1924        assert!(
1925            output.contains("8"),
1926            "complexity-increase should show before value, got: {}",
1927            output
1928        );
1929        assert!(
1930            output.contains("15"),
1931            "complexity-increase should show after value, got: {}",
1932            output
1933        );
1934        assert!(
1935            output.contains("Complexity:"),
1936            "complexity-increase should show 'Complexity:' label, got: {}",
1937            output
1938        );
1939    }
1940
1941    // --- #15 div-by-zero: variable name ---
1942
1943    #[test]
1944    fn test_text_format_div_by_zero_evidence() {
1945        let report = single_finding_report(BugbotFinding {
1946            finding_type: "div-by-zero".to_string(),
1947            severity: "high".to_string(),
1948            file: PathBuf::from("src/math.rs"),
1949            function: "average".to_string(),
1950            line: 8,
1951            message: "Potential division by zero".to_string(),
1952            evidence: serde_json::json!({
1953                "variable": "count",
1954                "line": 8,
1955            }),
1956            confidence: Some("POSSIBLE".to_string()),
1957            finding_id: None,
1958        });
1959
1960        let output = format_bugbot_text(&report);
1961
1962        assert!(
1963            output.contains("count"),
1964            "div-by-zero should show variable name, got: {}",
1965            output
1966        );
1967    }
1968
1969    // --- #16 null-deref: variable name ---
1970
1971    #[test]
1972    fn test_text_format_null_deref_evidence() {
1973        let report = single_finding_report(BugbotFinding {
1974            finding_type: "null-deref".to_string(),
1975            severity: "high".to_string(),
1976            file: PathBuf::from("src/api.py"),
1977            function: "get_user".to_string(),
1978            line: 12,
1979            message: "Potential null dereference".to_string(),
1980            evidence: serde_json::json!({
1981                "variable": "user",
1982                "line": 12,
1983            }),
1984            confidence: Some("POSSIBLE".to_string()),
1985            finding_id: None,
1986        });
1987
1988        let output = format_bugbot_text(&report);
1989
1990        assert!(
1991            output.contains("user"),
1992            "null-deref should show variable name, got: {}",
1993            output
1994        );
1995    }
1996
1997    // --- #12 dead-store: variable name ---
1998
1999    #[test]
2000    fn test_text_format_dead_store_evidence() {
2001        let report = single_finding_report(BugbotFinding {
2002            finding_type: "dead-store".to_string(),
2003            severity: "low".to_string(),
2004            file: PathBuf::from("src/calc.rs"),
2005            function: "compute".to_string(),
2006            line: 7,
2007            message: "Dead store: variable never read".to_string(),
2008            evidence: serde_json::json!({
2009                "variable": "temp",
2010                "def_line": 7,
2011            }),
2012            confidence: Some("POSSIBLE".to_string()),
2013            finding_id: None,
2014        });
2015
2016        let output = format_bugbot_text(&report);
2017
2018        assert!(
2019            output.contains("temp"),
2020            "dead-store should show variable name, got: {}",
2021            output
2022        );
2023    }
2024
2025    // --- #14 redundant-computation: original and redundant text ---
2026
2027    #[test]
2028    fn test_text_format_redundant_computation_evidence() {
2029        let report = single_finding_report(BugbotFinding {
2030            finding_type: "redundant-computation".to_string(),
2031            severity: "low".to_string(),
2032            file: PathBuf::from("src/calc.rs"),
2033            function: "process".to_string(),
2034            line: 20,
2035            message: "Redundant computation".to_string(),
2036            evidence: serde_json::json!({
2037                "original_line": 10,
2038                "original_text": "a + b",
2039                "redundant_line": 20,
2040                "redundant_text": "a + b",
2041                "reason": "same_expression",
2042            }),
2043            confidence: Some("POSSIBLE".to_string()),
2044            finding_id: None,
2045        });
2046
2047        let output = format_bugbot_text(&report);
2048
2049        assert!(
2050            output.contains("a + b"),
2051            "redundant-computation should show expression text, got: {}",
2052            output
2053        );
2054        assert!(
2055            output.contains("same_expression"),
2056            "redundant-computation should show reason, got: {}",
2057            output
2058        );
2059    }
2060
2061    // --- #4 new-smell: smell type and reason ---
2062
2063    #[test]
2064    fn test_text_format_new_smell_evidence() {
2065        let report = single_finding_report(BugbotFinding {
2066            finding_type: "new-smell".to_string(),
2067            severity: "low".to_string(),
2068            file: PathBuf::from("src/service.rs"),
2069            function: "handle_all".to_string(),
2070            line: 1,
2071            message: "New code smell detected".to_string(),
2072            evidence: serde_json::json!({
2073                "smell_type": "LongMethod",
2074                "reason": "Method has 150 lines, exceeds threshold of 50",
2075                "severity_level": 3,
2076            }),
2077            confidence: Some("POSSIBLE".to_string()),
2078            finding_id: None,
2079        });
2080
2081        let output = format_bugbot_text(&report);
2082
2083        assert!(
2084            output.contains("LongMethod"),
2085            "new-smell should show smell_type, got: {}",
2086            output
2087        );
2088        assert!(
2089            output.contains("150 lines"),
2090            "new-smell should show reason, got: {}",
2091            output
2092        );
2093    }
2094
2095    // --- #13 uninitialized-use: variable name ---
2096
2097    #[test]
2098    fn test_text_format_uninitialized_use_evidence() {
2099        let report = single_finding_report(BugbotFinding {
2100            finding_type: "uninitialized-use".to_string(),
2101            severity: "high".to_string(),
2102            file: PathBuf::from("src/parser.rs"),
2103            function: "parse".to_string(),
2104            line: 15,
2105            message: "Variable may be used before initialization".to_string(),
2106            evidence: serde_json::json!({
2107                "variable": "result",
2108                "def_line": 15,
2109            }),
2110            confidence: Some("POSSIBLE".to_string()),
2111            finding_id: None,
2112        });
2113
2114        let output = format_bugbot_text(&report);
2115
2116        assert!(
2117            output.contains("result"),
2118            "uninitialized-use should show variable name, got: {}",
2119            output
2120        );
2121    }
2122
2123    // --- #10 unreachable-code: evidence ---
2124
2125    #[test]
2126    fn test_text_format_unreachable_code_evidence() {
2127        let report = single_finding_report(BugbotFinding {
2128            finding_type: "unreachable-code".to_string(),
2129            severity: "low".to_string(),
2130            file: PathBuf::from("src/utils.rs"),
2131            function: "helper".to_string(),
2132            line: 30,
2133            message: "Code after return is unreachable".to_string(),
2134            evidence: serde_json::json!({
2135                "reason": "code_after_return",
2136                "block_id": 3,
2137            }),
2138            confidence: Some("POSSIBLE".to_string()),
2139            finding_id: None,
2140        });
2141
2142        let output = format_bugbot_text(&report);
2143
2144        assert!(
2145            output.contains("code_after_return"),
2146            "unreachable-code should show reason, got: {}",
2147            output
2148        );
2149    }
2150
2151    // --- #11 sccp-dead-code: evidence ---
2152
2153    #[test]
2154    fn test_text_format_sccp_dead_code_evidence() {
2155        let report = single_finding_report(BugbotFinding {
2156            finding_type: "sccp-dead-code".to_string(),
2157            severity: "low".to_string(),
2158            file: PathBuf::from("src/cond.rs"),
2159            function: "check".to_string(),
2160            line: 20,
2161            message: "Branch is dead: condition is always false".to_string(),
2162            evidence: serde_json::json!({
2163                "condition": "x > 100",
2164                "resolved_value": "false",
2165            }),
2166            confidence: Some("POSSIBLE".to_string()),
2167            finding_id: None,
2168        });
2169
2170        let output = format_bugbot_text(&report);
2171
2172        assert!(
2173            output.contains("x > 100"),
2174            "sccp-dead-code should show condition, got: {}",
2175            output
2176        );
2177        assert!(
2178            output.contains("false"),
2179            "sccp-dead-code should show resolved value, got: {}",
2180            output
2181        );
2182    }
2183
2184    // --- #18 vulnerability: vuln type and description ---
2185
2186    #[test]
2187    fn test_text_format_vulnerability_evidence() {
2188        let report = single_finding_report(BugbotFinding {
2189            finding_type: "vulnerability".to_string(),
2190            severity: "high".to_string(),
2191            file: PathBuf::from("src/auth.rs"),
2192            function: "login".to_string(),
2193            line: 25,
2194            message: "SQL injection vulnerability".to_string(),
2195            evidence: serde_json::json!({
2196                "vuln_type": "sql_injection",
2197                "cwe": "CWE-89",
2198                "description": "User input concatenated into SQL query",
2199            }),
2200            confidence: Some("POSSIBLE".to_string()),
2201            finding_id: None,
2202        });
2203
2204        let output = format_bugbot_text(&report);
2205
2206        assert!(
2207            output.contains("sql_injection"),
2208            "vulnerability should show vuln_type, got: {}",
2209            output
2210        );
2211        assert!(
2212            output.contains("CWE-89"),
2213            "vulnerability should show CWE, got: {}",
2214            output
2215        );
2216    }
2217
2218    // --- #19 secret-exposed: pattern and secret type ---
2219
2220    #[test]
2221    fn test_text_format_secret_exposed_with_pattern() {
2222        let report = single_finding_report(BugbotFinding {
2223            finding_type: "secret-exposed".to_string(),
2224            severity: "critical".to_string(),
2225            file: PathBuf::from("src/config.rs"),
2226            function: String::new(),
2227            line: 3,
2228            message: "AWS access key exposed".to_string(),
2229            evidence: serde_json::json!({
2230                "pattern": "AWS_ACCESS_KEY",
2231                "masked_value": "AKIA****XXXX",
2232                "secret_type": "aws_key",
2233            }),
2234            confidence: Some("CONFIRMED".to_string()),
2235            finding_id: None,
2236        });
2237
2238        let output = format_bugbot_text(&report);
2239
2240        assert!(
2241            output.contains("AKIA****XXXX"),
2242            "secret-exposed should show masked_value, got: {}",
2243            output
2244        );
2245    }
2246
2247    // --- #6 maintainability-drop: before/after scores ---
2248
2249    #[test]
2250    fn test_text_format_maintainability_drop_evidence() {
2251        let report = single_finding_report(BugbotFinding {
2252            finding_type: "maintainability-drop".to_string(),
2253            severity: "medium".to_string(),
2254            file: PathBuf::from("src/engine.rs"),
2255            function: "run".to_string(),
2256            line: 1,
2257            message: "Maintainability index dropped".to_string(),
2258            evidence: serde_json::json!({
2259                "before": 75,
2260                "after": 45,
2261                "threshold": 10,
2262            }),
2263            confidence: Some("POSSIBLE".to_string()),
2264            finding_id: None,
2265        });
2266
2267        let output = format_bugbot_text(&report);
2268
2269        // Numeric values should be displayed
2270        assert!(
2271            output.contains("75"),
2272            "maintainability-drop should show before score, got: {}",
2273            output
2274        );
2275        assert!(
2276            output.contains("45"),
2277            "maintainability-drop should show after score, got: {}",
2278            output
2279        );
2280    }
2281
2282    // --- #2 param-renamed: old and new parameter names ---
2283
2284    #[test]
2285    fn test_text_format_param_renamed_evidence() {
2286        let report = single_finding_report(BugbotFinding {
2287            finding_type: "param-renamed".to_string(),
2288            severity: "medium".to_string(),
2289            file: PathBuf::from("src/api.rs"),
2290            function: "create_user".to_string(),
2291            line: 10,
2292            message: "Parameter renamed".to_string(),
2293            evidence: serde_json::json!({
2294                "old_name": "user_name",
2295                "new_name": "username",
2296                "position": 0,
2297            }),
2298            confidence: Some("POSSIBLE".to_string()),
2299            finding_id: None,
2300        });
2301
2302        let output = format_bugbot_text(&report);
2303
2304        assert!(
2305            output.contains("user_name"),
2306            "param-renamed should show old parameter name, got: {}",
2307            output
2308        );
2309        assert!(
2310            output.contains("username"),
2311            "param-renamed should show new parameter name, got: {}",
2312            output
2313        );
2314    }
2315
2316    // --- Generic handler: should show numeric values too ---
2317
2318    #[test]
2319    fn test_text_format_generic_evidence_shows_numbers() {
2320        // The generic fallback handler should display numeric values,
2321        // not just string values.
2322        let report = single_finding_report(BugbotFinding {
2323            finding_type: "some-unknown-type".to_string(),
2324            severity: "low".to_string(),
2325            file: PathBuf::from("src/test.rs"),
2326            function: "test_fn".to_string(),
2327            line: 1,
2328            message: "Test finding".to_string(),
2329            evidence: serde_json::json!({
2330                "string_field": "hello",
2331                "number_field": 42,
2332                "float_field": 2.5,
2333                "bool_field": true,
2334            }),
2335            confidence: None,
2336            finding_id: None,
2337        });
2338
2339        let output = format_bugbot_text(&report);
2340
2341        assert!(
2342            output.contains("hello"),
2343            "generic should show string values, got: {}",
2344            output
2345        );
2346        assert!(
2347            output.contains("42"),
2348            "generic should show integer values, got: {}",
2349            output
2350        );
2351        assert!(
2352            output.contains("3.14"),
2353            "generic should show float values, got: {}",
2354            output
2355        );
2356        assert!(
2357            output.contains("true"),
2358            "generic should show boolean values, got: {}",
2359            output
2360        );
2361    }
2362
2363    // --- Generic handler: should show array values ---
2364
2365    #[test]
2366    fn test_text_format_generic_evidence_shows_arrays() {
2367        let report = single_finding_report(BugbotFinding {
2368            finding_type: "some-array-type".to_string(),
2369            severity: "low".to_string(),
2370            file: PathBuf::from("src/test.rs"),
2371            function: "test_fn".to_string(),
2372            line: 1,
2373            message: "Test finding".to_string(),
2374            evidence: serde_json::json!({
2375                "items": ["alpha", "beta", "gamma"],
2376            }),
2377            confidence: None,
2378            finding_id: None,
2379        });
2380
2381        let output = format_bugbot_text(&report);
2382
2383        assert!(
2384            output.contains("alpha"),
2385            "generic should show array string elements, got: {}",
2386            output
2387        );
2388        assert!(
2389            output.contains("beta"),
2390            "generic should show array string elements, got: {}",
2391            output
2392        );
2393    }
2394}