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