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