1use std::fmt::Write;
7
8use super::types::{BugbotCheckReport, L2AnalyzerResult};
9
10pub fn format_bugbot_text(report: &BugbotCheckReport) -> String {
19 let mut out = String::new();
20
21 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 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 for finding in &report.findings {
44 writeln!(out).unwrap(); 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 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 if let Some(ref confidence) = finding.confidence {
71 writeln!(out, " Confidence: {}", confidence).unwrap();
72 }
73
74 format_finding_evidence(&mut out, finding);
76 }
77
78 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 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 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 if !result.errors.is_empty() {
137 for err_detail in &result.errors {
138 writeln!(out, " [{}]", err_detail).unwrap();
139 }
140 }
141 }
142 }
143
144 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 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 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 let trimmed = out.trim_end_matches('\n');
179 trimmed.to_string()
180}
181
182fn format_severity_breakdown(by_severity: &std::collections::HashMap<String, usize>) -> String {
187 let mut parts = Vec::new();
188 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 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
212fn 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 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
405fn 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 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 writeln!(out, " {}: {}", key, value).unwrap();
445 }
446 }
447 }
448}
449
450fn 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 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 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 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 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(), medium_finding, born_dead_finding(), ];
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 #[test]
796 fn test_text_format_empty_function_renders_file_line_only() {
797 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(), 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 assert!(
835 output.contains("line 42"),
836 "Expected 'line 42' in output, got: {}",
837 output
838 );
839 assert!(
841 !output.contains(" (line 42)"),
842 "PM-4: empty function should not render as ' (line 42)', got: {}",
843 output
844 );
845 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 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 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 assert!(
938 output.contains("failed"),
939 "Expected 'failed' status for cargo-audit, got: {}",
940 output
941 );
942 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 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 #[test]
968 fn test_text_format_critical_finding_marker() {
969 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 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 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 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 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 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 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 let mut report = empty_report();
1247 report.findings = vec![signature_finding()]; 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 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(), ];
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 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 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 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 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 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 #[test]
1498 fn test_text_format_taint_flow_production_evidence() {
1499 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 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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[test]
2324 fn test_text_format_generic_evidence_shows_numbers() {
2325 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 #[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}