1use std::collections::HashMap;
8use std::io::Write;
9use std::path::PathBuf;
10use std::time::Instant;
11
12use anyhow::{bail, Result};
13use clap::Args;
14
15use tldr_core::Language;
16
17use crate::output::{OutputFormat, OutputWriter};
18
19use super::baseline::{get_baseline_content, write_baseline_tmpfile, BaselineStatus};
20use super::changes::detect_changes;
21use super::dead::compose_born_dead_scoped;
22use super::diff::diff_functions;
23use super::l2::types::AnalyzerStatus;
24use super::runner::ToolRunner;
25use super::signature::compose_signature_regression;
26use super::text_format::format_bugbot_text;
27use super::tools::{L1Finding, ToolRegistry, ToolResult};
28use super::types::{BugbotCheckReport, BugbotExitError, BugbotFinding, BugbotSummary, L2AnalyzerResult};
29
30#[derive(Debug, Args)]
32pub struct BugbotCheckArgs {
33 #[arg(default_value = ".")]
35 pub path: PathBuf,
36
37 #[arg(long, default_value = "HEAD")]
39 pub base_ref: String,
40
41 #[arg(long)]
43 pub staged: bool,
44
45 #[arg(long, default_value = "50")]
47 pub max_findings: usize,
48
49 #[arg(long)]
51 pub no_fail: bool,
52
53 #[arg(long, short)]
55 pub quiet: bool,
56
57 #[arg(long, default_value_t = false)]
59 pub no_tools: bool,
60
61 #[arg(long, default_value_t = 60)]
63 pub tool_timeout: u64,
64}
65
66impl BugbotCheckArgs {
67 pub fn run(&self, format: OutputFormat, quiet: bool, lang: Option<Language>) -> Result<()> {
72 let start = Instant::now();
73 let writer = OutputWriter::new(format, quiet);
74 let mut errors: Vec<String> = Vec::new();
75 let mut notes: Vec<String> = Vec::new();
76
77 let language = match lang {
79 Some(l) => l,
80 None => match Language::from_directory(&self.path) {
81 Some(l) => l,
82 None => {
83 bail!("Could not detect language. Use --lang <LANG>");
84 }
85 },
86 };
87
88 let language_str = format!("{:?}", language).to_lowercase();
89 let project = std::fs::canonicalize(&self.path)?;
90
91 let is_first_run = {
93 use super::first_run::{detect_first_run, run_first_run_scan, FirstRunStatus};
94 match detect_first_run(&project) {
95 FirstRunStatus::FirstRun => {
96 let progress_fn = |msg: &str| writer.progress(msg);
97 match run_first_run_scan(&project, &progress_fn) {
98 Ok(result) => {
99 if !result.baseline_errors.is_empty() {
100 for err in &result.baseline_errors {
101 errors.push(format!("first-run baseline: {err}"));
102 }
103 }
104 notes.push(format!(
105 "first_run_baseline_built_in_{}ms",
106 result.elapsed_ms
107 ));
108 true
109 }
110 Err(e) => {
111 errors.push(format!("first-run scan failed: {e}"));
112 true
114 }
115 }
116 }
117 FirstRunStatus::SubsequentRun { .. } => false,
118 }
119 };
120
121 writer.progress(&format!(
122 "Detecting {} changes in {}...",
123 language_str,
124 project.display()
125 ));
126
127 let changes = detect_changes(&project, &self.base_ref, self.staged, &language)?;
129
130 if changes.changed_files.is_empty() {
132 let report = BugbotCheckReport {
133 tool: "bugbot".to_string(),
134 mode: "check".to_string(),
135 language: language_str,
136 base_ref: self.base_ref.clone(),
137 detection_method: changes.detection_method,
138 timestamp: chrono::Utc::now().to_rfc3339(),
139 changed_files: Vec::new(),
140 findings: Vec::new(),
141 summary: build_summary(&[], 0, 0),
142 elapsed_ms: start.elapsed().as_millis() as u64,
143 errors: Vec::new(),
144 notes: vec!["no_changes_detected".to_string()],
145 tool_results: Vec::new(),
146 tools_available: Vec::new(),
147 tools_missing: Vec::new(),
148 l2_engine_results: Vec::new(),
149 };
150
151 if writer.is_text() {
152 writer.write_text(&format_bugbot_text(&report))?;
153 } else {
154 writer.write(&report)?;
155 }
156 return Ok(());
157 }
158
159 writer.progress(&format!(
160 "Found {} changed {} file(s)",
161 changes.changed_files.len(),
162 language_str
163 ));
164
165 let mut all_diffs: HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>> =
167 HashMap::new();
168 let mut _tmpfiles: Vec<tempfile::NamedTempFile> = Vec::new();
170 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
172 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
173
174 for file in &changes.changed_files {
175 match get_baseline_content(&project, file, &self.base_ref) {
176 Ok(BaselineStatus::Exists(content)) => {
177 if file.exists() {
178 let rel_path = file
180 .strip_prefix(&project)
181 .unwrap_or(file)
182 .to_path_buf();
183 baseline_contents.insert(rel_path.clone(), content.clone());
184 if let Ok(current) = std::fs::read_to_string(file) {
185 current_contents.insert(rel_path, current);
186 }
187
188 match write_baseline_tmpfile(&content, file) {
190 Ok(tmpfile) => {
191 match diff_functions(tmpfile.path(), file) {
192 Ok(report) => {
193 all_diffs.insert(file.clone(), report.changes);
194 }
195 Err(e) => {
196 errors.push(format!(
197 "diff failed for {}: {}",
198 file.display(),
199 e
200 ));
201 }
202 }
203 _tmpfiles.push(tmpfile);
204 }
205 Err(e) => {
206 errors.push(format!(
207 "baseline tmpfile failed for {}: {}",
208 file.display(),
209 e
210 ));
211 }
212 }
213 } else {
214 notes.push(format!("deleted_file:{}", file.display()));
216 }
217 }
218 Ok(BaselineStatus::NewFile) => {
219 if file.exists() {
220 let rel_path = file
222 .strip_prefix(&project)
223 .unwrap_or(file)
224 .to_path_buf();
225 baseline_contents.insert(rel_path.clone(), String::new());
226 if let Ok(current) = std::fs::read_to_string(file) {
227 current_contents.insert(rel_path, current);
228 }
229
230 let extension = file
232 .extension()
233 .and_then(|e| e.to_str())
234 .unwrap_or("txt");
235 match tempfile::Builder::new()
236 .prefix("bugbot_empty_")
237 .suffix(&format!(".{}", extension))
238 .tempfile()
239 {
240 Ok(mut empty_file) => {
241 let _ = empty_file.flush();
243 match diff_functions(empty_file.path(), file) {
244 Ok(report) => {
245 all_diffs.insert(file.clone(), report.changes);
246 }
247 Err(e) => {
248 errors.push(format!(
249 "diff (new file) failed for {}: {}",
250 file.display(),
251 e
252 ));
253 }
254 }
255 _tmpfiles.push(empty_file);
256 }
257 Err(e) => {
258 errors.push(format!(
259 "empty tmpfile failed for {}: {}",
260 file.display(),
261 e
262 ));
263 }
264 }
265 }
266 }
267 Ok(BaselineStatus::GitShowFailed(msg)) => {
268 errors.push(format!(
269 "git show failed for {}: {}",
270 file.display(),
271 msg
272 ));
273 }
274 Err(e) => {
275 errors.push(format!("baseline error for {}: {}", file.display(), e));
276 }
277 }
278 }
279
280 let files_analyzed = all_diffs.len();
281 let functions_analyzed: usize = all_diffs.values().map(|v| v.len()).sum();
282
283 writer.progress(&format!(
284 "Analyzed {} file(s), {} function-level change(s)",
285 files_analyzed, functions_analyzed
286 ));
287
288 writer.progress("Running L1 + L2 analysis in parallel...");
293 let l2_handle = {
294 use super::l2::{l2_engine_registry, L2Context};
295
296 let engines = l2_engine_registry();
297
298 let relative_changed: Vec<PathBuf> = changes
301 .changed_files
302 .iter()
303 .filter_map(|f| f.strip_prefix(&project).ok().map(|p| p.to_path_buf()))
304 .collect();
305
306 let relative_diffs: HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>> =
308 all_diffs
309 .iter()
310 .map(|(path, changes)| {
311 let rel = path.strip_prefix(&project).unwrap_or(path).to_path_buf();
312 (rel, changes.clone())
313 })
314 .collect();
315
316 let daemon = super::l2::daemon_client::create_daemon_client(&project);
319
320 let function_diff = build_function_diff(&all_diffs, &project);
322
323 let l2_ctx = L2Context::new(
324 project.clone(),
325 language,
326 relative_changed,
327 function_diff,
328 baseline_contents,
329 current_contents,
330 relative_diffs,
331 )
332 .with_first_run(is_first_run)
333 .with_base_ref(self.base_ref.clone())
334 .with_daemon(daemon);
335
336 std::thread::spawn(move || run_l2_engines(&l2_ctx, &engines))
339 };
340
341 if !self.no_tools {
343 writer.progress("Running L1 diagnostic tools...");
344 }
345 let (l1_raw, tool_results, tools_available, tools_missing) =
346 run_l1_tools_opt(&project, &language_str, self.no_tools, self.tool_timeout);
347
348 let l1_bugbot: Vec<BugbotFinding> = l1_raw.into_iter().map(BugbotFinding::from).collect();
350 let changed_paths: Vec<PathBuf> = changes
351 .changed_files
352 .iter()
353 .filter_map(|f| f.strip_prefix(&project).ok().map(|p| p.to_path_buf()))
354 .collect();
355 let l1_filtered = filter_l1_findings(l1_bugbot, &changed_paths);
356 let l1_count = l1_filtered.len();
357
358 if !tools_available.is_empty() {
359 let ran_count = tool_results.len();
360 let finding_count: usize = tool_results.iter().map(|r| r.finding_count).sum();
361 writer.progress(&format!(
362 "L1 tools: {} ran, {} raw findings, {} after filtering to changed files",
363 ran_count, finding_count, l1_count
364 ));
365 }
366
367 let sig_findings = compose_signature_regression(&all_diffs, &project);
369
370 use crate::commands::remaining::types::{ChangeType, NodeKind};
373 let inserts: Vec<&crate::commands::remaining::types::ASTChange> = all_diffs
374 .values()
375 .flat_map(|changes| changes.iter())
376 .filter(|c| matches!(c.change_type, ChangeType::Insert))
377 .filter(|c| matches!(c.node_kind, NodeKind::Function | NodeKind::Method))
378 .collect();
379 let dead_findings = if !inserts.is_empty() {
380 writer.progress("Scanning for born-dead functions...");
381 match compose_born_dead_scoped(&inserts, &changes.changed_files, &project, &language) {
382 Ok(findings) => findings,
383 Err(e) => {
384 errors.push(format!("born-dead analysis failed: {}", e));
385 Vec::new()
386 }
387 }
388 } else {
389 Vec::new()
390 };
391
392 let (l2_engine_findings, l2_engine_results) = l2_handle
394 .join()
395 .unwrap_or_else(|_| {
396 errors.push("L2 engine thread panicked".to_string());
397 (Vec::new(), Vec::new())
398 });
399
400 let compose_l2_count = sig_findings.len() + dead_findings.len();
402 let l2_count = compose_l2_count + l2_engine_findings.len();
403 let mut findings: Vec<BugbotFinding> = Vec::new();
404 findings.extend(l1_filtered);
405 findings.extend(sig_findings);
406 findings.extend(dead_findings);
407 findings.extend(l2_engine_findings);
408
409 use super::l2::dedup::dedup_and_prioritize;
411 findings = dedup_and_prioritize(findings, self.max_findings);
412
413 use super::l2::composition::compose_findings;
415 findings = compose_findings(findings);
416
417 findings.sort_by(|a, b| {
419 severity_rank(&b.severity)
420 .cmp(&severity_rank(&a.severity))
421 .then(a.file.cmp(&b.file))
422 .then(a.line.cmp(&b.line))
423 });
424
425 let summary = build_summary_with_l1(
427 &findings,
428 l1_count,
429 l2_count,
430 files_analyzed,
431 functions_analyzed,
432 &tool_results,
433 );
434 let elapsed_ms = start.elapsed().as_millis() as u64;
435
436 let report = BugbotCheckReport {
438 tool: "bugbot".to_string(),
439 mode: "check".to_string(),
440 language: language_str,
441 base_ref: self.base_ref.clone(),
442 detection_method: changes.detection_method,
443 timestamp: chrono::Utc::now().to_rfc3339(),
444 changed_files: changes.changed_files,
445 findings,
446 summary,
447 elapsed_ms,
448 errors,
449 notes,
450 tool_results,
451 tools_available,
452 tools_missing,
453 l2_engine_results,
454 };
455
456 if writer.is_text() {
458 writer.write_text(&format_bugbot_text(&report))?;
459 } else {
460 writer.write(&report)?;
461 }
462
463 let has_findings = !report.findings.is_empty();
471 let has_errors = !report.errors.is_empty();
472 let has_critical = report.findings.iter().any(|f| f.severity == "critical");
473
474 if has_critical && !self.no_fail {
476 return Err(BugbotExitError::CriticalFindings {
477 count: report
478 .findings
479 .iter()
480 .filter(|f| f.severity == "critical")
481 .count(),
482 }
483 .into());
484 }
485
486 if has_findings && !self.no_fail {
487 return Err(BugbotExitError::FindingsDetected {
488 count: report.findings.len(),
489 }
490 .into());
491 }
492
493 if !has_findings && has_errors && !self.no_fail {
494 return Err(BugbotExitError::AnalysisErrors {
495 count: report.errors.len(),
496 }
497 .into());
498 }
499
500 Ok(())
501 }
502}
503
504fn run_l1_tools_opt(
518 project_root: &std::path::Path,
519 language: &str,
520 no_tools: bool,
521 timeout_secs: u64,
522) -> (Vec<L1Finding>, Vec<ToolResult>, Vec<String>, Vec<String>) {
523 if no_tools {
524 return (Vec::new(), Vec::new(), Vec::new(), Vec::new());
525 }
526
527 let registry = ToolRegistry::new();
528 let (available, missing) = registry.detect_available_tools(language);
529
530 let available_names: Vec<String> = available.iter().map(|t| t.name.to_string()).collect();
531 let missing_names: Vec<String> = missing.iter().map(|t| t.name.to_string()).collect();
532
533 if available.is_empty() {
534 return (Vec::new(), Vec::new(), available_names, missing_names);
535 }
536
537 let runner = ToolRunner::new(timeout_secs);
538 let (tool_results, l1_findings) = runner.run_tools_parallel(&available, project_root);
539
540 (l1_findings, tool_results, available_names, missing_names)
541}
542
543fn severity_rank(severity: &str) -> u8 {
548 match severity {
549 "critical" => 5,
550 "high" => 4,
551 "medium" => 3,
552 "low" => 2,
553 "info" => 1,
554 _ => 0,
555 }
556}
557
558fn build_summary(
560 findings: &[BugbotFinding],
561 files_analyzed: usize,
562 functions_analyzed: usize,
563) -> BugbotSummary {
564 build_summary_with_l1(findings, 0, findings.len(), files_analyzed, functions_analyzed, &[])
565}
566
567fn build_summary_with_l1(
576 findings: &[BugbotFinding],
577 l1_count: usize,
578 l2_count: usize,
579 files_analyzed: usize,
580 functions_analyzed: usize,
581 tool_results: &[super::tools::ToolResult],
582) -> BugbotSummary {
583 let mut by_severity: HashMap<String, usize> = HashMap::new();
584 let mut by_type: HashMap<String, usize> = HashMap::new();
585
586 for f in findings {
587 *by_severity.entry(f.severity.clone()).or_insert(0) += 1;
588 *by_type.entry(f.finding_type.clone()).or_insert(0) += 1;
589 }
590
591 let tools_run = tool_results.len();
592 let tools_failed = tool_results.iter().filter(|r| !r.success).count();
593
594 let actual_l1 = findings
598 .iter()
599 .filter(|f| f.finding_type.starts_with("tool:"))
600 .count();
601 let actual_l2 = findings.len() - actual_l1;
602
603 let final_l1 = if actual_l1 + actual_l2 != l1_count + l2_count {
605 actual_l1
606 } else {
607 l1_count
608 };
609 let final_l2 = if actual_l1 + actual_l2 != l1_count + l2_count {
610 actual_l2
611 } else {
612 l2_count
613 };
614
615 BugbotSummary {
616 total_findings: findings.len(),
617 by_severity,
618 by_type,
619 files_analyzed,
620 functions_analyzed,
621 l1_findings: final_l1,
622 l2_findings: final_l2,
623 tools_run,
624 tools_failed,
625 }
626}
627
628fn filter_l1_findings(
634 findings: Vec<BugbotFinding>,
635 changed_files: &[PathBuf],
636) -> Vec<BugbotFinding> {
637 if changed_files.is_empty() {
638 return findings;
639 }
640 findings
641 .into_iter()
642 .filter(|f| {
643 changed_files.iter().any(|cf| {
644 cf == &f.file
646 || f.file.ends_with(cf)
648 || cf.ends_with(&f.file)
649 })
650 })
651 .collect()
652}
653
654fn run_single_engine(
659 engine: &dyn super::l2::L2Engine,
660 ctx: &super::l2::L2Context,
661) -> (Vec<BugbotFinding>, L2AnalyzerResult) {
662 let supported = engine.languages();
665 if !supported.is_empty() && !supported.contains(&ctx.language) {
666 return (
667 Vec::new(),
668 L2AnalyzerResult {
669 name: engine.name().to_string(),
670 success: true,
671 duration_ms: 0,
672 finding_count: 0,
673 functions_analyzed: 0,
674 functions_skipped: 0,
675 status: format!(
676 "Skipped: {} does not support {:?}",
677 engine.name(),
678 ctx.language
679 ),
680 errors: vec![],
681 },
682 );
683 }
684
685 let start = Instant::now();
686 let output = engine.analyze(ctx);
687 let duration = start.elapsed().as_millis() as u64;
688
689 let status_str = match &output.status {
690 AnalyzerStatus::Complete => "complete".to_string(),
691 AnalyzerStatus::Partial { reason } => format!("partial ({})", reason),
692 AnalyzerStatus::Skipped { reason } => format!("skipped ({})", reason),
693 AnalyzerStatus::TimedOut { partial_findings } => {
694 format!("timed out ({} partial findings)", partial_findings)
695 }
696 };
697
698 let errors = match &output.status {
699 AnalyzerStatus::Partial { reason } => vec![reason.clone()],
700 AnalyzerStatus::TimedOut { .. } => vec!["Engine timed out".to_string()],
701 _ => vec![],
702 };
703
704 let result = L2AnalyzerResult {
705 name: engine.name().to_string(),
706 success: matches!(output.status, AnalyzerStatus::Complete),
707 duration_ms: duration,
708 finding_count: output.findings.len(),
709 functions_analyzed: output.functions_analyzed,
710 functions_skipped: output.functions_skipped,
711 status: status_str,
712 errors,
713 };
714
715 (output.findings, result)
716}
717
718fn run_l2_engines(
723 ctx: &super::l2::L2Context,
724 engines: &[Box<dyn super::l2::L2Engine>],
725) -> (Vec<BugbotFinding>, Vec<L2AnalyzerResult>) {
726 let mut all_findings = Vec::new();
727 let mut results = Vec::new();
728
729 for engine in engines {
730 let (findings, result) = run_single_engine(engine.as_ref(), ctx);
731 all_findings.extend(findings);
732 results.push(result);
733 }
734
735 (all_findings, results)
736}
737
738fn build_function_diff(
749 all_diffs: &HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>>,
750 project: &std::path::Path,
751) -> super::l2::context::FunctionDiff {
752 use super::l2::context::{DeletedFunction, FunctionChange, FunctionDiff, InsertedFunction};
753 use super::l2::types::FunctionId;
754 use crate::commands::remaining::types::{ChangeType, NodeKind};
755
756 let mut changed_fns = Vec::new();
757 let mut inserted_fns = Vec::new();
758 let mut deleted_fns = Vec::new();
759
760 for (abs_path, changes) in all_diffs {
761 let rel_path = abs_path
762 .strip_prefix(project)
763 .unwrap_or(abs_path)
764 .to_path_buf();
765
766 for change in changes {
767 if !matches!(change.node_kind, NodeKind::Function | NodeKind::Method) {
769 continue;
770 }
771
772 let name = match &change.name {
773 Some(n) => n.clone(),
774 None => continue, };
776
777 let def_line = change
778 .new_location
779 .as_ref()
780 .or(change.old_location.as_ref())
781 .map(|loc| loc.line as usize)
782 .unwrap_or(0);
783
784 let func_id = FunctionId::new(rel_path.clone(), &name, def_line);
785
786 match change.change_type {
787 ChangeType::Update => {
788 let old_source = change.old_text.clone().unwrap_or_default();
789 let new_source = change.new_text.clone().unwrap_or_default();
790 changed_fns.push(FunctionChange {
791 id: func_id,
792 name: name.clone(),
793 old_source,
794 new_source,
795 });
796 }
797 ChangeType::Insert => {
798 let source = change.new_text.clone().unwrap_or_default();
799 inserted_fns.push(InsertedFunction {
800 id: func_id,
801 name: name.clone(),
802 source,
803 });
804 }
805 ChangeType::Delete => {
806 deleted_fns.push(DeletedFunction {
807 id: func_id,
808 name: name.clone(),
809 });
810 }
811 ChangeType::Move
812 | ChangeType::Rename
813 | ChangeType::Extract
814 | ChangeType::Inline
815 | ChangeType::Format => {
816 if change.old_text.is_some() && change.new_text.is_some() {
818 changed_fns.push(FunctionChange {
819 id: func_id,
820 name: name.clone(),
821 old_source: change.old_text.clone().unwrap_or_default(),
822 new_source: change.new_text.clone().unwrap_or_default(),
823 });
824 }
825 }
826 }
827 }
828 }
829
830 FunctionDiff {
831 changed: changed_fns,
832 inserted: inserted_fns,
833 deleted: deleted_fns,
834 }
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 #[test]
846 fn test_severity_rank_ordering() {
847 assert_eq!(severity_rank("critical"), 5);
848 assert_eq!(severity_rank("high"), 4);
849 assert_eq!(severity_rank("medium"), 3);
850 assert_eq!(severity_rank("low"), 2);
851 assert_eq!(severity_rank("info"), 1); assert_eq!(severity_rank(""), 0);
853 }
854
855 #[test]
857 fn test_severity_rank_critical() {
858 assert_eq!(severity_rank("critical"), 5);
859 assert!(
860 severity_rank("critical") > severity_rank("high"),
861 "critical ({}) should rank above high ({})",
862 severity_rank("critical"),
863 severity_rank("high"),
864 );
865 }
866
867 #[test]
868 fn test_build_summary_empty() {
869 let summary = build_summary(&[], 0, 0);
870 assert_eq!(summary.total_findings, 0);
871 assert!(summary.by_severity.is_empty());
872 assert!(summary.by_type.is_empty());
873 assert_eq!(summary.files_analyzed, 0);
874 assert_eq!(summary.functions_analyzed, 0);
875 }
876
877 #[test]
878 fn test_build_summary_counts() {
879 let findings = vec![
880 BugbotFinding {
881 finding_type: "signature-regression".to_string(),
882 severity: "high".to_string(),
883 file: PathBuf::from("a.rs"),
884 function: "foo".to_string(),
885 line: 10,
886 message: "param removed".to_string(),
887 evidence: serde_json::Value::Null,
888 confidence: None,
889 finding_id: None,
890 },
891 BugbotFinding {
892 finding_type: "born-dead".to_string(),
893 severity: "low".to_string(),
894 file: PathBuf::from("b.rs"),
895 function: "bar".to_string(),
896 line: 20,
897 message: "no callers".to_string(),
898 evidence: serde_json::Value::Null,
899 confidence: None,
900 finding_id: None,
901 },
902 BugbotFinding {
903 finding_type: "signature-regression".to_string(),
904 severity: "high".to_string(),
905 file: PathBuf::from("c.rs"),
906 function: "baz".to_string(),
907 line: 5,
908 message: "return type changed".to_string(),
909 evidence: serde_json::Value::Null,
910 confidence: None,
911 finding_id: None,
912 },
913 ];
914
915 let summary = build_summary(&findings, 3, 10);
916 assert_eq!(summary.total_findings, 3);
917 assert_eq!(summary.by_severity.get("high"), Some(&2));
918 assert_eq!(summary.by_severity.get("low"), Some(&1));
919 assert_eq!(summary.by_type.get("signature-regression"), Some(&2));
920 assert_eq!(summary.by_type.get("born-dead"), Some(&1));
921 assert_eq!(summary.files_analyzed, 3);
922 assert_eq!(summary.functions_analyzed, 10);
923 }
924
925 #[test]
926 fn test_findings_sort_severity_first() {
927 let mut findings = [
928 BugbotFinding {
929 finding_type: "born-dead".to_string(),
930 severity: "low".to_string(),
931 file: PathBuf::from("a.rs"),
932 function: "f1".to_string(),
933 line: 1,
934 message: String::new(),
935 evidence: serde_json::Value::Null,
936 confidence: None,
937 finding_id: None,
938 },
939 BugbotFinding {
940 finding_type: "signature-regression".to_string(),
941 severity: "high".to_string(),
942 file: PathBuf::from("z.rs"),
943 function: "f2".to_string(),
944 line: 100,
945 message: String::new(),
946 evidence: serde_json::Value::Null,
947 confidence: None,
948 finding_id: None,
949 },
950 BugbotFinding {
951 finding_type: "born-dead".to_string(),
952 severity: "medium".to_string(),
953 file: PathBuf::from("b.rs"),
954 function: "f3".to_string(),
955 line: 50,
956 message: String::new(),
957 evidence: serde_json::Value::Null,
958 confidence: None,
959 finding_id: None,
960 },
961 ];
962
963 findings.sort_by(|a, b| {
964 severity_rank(&b.severity)
965 .cmp(&severity_rank(&a.severity))
966 .then(a.file.cmp(&b.file))
967 .then(a.line.cmp(&b.line))
968 });
969
970 assert_eq!(findings[0].severity, "high");
971 assert_eq!(findings[1].severity, "medium");
972 assert_eq!(findings[2].severity, "low");
973 }
974
975 #[test]
976 fn test_findings_sort_file_then_line_within_same_severity() {
977 let mut findings = [
978 BugbotFinding {
979 finding_type: "sig".to_string(),
980 severity: "high".to_string(),
981 file: PathBuf::from("z.rs"),
982 function: "f1".to_string(),
983 line: 10,
984 message: String::new(),
985 evidence: serde_json::Value::Null,
986 confidence: None,
987 finding_id: None,
988 },
989 BugbotFinding {
990 finding_type: "sig".to_string(),
991 severity: "high".to_string(),
992 file: PathBuf::from("a.rs"),
993 function: "f2".to_string(),
994 line: 50,
995 message: String::new(),
996 evidence: serde_json::Value::Null,
997 confidence: None,
998 finding_id: None,
999 },
1000 BugbotFinding {
1001 finding_type: "sig".to_string(),
1002 severity: "high".to_string(),
1003 file: PathBuf::from("a.rs"),
1004 function: "f3".to_string(),
1005 line: 5,
1006 message: String::new(),
1007 evidence: serde_json::Value::Null,
1008 confidence: None,
1009 finding_id: None,
1010 },
1011 ];
1012
1013 findings.sort_by(|a, b| {
1014 severity_rank(&b.severity)
1015 .cmp(&severity_rank(&a.severity))
1016 .then(a.file.cmp(&b.file))
1017 .then(a.line.cmp(&b.line))
1018 });
1019
1020 assert_eq!(findings[0].file, PathBuf::from("a.rs"));
1022 assert_eq!(findings[0].line, 5);
1023 assert_eq!(findings[1].file, PathBuf::from("a.rs"));
1024 assert_eq!(findings[1].line, 50);
1025 assert_eq!(findings[2].file, PathBuf::from("z.rs"));
1026 }
1027
1028 #[test]
1029 fn test_findings_truncation() {
1030 let max_findings = 2;
1031 let mut findings: Vec<BugbotFinding> = (0..5)
1032 .map(|i| BugbotFinding {
1033 finding_type: "test".to_string(),
1034 severity: "low".to_string(),
1035 file: PathBuf::from(format!("f{}.rs", i)),
1036 function: format!("fn_{}", i),
1037 line: i,
1038 message: String::new(),
1039 evidence: serde_json::Value::Null,
1040 confidence: None,
1041 finding_id: None,
1042 })
1043 .collect();
1044
1045 let mut notes: Vec<String> = Vec::new();
1046 if findings.len() > max_findings {
1047 notes.push(format!("truncated_to_{}", max_findings));
1048 findings.truncate(max_findings);
1049 }
1050
1051 assert_eq!(findings.len(), 2);
1052 assert_eq!(notes, vec!["truncated_to_2"]);
1053 }
1054
1055 fn init_git_repo() -> tempfile::TempDir {
1061 let tmp = tempfile::TempDir::new().expect("create temp dir");
1062 let dir = tmp.path();
1063
1064 std::process::Command::new("git")
1065 .args(["init"])
1066 .current_dir(dir)
1067 .output()
1068 .expect("git init");
1069
1070 std::process::Command::new("git")
1071 .args(["config", "user.email", "test@test.com"])
1072 .current_dir(dir)
1073 .output()
1074 .expect("git config email");
1075
1076 std::process::Command::new("git")
1077 .args(["config", "user.name", "Test"])
1078 .current_dir(dir)
1079 .output()
1080 .expect("git config name");
1081
1082 std::fs::write(dir.join("lib.rs"), "fn placeholder() {}\n").expect("write lib.rs");
1084 std::process::Command::new("git")
1085 .args(["add", "."])
1086 .current_dir(dir)
1087 .output()
1088 .expect("git add");
1089 std::process::Command::new("git")
1090 .args(["commit", "-m", "init"])
1091 .current_dir(dir)
1092 .output()
1093 .expect("git commit");
1094
1095 tmp
1096 }
1097
1098 #[test]
1099 fn test_run_no_changes_produces_empty_report() {
1100 let tmp = init_git_repo();
1101 let args = BugbotCheckArgs {
1102 path: tmp.path().to_path_buf(),
1103 base_ref: "HEAD".to_string(),
1104 staged: false,
1105 max_findings: 50,
1106 no_fail: false,
1107 quiet: true,
1108 no_tools: false,
1109 tool_timeout: 60,
1110 };
1111
1112 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1114 assert!(result.is_ok(), "run() should succeed: {:?}", result.err());
1115 }
1116
1117 #[test]
1118 fn test_run_with_signature_change_finds_regression() {
1119 let tmp = init_git_repo();
1120 let dir = tmp.path();
1121
1122 let original = "pub fn compute(x: i32, y: i32) -> i32 {\n x + y\n}\n";
1124 std::fs::write(dir.join("lib.rs"), original).expect("write lib.rs");
1125 std::process::Command::new("git")
1126 .args(["add", "lib.rs"])
1127 .current_dir(dir)
1128 .output()
1129 .expect("git add");
1130 std::process::Command::new("git")
1131 .args(["commit", "-m", "add compute"])
1132 .current_dir(dir)
1133 .output()
1134 .expect("git commit");
1135
1136 let modified = "pub fn compute(x: i32) -> i32 {\n x * 2\n}\n";
1138 std::fs::write(dir.join("lib.rs"), modified).expect("overwrite lib.rs");
1139
1140 let args = BugbotCheckArgs {
1141 path: dir.to_path_buf(),
1142 base_ref: "HEAD".to_string(),
1143 staged: false,
1144 max_findings: 50,
1145 no_fail: true,
1146 quiet: true,
1147 no_tools: false,
1148 tool_timeout: 60,
1149 };
1150
1151 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1154 assert!(result.is_ok(), "run() should succeed: {:?}", result.err());
1155 }
1156
1157 #[test]
1158 fn test_run_new_file_produces_insert_changes() {
1159 let tmp = init_git_repo();
1160 let dir = tmp.path();
1161
1162 let new_code = "fn brand_new_function() {\n println!(\"hello\");\n}\n";
1164 std::fs::write(dir.join("new_module.rs"), new_code).expect("write new_module.rs");
1165
1166 let args = BugbotCheckArgs {
1167 path: dir.to_path_buf(),
1168 base_ref: "HEAD".to_string(),
1169 staged: false,
1170 max_findings: 50,
1171 no_fail: true,
1172 quiet: true,
1173 no_tools: false,
1174 tool_timeout: 60,
1175 };
1176
1177 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1180 assert!(result.is_ok(), "run() should succeed with no_fail: {:?}", result.err());
1181 }
1182
1183 #[test]
1184 fn test_run_elapsed_ms_is_populated() {
1185 let start = Instant::now();
1187 std::thread::sleep(std::time::Duration::from_millis(1));
1188 let elapsed_ms = start.elapsed().as_millis() as u64;
1189 assert!(elapsed_ms >= 1, "Instant timing should work");
1190 }
1191
1192 #[test]
1197 fn test_run_findings_without_no_fail_returns_error() {
1198 let tmp = init_git_repo();
1201 let dir = tmp.path();
1202
1203 let original = "pub fn compute(x: i32, y: i32) -> i32 {\n x + y\n}\n";
1204 std::fs::write(dir.join("lib.rs"), original).expect("write lib.rs");
1205 std::process::Command::new("git")
1206 .args(["add", "lib.rs"])
1207 .current_dir(dir)
1208 .output()
1209 .expect("git add");
1210 std::process::Command::new("git")
1211 .args(["commit", "-m", "add compute"])
1212 .current_dir(dir)
1213 .output()
1214 .expect("git commit");
1215
1216 let modified = "pub fn compute(x: i32) -> i32 {\n x * 2\n}\n";
1217 std::fs::write(dir.join("lib.rs"), modified).expect("overwrite lib.rs");
1218
1219 let args = BugbotCheckArgs {
1220 path: dir.to_path_buf(),
1221 base_ref: "HEAD".to_string(),
1222 staged: false,
1223 max_findings: 50,
1224 no_fail: false,
1225 quiet: true,
1226 no_tools: false,
1227 tool_timeout: 60,
1228 };
1229
1230 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1231 assert!(result.is_err(), "run() should return Err when findings exist");
1232
1233 let err = result.unwrap_err();
1235 use crate::commands::bugbot::BugbotExitError;
1236 let bugbot_err = err
1237 .downcast_ref::<BugbotExitError>()
1238 .expect("error should be BugbotExitError");
1239 assert_eq!(bugbot_err.exit_code(), 1, "exit code should be 1 for findings");
1240 }
1241
1242 #[test]
1247 fn test_max_findings_zero_means_unlimited() {
1248 let max_findings: usize = 0;
1250 let mut findings: Vec<BugbotFinding> = (0..5)
1251 .map(|i| BugbotFinding {
1252 finding_type: "test".to_string(),
1253 severity: "low".to_string(),
1254 file: PathBuf::from(format!("f{}.rs", i)),
1255 function: format!("fn_{}", i),
1256 line: i,
1257 message: String::new(),
1258 evidence: serde_json::Value::Null,
1259 confidence: None,
1260 finding_id: None,
1261 })
1262 .collect();
1263
1264 let mut notes: Vec<String> = Vec::new();
1265 if max_findings > 0 && findings.len() > max_findings {
1266 notes.push(format!("truncated_to_{}", max_findings));
1267 findings.truncate(max_findings);
1268 }
1269
1270 assert_eq!(findings.len(), 5, "max_findings=0 should not truncate");
1271 assert!(notes.is_empty(), "no truncation note with max_findings=0");
1272 }
1273
1274 #[test]
1279 fn test_severity_rank_info_below_low() {
1280 assert!(
1282 severity_rank("info") < severity_rank("low"),
1283 "info ({}) should rank below low ({})",
1284 severity_rank("info"),
1285 severity_rank("low")
1286 );
1287 assert!(
1288 severity_rank("info") > 0,
1289 "PM-8: info should have an explicit rank > 0, not wildcard"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_filter_l1_findings_to_changed_files() {
1295 let l1_findings: Vec<BugbotFinding> = vec![
1297 BugbotFinding {
1298 finding_type: "tool:clippy".to_string(),
1299 severity: "medium".to_string(),
1300 file: PathBuf::from("src/main.rs"),
1301 function: String::new(),
1302 line: 10,
1303 message: "warning in changed file".to_string(),
1304 evidence: serde_json::Value::Null,
1305 confidence: None,
1306 finding_id: None,
1307 },
1308 BugbotFinding {
1309 finding_type: "tool:clippy".to_string(),
1310 severity: "low".to_string(),
1311 file: PathBuf::from("src/untouched.rs"),
1312 function: String::new(),
1313 line: 5,
1314 message: "warning in untouched file".to_string(),
1315 evidence: serde_json::Value::Null,
1316 confidence: None,
1317 finding_id: None,
1318 },
1319 BugbotFinding {
1320 finding_type: "tool:clippy".to_string(),
1321 severity: "high".to_string(),
1322 file: PathBuf::from("src/lib.rs"),
1323 function: String::new(),
1324 line: 20,
1325 message: "error in changed file".to_string(),
1326 evidence: serde_json::Value::Null,
1327 confidence: None,
1328 finding_id: None,
1329 },
1330 ];
1331
1332 let changed_files: Vec<PathBuf> = vec![
1333 PathBuf::from("src/main.rs"),
1334 PathBuf::from("src/lib.rs"),
1335 ];
1336
1337 let filtered = filter_l1_findings(l1_findings, &changed_files);
1338
1339 assert_eq!(filtered.len(), 2, "should keep only 2 findings matching changed files");
1340 let untouched = std::path::Path::new("src/untouched.rs");
1341 assert!(
1342 filtered.iter().all(|f| f.file != untouched),
1343 "PM-3: untouched file findings should be excluded"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_filter_l1_findings_empty_changed_files_keeps_all() {
1349 let l1_findings: Vec<BugbotFinding> = vec![
1351 BugbotFinding {
1352 finding_type: "tool:clippy".to_string(),
1353 severity: "medium".to_string(),
1354 file: PathBuf::from("src/main.rs"),
1355 function: String::new(),
1356 line: 10,
1357 message: "warning".to_string(),
1358 evidence: serde_json::Value::Null,
1359 confidence: None,
1360 finding_id: None,
1361 },
1362 BugbotFinding {
1363 finding_type: "tool:clippy".to_string(),
1364 severity: "low".to_string(),
1365 file: PathBuf::from("src/other.rs"),
1366 function: String::new(),
1367 line: 5,
1368 message: "another warning".to_string(),
1369 evidence: serde_json::Value::Null,
1370 confidence: None,
1371 finding_id: None,
1372 },
1373 ];
1374
1375 let changed_files: Vec<PathBuf> = vec![];
1376
1377 let filtered = filter_l1_findings(l1_findings, &changed_files);
1378
1379 assert_eq!(filtered.len(), 2, "empty changed_files should keep all findings");
1380 }
1381
1382 #[test]
1383 fn test_build_summary_with_l1_and_l2() {
1384 let l2_findings = vec![
1386 BugbotFinding {
1387 finding_type: "signature-regression".to_string(),
1388 severity: "high".to_string(),
1389 file: PathBuf::from("a.rs"),
1390 function: "foo".to_string(),
1391 line: 10,
1392 message: "param removed".to_string(),
1393 evidence: serde_json::Value::Null,
1394 confidence: None,
1395 finding_id: None,
1396 },
1397 ];
1398 let l1_findings = vec![
1399 BugbotFinding {
1400 finding_type: "tool:clippy".to_string(),
1401 severity: "medium".to_string(),
1402 file: PathBuf::from("b.rs"),
1403 function: String::new(),
1404 line: 5,
1405 message: "unused var".to_string(),
1406 evidence: serde_json::Value::Null,
1407 confidence: None,
1408 finding_id: None,
1409 },
1410 BugbotFinding {
1411 finding_type: "tool:cargo-audit".to_string(),
1412 severity: "high".to_string(),
1413 file: PathBuf::from("Cargo.lock"),
1414 function: String::new(),
1415 line: 1,
1416 message: "vuln".to_string(),
1417 evidence: serde_json::Value::Null,
1418 confidence: None,
1419 finding_id: None,
1420 },
1421 ];
1422
1423 let tool_results = vec![
1424 super::super::tools::ToolResult {
1425 name: "clippy".to_string(),
1426 category: super::super::tools::ToolCategory::Linter,
1427 success: true,
1428 duration_ms: 100,
1429 finding_count: 1,
1430 error: None,
1431 exit_code: Some(0),
1432 },
1433 super::super::tools::ToolResult {
1434 name: "cargo-audit".to_string(),
1435 category: super::super::tools::ToolCategory::SecurityScanner,
1436 success: true,
1437 duration_ms: 50,
1438 finding_count: 1,
1439 error: None,
1440 exit_code: Some(0),
1441 },
1442 ];
1443
1444 let mut all_findings = Vec::new();
1445 all_findings.extend(l1_findings.clone());
1446 all_findings.extend(l2_findings.clone());
1447
1448 let summary = build_summary_with_l1(
1449 &all_findings,
1450 l1_findings.len(),
1451 l2_findings.len(),
1452 5,
1453 20,
1454 &tool_results,
1455 );
1456
1457 assert_eq!(summary.total_findings, 3);
1458 assert_eq!(summary.l1_findings, 2);
1459 assert_eq!(summary.l2_findings, 1);
1460 assert_eq!(summary.tools_run, 2);
1461 assert_eq!(summary.tools_failed, 0);
1462 assert_eq!(summary.files_analyzed, 5);
1463 assert_eq!(summary.functions_analyzed, 20);
1464 }
1465
1466 #[test]
1467 fn test_build_summary_with_l1_counts_failed_tools() {
1468 let findings: Vec<BugbotFinding> = vec![];
1469 let tool_results = vec![
1470 super::super::tools::ToolResult {
1471 name: "clippy".to_string(),
1472 category: super::super::tools::ToolCategory::Linter,
1473 success: true,
1474 duration_ms: 100,
1475 finding_count: 0,
1476 error: None,
1477 exit_code: Some(0),
1478 },
1479 super::super::tools::ToolResult {
1480 name: "cargo-audit".to_string(),
1481 category: super::super::tools::ToolCategory::SecurityScanner,
1482 success: false,
1483 duration_ms: 50,
1484 finding_count: 0,
1485 error: Some("binary not found".to_string()),
1486 exit_code: None,
1487 },
1488 ];
1489
1490 let summary = build_summary_with_l1(&findings, 0, 0, 3, 10, &tool_results);
1491
1492 assert_eq!(summary.tools_run, 2);
1493 assert_eq!(summary.tools_failed, 1);
1494 }
1495
1496 #[test]
1497 fn test_no_tools_available_graceful_degradation() {
1498 let l2_findings = vec![
1502 BugbotFinding {
1503 finding_type: "born-dead".to_string(),
1504 severity: "low".to_string(),
1505 file: PathBuf::from("src/lib.rs"),
1506 function: "dead_fn".to_string(),
1507 line: 10,
1508 message: "no callers".to_string(),
1509 evidence: serde_json::Value::Null,
1510 confidence: None,
1511 finding_id: None,
1512 },
1513 ];
1514
1515 let summary = build_summary_with_l1(&l2_findings, 0, 1, 1, 5, &[]);
1516
1517 assert_eq!(summary.total_findings, 1);
1518 assert_eq!(summary.l1_findings, 0);
1519 assert_eq!(summary.l2_findings, 1);
1520 assert_eq!(summary.tools_run, 0);
1521 assert_eq!(summary.tools_failed, 0);
1522 }
1523
1524 #[test]
1529 fn test_no_tools_flag_defaults_to_false() {
1530 let args = BugbotCheckArgs {
1531 path: PathBuf::from("."),
1532 base_ref: "HEAD".to_string(),
1533 staged: false,
1534 max_findings: 50,
1535 no_fail: false,
1536 quiet: true,
1537 no_tools: false,
1538 tool_timeout: 60,
1539 };
1540 assert!(!args.no_tools);
1541 }
1542
1543 #[test]
1544 fn test_tool_timeout_default_is_60() {
1545 let args = BugbotCheckArgs {
1546 path: PathBuf::from("."),
1547 base_ref: "HEAD".to_string(),
1548 staged: false,
1549 max_findings: 50,
1550 no_fail: false,
1551 quiet: true,
1552 no_tools: false,
1553 tool_timeout: 60,
1554 };
1555 assert_eq!(args.tool_timeout, 60);
1556 }
1557
1558 #[test]
1559 fn test_no_tools_skips_l1_analysis() {
1560 let tmp = init_git_repo();
1563 let dir = tmp.path();
1564
1565 let code = "pub fn hello() { println!(\"hello\"); }\n";
1567 std::fs::write(dir.join("lib.rs"), code).expect("write lib.rs");
1568 std::process::Command::new("git")
1569 .args(["add", "lib.rs"])
1570 .current_dir(dir)
1571 .output()
1572 .expect("git add");
1573 std::process::Command::new("git")
1574 .args(["commit", "-m", "add hello"])
1575 .current_dir(dir)
1576 .output()
1577 .expect("git commit");
1578
1579 let modified = "pub fn hello(name: &str) { println!(\"hello {}\", name); }\n";
1581 std::fs::write(dir.join("lib.rs"), modified).expect("overwrite lib.rs");
1582
1583 let args = BugbotCheckArgs {
1584 path: dir.to_path_buf(),
1585 base_ref: "HEAD".to_string(),
1586 staged: false,
1587 max_findings: 50,
1588 no_fail: true,
1589 quiet: true,
1590 no_tools: true,
1591 tool_timeout: 60,
1592 };
1593
1594 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1596 assert!(result.is_ok(), "run() should succeed with --no-tools: {:?}", result.err());
1598 }
1599
1600 #[test]
1601 fn test_no_tools_report_has_no_l1_data() {
1602 let (l1_findings, tool_results, available, missing) =
1606 run_l1_tools_opt(std::path::Path::new("/nonexistent"), "rust", true, 60);
1607
1608 assert!(l1_findings.is_empty(), "no_tools should produce empty L1 findings");
1609 assert!(tool_results.is_empty(), "no_tools should produce empty tool_results");
1610 assert!(available.is_empty(), "no_tools should produce empty tools_available");
1611 assert!(missing.is_empty(), "no_tools should produce empty tools_missing");
1612 }
1613
1614 #[test]
1615 fn test_tool_timeout_passed_to_runner() {
1616 let (_l1, _results, _avail, _missing) =
1622 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", false, 5);
1623 }
1625
1626 #[test]
1627 fn test_no_tools_no_changes_report() {
1628 let tmp = init_git_repo();
1630 let args = BugbotCheckArgs {
1631 path: tmp.path().to_path_buf(),
1632 base_ref: "HEAD".to_string(),
1633 staged: false,
1634 max_findings: 50,
1635 no_fail: false,
1636 quiet: true,
1637 no_tools: true,
1638 tool_timeout: 60,
1639 };
1640
1641 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1642 assert!(result.is_ok(), "no-tools + no-changes should succeed: {:?}", result.err());
1643 }
1644
1645 #[test]
1646 fn test_default_behavior_without_flags_runs_l1() {
1647 let (_l1, _results, available, _missing) =
1650 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", false, 60);
1651 let _ = available; }
1657
1658 #[test]
1663 fn test_build_summary_l1_l2_counts_reflect_actual_findings() {
1664 let mut all_findings: Vec<BugbotFinding> = Vec::new();
1671
1672 for i in 0..5 {
1674 all_findings.push(BugbotFinding {
1675 finding_type: "tool:clippy".to_string(),
1676 severity: "medium".to_string(),
1677 file: PathBuf::from(format!("l1_{}.rs", i)),
1678 function: String::new(),
1679 line: i,
1680 message: "lint".to_string(),
1681 evidence: serde_json::Value::Null,
1682 confidence: None,
1683 finding_id: None,
1684 });
1685 }
1686
1687 for i in 0..3 {
1689 all_findings.push(BugbotFinding {
1690 finding_type: "signature-regression".to_string(),
1691 severity: "high".to_string(),
1692 file: PathBuf::from(format!("l2_{}.rs", i)),
1693 function: format!("fn_{}", i),
1694 line: i + 100,
1695 message: "param removed".to_string(),
1696 evidence: serde_json::Value::Null,
1697 confidence: None,
1698 finding_id: None,
1699 });
1700 }
1701
1702 all_findings.truncate(4);
1704
1705 let actual_l1 = all_findings.iter().filter(|f| f.finding_type.starts_with("tool:")).count();
1707 let actual_l2 = all_findings.iter().filter(|f| !f.finding_type.starts_with("tool:")).count();
1708
1709 let summary = build_summary_with_l1(
1710 &all_findings,
1711 actual_l1,
1712 actual_l2,
1713 3,
1714 10,
1715 &[],
1716 );
1717
1718 assert_eq!(
1720 summary.total_findings,
1721 summary.l1_findings + summary.l2_findings,
1722 "total_findings ({}) should equal l1_findings ({}) + l2_findings ({})",
1723 summary.total_findings,
1724 summary.l1_findings,
1725 summary.l2_findings
1726 );
1727
1728 assert_eq!(summary.total_findings, 4, "should reflect truncated count");
1730 assert_eq!(summary.l1_findings, actual_l1, "l1 should reflect post-truncation count");
1731 assert_eq!(summary.l2_findings, actual_l2, "l2 should reflect post-truncation count");
1732 }
1733
1734 #[test]
1735 fn test_summary_counts_consistent_after_heavy_truncation() {
1736 let mut all_findings: Vec<BugbotFinding> = Vec::new();
1738
1739 for i in 0..10 {
1741 all_findings.push(BugbotFinding {
1742 finding_type: "tool:clippy".to_string(),
1743 severity: "medium".to_string(),
1744 file: PathBuf::from(format!("l1_{}.rs", i)),
1745 function: String::new(),
1746 line: i,
1747 message: "lint".to_string(),
1748 evidence: serde_json::Value::Null,
1749 confidence: None,
1750 finding_id: None,
1751 });
1752 }
1753
1754 for i in 0..10 {
1756 all_findings.push(BugbotFinding {
1757 finding_type: "born-dead".to_string(),
1758 severity: "low".to_string(),
1759 file: PathBuf::from(format!("l2_{}.rs", i)),
1760 function: format!("fn_{}", i),
1761 line: i + 100,
1762 message: "no callers".to_string(),
1763 evidence: serde_json::Value::Null,
1764 confidence: None,
1765 finding_id: None,
1766 });
1767 }
1768
1769 let pre_l1 = 10;
1771 let pre_l2 = 10;
1772 assert_eq!(all_findings.len(), 20);
1773
1774 all_findings.truncate(1);
1776
1777 let _post_l1 = all_findings.iter().filter(|f| f.finding_type.starts_with("tool:")).count();
1779 let _post_l2 = all_findings.iter().filter(|f| !f.finding_type.starts_with("tool:")).count();
1780
1781 let bad_summary = build_summary_with_l1(
1783 &all_findings,
1784 pre_l1,
1785 pre_l2,
1786 3,
1787 10,
1788 &[],
1789 );
1790
1791 assert_eq!(
1794 bad_summary.total_findings,
1795 bad_summary.l1_findings + bad_summary.l2_findings,
1796 "F14: total ({}) must equal l1 ({}) + l2 ({}) even with stale pre-truncation counts",
1797 bad_summary.total_findings,
1798 bad_summary.l1_findings,
1799 bad_summary.l2_findings,
1800 );
1801 }
1802
1803 #[test]
1808 fn test_critical_exit_code_3() {
1809 let tmp = init_git_repo();
1812 let dir = tmp.path();
1813
1814 let code = r#"
1816fn main() {
1817 // AWS secret key hardcoded (intentional test fixture)
1818 let _key = "AKIAIOSFODNN7EXAMPLE";
1819 let _secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
1820}
1821"#;
1822 std::fs::write(dir.join("lib.rs"), "fn placeholder() {}\n").ok();
1823 std::process::Command::new("git")
1824 .args(["add", "lib.rs"])
1825 .current_dir(dir)
1826 .output()
1827 .expect("git add");
1828 std::process::Command::new("git")
1829 .args(["commit", "-m", "add placeholder"])
1830 .current_dir(dir)
1831 .output()
1832 .expect("git commit");
1833
1834 std::fs::write(dir.join("lib.rs"), code).expect("write secret code");
1835
1836 let args = BugbotCheckArgs {
1837 path: dir.to_path_buf(),
1838 base_ref: "HEAD".to_string(),
1839 staged: false,
1840 max_findings: 50,
1841 no_fail: false,
1842 quiet: true,
1843 no_tools: true, tool_timeout: 60,
1845 };
1846
1847 let result = args.run(OutputFormat::Json, true, Some(Language::Rust));
1848 let _ = result;
1853
1854 let err = BugbotExitError::CriticalFindings { count: 2 };
1856 assert_eq!(err.exit_code(), 3, "CriticalFindings exit code should be 3");
1857 }
1858
1859 #[test]
1860 fn test_l2_all_engines_registered() {
1861 use crate::commands::bugbot::l2::l2_engine_registry;
1863 let engines = l2_engine_registry();
1864 assert_eq!(
1865 engines.len(),
1866 1,
1867 "Registry should contain exactly 1 engine (TldrDifferentialEngine), got {}",
1868 engines.len()
1869 );
1870 assert_eq!(engines[0].name(), "TldrDifferentialEngine");
1871 }
1872
1873 #[test]
1874 fn test_l2_total_finding_types_matches_tldr_engine() {
1875 use crate::commands::bugbot::l2::l2_engine_registry;
1879 let engines = l2_engine_registry();
1880 let total: usize = engines.iter().map(|e| e.finding_types().len()).sum();
1881 assert_eq!(
1882 total, 11,
1883 "Total finding types across all engines should be 11 (TldrDifferentialEngine), got {}",
1884 total
1885 );
1886 }
1887
1888 #[test]
1889 fn test_l2_engine_names_unique() {
1890 use crate::commands::bugbot::l2::l2_engine_registry;
1892 let engines = l2_engine_registry();
1893 let mut names: Vec<&str> = engines.iter().map(|e| e.name()).collect();
1894 let original_len = names.len();
1895 names.sort();
1896 names.dedup();
1897 assert_eq!(
1898 names.len(),
1899 original_len,
1900 "Engine names should be unique, found duplicates"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_severity_rank_ordering_complete() {
1906 assert!(
1909 severity_rank("critical") > severity_rank("high"),
1910 "critical should rank above high"
1911 );
1912 assert!(
1913 severity_rank("high") > severity_rank("medium"),
1914 "high should rank above medium"
1915 );
1916 assert!(
1917 severity_rank("medium") > severity_rank("low"),
1918 "medium should rank above low"
1919 );
1920 assert!(
1921 severity_rank("low") > severity_rank("info"),
1922 "low should rank above info"
1923 );
1924 assert!(
1925 severity_rank("info") > severity_rank("unknown"),
1926 "info should rank above unknown"
1927 );
1928 assert_eq!(
1929 severity_rank("unknown"),
1930 0,
1931 "unknown severity should have rank 0"
1932 );
1933 }
1934
1935 #[test]
1936 fn test_run_l2_engines_empty_context() {
1937 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
1939 use crate::commands::bugbot::l2::context::FunctionDiff;
1940 use std::collections::HashMap;
1941
1942 let engines = l2_engine_registry();
1943 let ctx = L2Context::new(
1944 PathBuf::from("/tmp/nonexistent"),
1945 Language::Rust,
1946 vec![],
1947 FunctionDiff {
1948 changed: vec![],
1949 inserted: vec![],
1950 deleted: vec![],
1951 },
1952 HashMap::new(),
1953 HashMap::new(),
1954 HashMap::new(),
1955 );
1956
1957 let (_findings, results) = run_l2_engines(&ctx, &engines);
1958
1959 assert_eq!(
1961 results.len(),
1962 engines.len(),
1963 "Should have one result per engine"
1964 );
1965
1966 for result in &results {
1968 assert!(
1969 !result.name.is_empty(),
1970 "Engine result should have a name"
1971 );
1972 }
1973 }
1974
1975 #[test]
1980 fn test_l2_engine_failure_isolation() {
1981 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
1986 use crate::commands::bugbot::l2::context::FunctionDiff;
1987 use std::collections::HashMap;
1988
1989 let engines = l2_engine_registry();
1990 let ctx = L2Context::new(
1991 PathBuf::from("/tmp/nonexistent"),
1992 Language::Rust,
1993 vec![],
1994 FunctionDiff {
1995 changed: vec![],
1996 inserted: vec![],
1997 deleted: vec![],
1998 },
1999 HashMap::new(),
2000 HashMap::new(),
2001 HashMap::new(),
2002 );
2003
2004 let (_findings, results) = run_l2_engines(&ctx, &engines);
2005
2006 assert_eq!(
2008 results.len(),
2009 engines.len(),
2010 "Every engine must produce a result even when others partially fail"
2011 );
2012
2013 for result in &results {
2015 assert!(
2016 !result.name.is_empty(),
2017 "Engine result must have a non-empty name"
2018 );
2019 assert!(
2020 !result.status.is_empty(),
2021 "Engine '{}' must have a non-empty status string",
2022 result.name
2023 );
2024 }
2025 }
2026
2027 #[test]
2028 fn test_l2_findings_merge_with_l1() {
2029 let l1_findings = vec![
2032 BugbotFinding {
2033 finding_type: "tool:clippy".to_string(),
2034 severity: "medium".to_string(),
2035 file: PathBuf::from("src/lib.rs"),
2036 function: String::new(),
2037 line: 10,
2038 message: "clippy lint".to_string(),
2039 evidence: serde_json::Value::Null,
2040 confidence: None,
2041 finding_id: None,
2042 },
2043 BugbotFinding {
2044 finding_type: "tool:cargo-audit".to_string(),
2045 severity: "low".to_string(),
2046 file: PathBuf::from("Cargo.lock"),
2047 function: String::new(),
2048 line: 1,
2049 message: "advisory".to_string(),
2050 evidence: serde_json::Value::Null,
2051 confidence: None,
2052 finding_id: None,
2053 },
2054 ];
2055
2056 let l2_findings = vec![
2057 BugbotFinding {
2058 finding_type: "signature-regression".to_string(),
2059 severity: "high".to_string(),
2060 file: PathBuf::from("src/api.rs"),
2061 function: "handle_request".to_string(),
2062 line: 42,
2063 message: "parameter removed".to_string(),
2064 evidence: serde_json::Value::Null,
2065 confidence: Some("CERTAIN".to_string()),
2066 finding_id: None,
2067 },
2068 BugbotFinding {
2069 finding_type: "born-dead".to_string(),
2070 severity: "low".to_string(),
2071 file: PathBuf::from("src/util.rs"),
2072 function: "unused_helper".to_string(),
2073 line: 5,
2074 message: "no callers".to_string(),
2075 evidence: serde_json::Value::Null,
2076 confidence: Some("CERTAIN".to_string()),
2077 finding_id: None,
2078 },
2079 ];
2080
2081 let mut all_findings = Vec::new();
2083 all_findings.extend(l1_findings);
2084 all_findings.extend(l2_findings);
2085
2086 all_findings.sort_by(|a, b| {
2088 severity_rank(&b.severity)
2089 .cmp(&severity_rank(&a.severity))
2090 .then(a.file.cmp(&b.file))
2091 .then(a.line.cmp(&b.line))
2092 });
2093
2094 assert_eq!(all_findings.len(), 4, "merged list should contain all 4 findings");
2096
2097 assert_eq!(
2099 all_findings[0].severity, "high",
2100 "highest severity finding should be first"
2101 );
2102 assert_eq!(
2103 all_findings[1].severity, "medium",
2104 "medium severity should be second"
2105 );
2106 assert_eq!(all_findings[2].severity, "low");
2108 assert_eq!(all_findings[3].severity, "low");
2109 assert!(
2110 all_findings[2].file <= all_findings[3].file,
2111 "low-severity findings should be sorted by file path"
2112 );
2113
2114 let l1_count = all_findings
2116 .iter()
2117 .filter(|f| f.finding_type.starts_with("tool:"))
2118 .count();
2119 let l2_count = all_findings
2120 .iter()
2121 .filter(|f| !f.finding_type.starts_with("tool:"))
2122 .count();
2123 assert_eq!(l1_count, 2, "should have 2 L1 findings");
2124 assert_eq!(l2_count, 2, "should have 2 L2 findings");
2125 }
2126
2127 #[test]
2128 fn test_l2_dedup_suppresses_born_dead_cascade() {
2129 use crate::commands::bugbot::l2::dedup::dedup_and_prioritize;
2133
2134 let findings = vec![
2135 BugbotFinding {
2136 finding_type: "born-dead".to_string(),
2137 severity: "low".to_string(),
2138 file: PathBuf::from("src/orphan.rs"),
2139 function: "orphan_fn".to_string(),
2140 line: 10,
2141 message: "function has no callers".to_string(),
2142 evidence: serde_json::Value::Null,
2143 confidence: Some("CERTAIN".to_string()),
2144 finding_id: None,
2145 },
2146 BugbotFinding {
2147 finding_type: "complexity-increase".to_string(),
2148 severity: "medium".to_string(),
2149 file: PathBuf::from("src/orphan.rs"),
2150 function: "orphan_fn".to_string(),
2151 line: 12,
2152 message: "cyclomatic complexity increased by 5".to_string(),
2153 evidence: serde_json::Value::Null,
2154 confidence: Some("LIKELY".to_string()),
2155 finding_id: None,
2156 },
2157 ];
2158
2159 let deduped = dedup_and_prioritize(findings, 0);
2160
2161 assert_eq!(
2163 deduped.len(),
2164 1,
2165 "born-dead should suppress all other findings for the same function, got {} findings",
2166 deduped.len()
2167 );
2168 assert_eq!(
2169 deduped[0].finding_type, "born-dead",
2170 "the surviving finding should be born-dead, not '{}'",
2171 deduped[0].finding_type
2172 );
2173 }
2174
2175 #[test]
2176 fn test_l2_composition_taint_plus_guard() {
2177 use crate::commands::bugbot::l2::composition::compose_findings;
2182
2183 let findings = vec![
2184 BugbotFinding {
2185 finding_type: "taint-flow".to_string(),
2186 severity: "high".to_string(),
2187 file: PathBuf::from("src/handler.rs"),
2188 function: "process_input".to_string(),
2189 line: 25,
2190 message: "user input flows to SQL query".to_string(),
2191 evidence: serde_json::json!({"source": "param:input", "sink": "sql_query"}),
2192 confidence: Some("LIKELY".to_string()),
2193 finding_id: None,
2194 },
2195 BugbotFinding {
2196 finding_type: "guard-removed".to_string(),
2197 severity: "medium".to_string(),
2198 file: PathBuf::from("src/handler.rs"),
2199 function: "process_input".to_string(),
2200 line: 28,
2201 message: "input validation guard was removed".to_string(),
2202 evidence: serde_json::json!({"guard": "validate_input()"}),
2203 confidence: Some("CERTAIN".to_string()),
2204 finding_id: None,
2205 },
2206 ];
2207
2208 let composed = compose_findings(findings);
2209
2210 assert_eq!(
2212 composed.len(),
2213 1,
2214 "taint-flow + guard-removed should compose into 1 finding, got {}",
2215 composed.len()
2216 );
2217
2218 let finding = &composed[0];
2219 assert_eq!(
2220 finding.finding_type, "unguarded-injection-path",
2221 "composed type should be unguarded-injection-path, got '{}'",
2222 finding.finding_type
2223 );
2224 assert_eq!(
2225 finding.severity, "critical",
2226 "unguarded-injection-path severity should be critical, got '{}'",
2227 finding.severity
2228 );
2229 assert_eq!(
2230 finding.confidence.as_deref(),
2231 Some("LIKELY"),
2232 "composed finding confidence should be LIKELY, got {:?}",
2233 finding.confidence
2234 );
2235
2236 assert!(
2238 finding.evidence.get("constituent_a").is_some(),
2239 "composed evidence should contain constituent_a"
2240 );
2241 assert!(
2242 finding.evidence.get("constituent_b").is_some(),
2243 "composed evidence should contain constituent_b"
2244 );
2245 }
2246
2247 #[test]
2248 fn test_l2_language_gating_rust_skips_gvn() {
2249 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2253 use crate::commands::bugbot::l2::context::{FunctionDiff, InsertedFunction};
2254 use crate::commands::bugbot::l2::types::FunctionId;
2255 use std::collections::HashMap;
2256
2257 let source = "fn compute(x: i32) -> i32 { x + x }".to_string();
2260 let file = PathBuf::from("src/lib.rs");
2261 let func_id = FunctionId::new(file.clone(), "compute", 1);
2262
2263 let mut current_contents = HashMap::new();
2264 current_contents.insert(file.clone(), source.clone());
2265
2266 let ctx = L2Context::new(
2267 PathBuf::from("/tmp/test-gvn-gating"),
2268 Language::Rust,
2269 vec![file.clone()],
2270 FunctionDiff {
2271 changed: vec![],
2272 inserted: vec![InsertedFunction {
2273 id: func_id,
2274 name: "compute".to_string(),
2275 source,
2276 }],
2277 deleted: vec![],
2278 },
2279 HashMap::new(),
2280 current_contents,
2281 HashMap::new(),
2282 );
2283
2284 let engines = l2_engine_registry();
2285 let (findings, _results) = run_l2_engines(&ctx, &engines);
2286
2287 let gvn_findings: Vec<&BugbotFinding> = findings
2289 .iter()
2290 .filter(|f| f.finding_type == "redundant-computation")
2291 .collect();
2292
2293 assert!(
2294 gvn_findings.is_empty(),
2295 "Rust context should not produce redundant-computation findings (Python-only GVN), \
2296 but found {} such findings",
2297 gvn_findings.len()
2298 );
2299 }
2300
2301 #[test]
2302 fn test_l2_no_finding_types_overlap() {
2303 use crate::commands::bugbot::l2::l2_engine_registry;
2306 use std::collections::HashSet;
2307
2308 let engines = l2_engine_registry();
2309 let mut seen: HashSet<&str> = HashSet::new();
2310 let mut duplicates: Vec<String> = Vec::new();
2311
2312 for engine in &engines {
2313 for ft in engine.finding_types() {
2314 if !seen.insert(ft) {
2315 duplicates.push(format!(
2316 "'{}' claimed by engine '{}' but already registered",
2317 ft,
2318 engine.name()
2319 ));
2320 }
2321 }
2322 }
2323
2324 assert!(
2325 duplicates.is_empty(),
2326 "Finding type overlap detected: {}",
2327 duplicates.join("; ")
2328 );
2329 }
2330
2331 #[test]
2332 fn test_l2_all_finding_types_have_confidence() {
2333 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2338 use crate::commands::bugbot::l2::context::{FunctionDiff, InsertedFunction};
2339 use crate::commands::bugbot::l2::types::FunctionId;
2340 use std::collections::HashMap;
2341
2342 let source = "pub fn lonely_function(x: i32) -> i32 { x + 1 }".to_string();
2345 let file = PathBuf::from("src/lonely.rs");
2346 let func_id = FunctionId::new(file.clone(), "lonely_function", 1);
2347
2348 let mut current_contents = HashMap::new();
2349 current_contents.insert(file.clone(), source.clone());
2350
2351 let ctx = L2Context::new(
2352 PathBuf::from("/tmp/test-confidence"),
2353 Language::Rust,
2354 vec![file.clone()],
2355 FunctionDiff {
2356 changed: vec![],
2357 inserted: vec![InsertedFunction {
2358 id: func_id,
2359 name: "lonely_function".to_string(),
2360 source,
2361 }],
2362 deleted: vec![],
2363 },
2364 HashMap::new(),
2365 current_contents,
2366 HashMap::new(),
2367 );
2368
2369 let engines = l2_engine_registry();
2370 let (findings, _results) = run_l2_engines(&ctx, &engines);
2371
2372 let missing_confidence: Vec<String> = findings
2374 .iter()
2375 .filter(|f| f.confidence.is_none())
2376 .map(|f| format!("{}:{} ({})", f.file.display(), f.line, f.finding_type))
2377 .collect();
2378
2379 assert!(
2380 missing_confidence.is_empty(),
2381 "All L2 findings must have confidence set, but {} findings are missing it: {}",
2382 missing_confidence.len(),
2383 missing_confidence.join(", ")
2384 );
2385 }
2386
2387 #[test]
2392 fn test_l2_engines_sendable_to_thread() {
2393 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2396 use crate::commands::bugbot::l2::context::FunctionDiff;
2397 use std::collections::HashMap;
2398
2399 let engines = l2_engine_registry();
2400 let ctx = L2Context::new(
2401 PathBuf::from("/tmp/nonexistent"),
2402 Language::Rust,
2403 vec![],
2404 FunctionDiff {
2405 changed: vec![],
2406 inserted: vec![],
2407 deleted: vec![],
2408 },
2409 HashMap::new(),
2410 HashMap::new(),
2411 HashMap::new(),
2412 );
2413
2414 let handle = std::thread::spawn(move || {
2416 run_l2_engines(&ctx, &engines)
2417 });
2418
2419 let (findings, results) = handle.join().expect("L2 thread should not panic");
2420
2421 assert_eq!(
2423 results.len(),
2424 1,
2425 "L2 engines on thread should produce 1 result (DeltaEngine), got {}",
2426 results.len()
2427 );
2428 for result in &results {
2429 assert!(
2430 !result.name.is_empty(),
2431 "Engine result from thread should have a name"
2432 );
2433 }
2434 let _ = findings;
2436 }
2437
2438 #[test]
2439 fn test_l2_thread_panic_graceful_degradation() {
2440 let handle = std::thread::spawn(|| -> (Vec<BugbotFinding>, Vec<L2AnalyzerResult>) {
2442 panic!("simulated L2 engine panic");
2443 });
2444
2445 let (findings, results) = handle.join()
2446 .unwrap_or_else(|_| (Vec::new(), Vec::new()));
2447
2448 assert!(findings.is_empty(), "Panicked thread should yield empty findings");
2449 assert!(results.is_empty(), "Panicked thread should yield empty results");
2450 }
2451
2452 #[test]
2453 fn test_l1_and_l2_parallel_both_contribute_to_report() {
2454 let l1_findings = vec![
2457 BugbotFinding {
2458 finding_type: "tool:clippy".to_string(),
2459 severity: "medium".to_string(),
2460 file: PathBuf::from("src/main.rs"),
2461 function: String::new(),
2462 line: 10,
2463 message: "unused variable".to_string(),
2464 evidence: serde_json::Value::Null,
2465 confidence: None,
2466 finding_id: None,
2467 },
2468 ];
2469
2470 let l2_findings = vec![
2471 BugbotFinding {
2472 finding_type: "signature-regression".to_string(),
2473 severity: "high".to_string(),
2474 file: PathBuf::from("src/lib.rs"),
2475 function: "compute".to_string(),
2476 line: 5,
2477 message: "param removed".to_string(),
2478 evidence: serde_json::Value::Null,
2479 confidence: None,
2480 finding_id: None,
2481 },
2482 ];
2483
2484 let mut findings: Vec<BugbotFinding> = Vec::new();
2486 findings.extend(l1_findings);
2487 findings.extend(l2_findings);
2488
2489 assert_eq!(findings.len(), 2, "Merged findings should contain both L1 and L2");
2490 assert!(
2491 findings.iter().any(|f| f.finding_type.starts_with("tool:")),
2492 "Should contain L1 finding"
2493 );
2494 assert!(
2495 findings.iter().any(|f| f.finding_type == "signature-regression"),
2496 "Should contain L2 finding"
2497 );
2498 }
2499
2500 #[test]
2501 fn test_parallel_execution_integration() {
2502 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2505 use crate::commands::bugbot::l2::context::FunctionDiff;
2506 use std::collections::HashMap;
2507
2508 let engines = l2_engine_registry();
2509 let ctx = L2Context::new(
2510 PathBuf::from("/tmp/nonexistent"),
2511 Language::Rust,
2512 vec![],
2513 FunctionDiff {
2514 changed: vec![],
2515 inserted: vec![],
2516 deleted: vec![],
2517 },
2518 HashMap::new(),
2519 HashMap::new(),
2520 HashMap::new(),
2521 );
2522
2523 let l2_handle = std::thread::spawn(move || {
2525 run_l2_engines(&ctx, &engines)
2526 });
2527
2528 let (l1_raw, tool_results, tools_available, tools_missing) =
2530 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", false, 5);
2531
2532 let (l2_engine_findings, l2_engine_results) = l2_handle.join()
2534 .unwrap_or_else(|_| (Vec::new(), Vec::new()));
2535
2536 let l1_bugbot: Vec<BugbotFinding> = l1_raw.into_iter().map(BugbotFinding::from).collect();
2538 let mut findings: Vec<BugbotFinding> = Vec::new();
2539 findings.extend(l1_bugbot);
2540 findings.extend(l2_engine_findings);
2541
2542 assert_eq!(
2544 l2_engine_results.len(),
2545 1,
2546 "L2 should produce 1 engine result (DeltaEngine), got {}",
2547 l2_engine_results.len()
2548 );
2549 let _ = tool_results;
2551 let _ = tools_available;
2552 let _ = tools_missing;
2553 }
2554
2555 #[test]
2556 fn test_no_tools_flag_still_runs_l2_on_thread() {
2557 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2560 use crate::commands::bugbot::l2::context::FunctionDiff;
2561 use std::collections::HashMap;
2562
2563 let engines = l2_engine_registry();
2564 let ctx = L2Context::new(
2565 PathBuf::from("/tmp/nonexistent"),
2566 Language::Rust,
2567 vec![],
2568 FunctionDiff {
2569 changed: vec![],
2570 inserted: vec![],
2571 deleted: vec![],
2572 },
2573 HashMap::new(),
2574 HashMap::new(),
2575 HashMap::new(),
2576 );
2577
2578 let l2_handle = std::thread::spawn(move || {
2580 run_l2_engines(&ctx, &engines)
2581 });
2582
2583 let (l1_raw, tool_results, _, _) =
2585 run_l1_tools_opt(std::path::Path::new("/tmp/nonexistent"), "rust", true, 60);
2586
2587 let (l2_findings, l2_results) = l2_handle.join()
2589 .unwrap_or_else(|_| (Vec::new(), Vec::new()));
2590
2591 assert!(l1_raw.is_empty(), "no_tools should produce empty L1 findings");
2592 assert!(tool_results.is_empty(), "no_tools should produce empty tool_results");
2593 assert_eq!(l2_results.len(), 1, "L2 should run 1 engine (DeltaEngine), got {}", l2_results.len());
2594 let _ = l2_findings;
2595 }
2596
2597 #[test]
2607 fn test_l2_all_engines_budget() {
2608 use crate::commands::bugbot::l2::context::{
2609 FunctionChange, FunctionDiff, InsertedFunction,
2610 };
2611 use crate::commands::bugbot::l2::types::FunctionId;
2612 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2613 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
2614 use std::collections::HashMap;
2615 use std::time::Duration;
2616
2617 let num_functions: usize = 50;
2619 let mut changed_functions = Vec::with_capacity(num_functions);
2620 let mut inserted_functions = Vec::with_capacity(num_functions / 5);
2621 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
2622 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
2623 let mut ast_changes: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
2624 let mut changed_files: Vec<PathBuf> = Vec::new();
2625
2626 let files_count = 10;
2628 for file_idx in 0..files_count {
2629 let file_path = PathBuf::from(format!("src/module_{}.rs", file_idx));
2630 changed_files.push(file_path.clone());
2631
2632 let mut baseline_src = String::new();
2633 let mut current_src = String::new();
2634 let mut file_ast_changes = Vec::new();
2635
2636 let funcs_per_file = num_functions / files_count;
2637 for func_idx in 0..funcs_per_file {
2638 let global_idx = file_idx * funcs_per_file + func_idx;
2639 let func_name = format!("process_item_{}", global_idx);
2640 let def_line = func_idx * 10 + 1;
2641
2642 let old_source = format!(
2644 "fn {}(input: &str) -> Result<(), Error> {{\n \
2645 let data = parse(input)?;\n \
2646 validate(&data)?;\n \
2647 Ok(())\n\
2648 }}\n",
2649 func_name
2650 );
2651
2652 let new_source = format!(
2654 "fn {}(input: &str, config: &Config) -> Result<(), Error> {{\n \
2655 let data = parse(input)?;\n \
2656 if config.strict {{\n \
2657 validate_strict(&data)?;\n \
2658 }} else {{\n \
2659 validate(&data)?;\n \
2660 }}\n \
2661 Ok(())\n\
2662 }}\n",
2663 func_name
2664 );
2665
2666 baseline_src.push_str(&old_source);
2667 baseline_src.push('\n');
2668 current_src.push_str(&new_source);
2669 current_src.push('\n');
2670
2671 let fid =
2672 FunctionId::new(file_path.clone(), func_name.clone(), def_line);
2673
2674 changed_functions.push(FunctionChange {
2675 id: fid,
2676 name: func_name.clone(),
2677 old_source: old_source.clone(),
2678 new_source: new_source.clone(),
2679 });
2680
2681 file_ast_changes.push(ASTChange {
2683 change_type: ChangeType::Update,
2684 node_kind: NodeKind::Function,
2685 name: Some(func_name.clone()),
2686 old_location: Some(Location::new(
2687 file_path.to_string_lossy().to_string(),
2688 def_line as u32,
2689 )),
2690 new_location: Some(Location::new(
2691 file_path.to_string_lossy().to_string(),
2692 def_line as u32,
2693 )),
2694 old_text: Some(old_source),
2695 new_text: Some(new_source),
2696 similarity: Some(0.85),
2697 children: None,
2698 base_changes: None,
2699 });
2700
2701 if global_idx.is_multiple_of(10) {
2703 let ins_name = format!("new_helper_{}", global_idx);
2704 let ins_source = format!(
2705 "fn {}(x: i32) -> i32 {{\n x * 2\n}}\n",
2706 ins_name
2707 );
2708 inserted_functions.push(InsertedFunction {
2709 id: FunctionId::new(
2710 file_path.clone(),
2711 ins_name.clone(),
2712 def_line + 100,
2713 ),
2714 name: ins_name,
2715 source: ins_source,
2716 });
2717 }
2718 }
2719
2720 baseline_contents.insert(file_path.clone(), baseline_src);
2721 current_contents.insert(file_path.clone(), current_src);
2722 ast_changes.insert(file_path, file_ast_changes);
2723 }
2724
2725 let ctx = L2Context::new(
2726 PathBuf::from("/tmp/bench-project"),
2727 Language::Rust,
2728 changed_files,
2729 FunctionDiff {
2730 changed: changed_functions,
2731 inserted: inserted_functions,
2732 deleted: vec![],
2733 },
2734 baseline_contents,
2735 current_contents,
2736 ast_changes,
2737 );
2738
2739 let all_engines = l2_engine_registry();
2741
2742 assert_eq!(
2743 all_engines.len(),
2744 1,
2745 "Expected exactly 1 engine (DeltaEngine), got {}",
2746 all_engines.len()
2747 );
2748
2749 let start = Instant::now();
2751 let (findings, results) = run_l2_engines(&ctx, &all_engines);
2752 let elapsed = start.elapsed();
2753
2754 assert_eq!(
2758 results.len(),
2759 all_engines.len(),
2760 "Every engine must produce a result"
2761 );
2762
2763 let engines_that_ran = results
2765 .iter()
2766 .filter(|r| r.functions_analyzed > 0 || r.finding_count > 0)
2767 .count();
2768 assert!(
2769 engines_that_ran > 0,
2770 "At least one engine must have analyzed functions, \
2771 but all were skipped: {:?}",
2772 results
2773 .iter()
2774 .map(|r| format!("{}:{}", r.name, r.status))
2775 .collect::<Vec<_>>()
2776 );
2777
2778 let budget = if cfg!(debug_assertions) {
2781 Duration::from_millis(5000)
2782 } else {
2783 Duration::from_millis(2000)
2784 };
2785
2786 assert!(
2787 elapsed < budget,
2788 "All engines took {:?} which exceeds the {:?} budget \
2789 (release target: <2000ms). Engine breakdown: {:?}",
2790 elapsed,
2791 budget,
2792 results
2793 .iter()
2794 .map(|r| format!("{}={}ms", r.name, r.duration_ms))
2795 .collect::<Vec<_>>()
2796 );
2797
2798 let total_finding_count: usize =
2800 results.iter().map(|r| r.finding_count).sum();
2801 assert_eq!(
2802 findings.len(),
2803 total_finding_count,
2804 "Merged findings count must equal sum of per-engine finding counts"
2805 );
2806 }
2807
2808 #[test]
2817 fn test_flow_engine_realistic_workload() {
2818 use crate::commands::bugbot::l2::context::{
2819 FunctionChange, FunctionDiff, InsertedFunction,
2820 };
2821 use crate::commands::bugbot::l2::types::FunctionId;
2822 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
2823 use crate::commands::remaining::types::{ASTChange, ChangeType, Location, NodeKind};
2824 use std::collections::HashMap;
2825
2826 type ComplexTemplate = Box<dyn Fn(usize) -> (String, String)>;
2832 let complex_templates: Vec<ComplexTemplate> = vec![
2833 Box::new(|idx: usize| {
2835 let old = format!(
2836 r#"fn deserialize_{idx}(input: &[u8]) -> Result<Value, Error> {{
2837 let mut pos = 0;
2838 let tag = input.get(pos).ok_or(Error::Eof)?;
2839 pos += 1;
2840 match tag {{
2841 0x01 => {{
2842 let len = input.get(pos).copied().unwrap_or(0) as usize;
2843 pos += 1;
2844 let data = &input[pos..pos + len];
2845 Ok(Value::String(std::str::from_utf8(data)?.to_string()))
2846 }}
2847 0x02 => {{
2848 let n = i32::from_le_bytes(input[pos..pos+4].try_into()?);
2849 Ok(Value::Int(n))
2850 }}
2851 0x03 => {{
2852 let count = input.get(pos).copied().unwrap_or(0) as usize;
2853 pos += 1;
2854 let mut items = Vec::with_capacity(count);
2855 for _ in 0..count {{
2856 let item = deserialize_{idx}(&input[pos..])?;
2857 items.push(item);
2858 }}
2859 Ok(Value::Array(items))
2860 }}
2861 _ => Err(Error::UnknownTag(*tag)),
2862 }}
2863}}"#
2864 );
2865 let new = format!(
2866 r#"fn deserialize_{idx}(input: &[u8], opts: &Options) -> Result<Value, Error> {{
2867 let mut pos = 0;
2868 let tag = input.get(pos).ok_or(Error::Eof)?;
2869 pos += 1;
2870 match tag {{
2871 0x01 => {{
2872 let len = input.get(pos).copied().unwrap_or(0) as usize;
2873 pos += 1;
2874 if len > opts.max_string_len {{
2875 return Err(Error::TooLong(len));
2876 }}
2877 let data = &input[pos..pos + len];
2878 Ok(Value::String(std::str::from_utf8(data)?.to_string()))
2879 }}
2880 0x02 => {{
2881 let n = i32::from_le_bytes(input[pos..pos+4].try_into()?);
2882 if opts.strict && n < 0 {{
2883 return Err(Error::NegativeInt(n));
2884 }}
2885 Ok(Value::Int(n))
2886 }}
2887 0x03 => {{
2888 let count = input.get(pos).copied().unwrap_or(0) as usize;
2889 if count > opts.max_array_len {{
2890 return Err(Error::TooMany(count));
2891 }}
2892 pos += 1;
2893 let mut items = Vec::with_capacity(count);
2894 for _ in 0..count {{
2895 let item = deserialize_{idx}(&input[pos..], opts)?;
2896 items.push(item);
2897 }}
2898 Ok(Value::Array(items))
2899 }}
2900 _ => Err(Error::UnknownTag(*tag)),
2901 }}
2902}}"#
2903 );
2904 (old, new)
2905 }),
2906
2907 Box::new(|idx: usize| {
2909 let old = format!(
2910 r#"fn process_state_{idx}(events: &[Event]) -> Result<State, Error> {{
2911 let mut state = State::Init;
2912 let mut retries = 0;
2913 let mut last_error = None;
2914 for event in events {{
2915 state = match (state, event) {{
2916 (State::Init, Event::Start) => State::Running,
2917 (State::Running, Event::Pause) => State::Paused,
2918 (State::Paused, Event::Resume) => State::Running,
2919 (State::Running, Event::Error(e)) => {{
2920 last_error = Some(e.clone());
2921 retries += 1;
2922 if retries > 3 {{
2923 return Err(Error::TooManyRetries(last_error.unwrap()));
2924 }}
2925 State::Running
2926 }}
2927 (State::Running, Event::Done) => State::Complete,
2928 (s, _) => s,
2929 }};
2930 }}
2931 Ok(state)
2932}}"#
2933 );
2934 let new = format!(
2935 r#"fn process_state_{idx}(events: &[Event], config: &Config) -> Result<State, Error> {{
2936 let mut state = State::Init;
2937 let mut retries = 0;
2938 let mut last_error = None;
2939 let max_retries = config.max_retries.unwrap_or(3);
2940 for event in events {{
2941 state = match (state, event) {{
2942 (State::Init, Event::Start) => {{
2943 if config.require_auth && !config.authenticated {{
2944 return Err(Error::Unauthorized);
2945 }}
2946 State::Running
2947 }}
2948 (State::Running, Event::Pause) => State::Paused,
2949 (State::Paused, Event::Resume) => {{
2950 retries = 0;
2951 State::Running
2952 }}
2953 (State::Running, Event::Error(e)) => {{
2954 last_error = Some(e.clone());
2955 retries += 1;
2956 if retries > max_retries {{
2957 return Err(Error::TooManyRetries(last_error.unwrap()));
2958 }}
2959 State::Running
2960 }}
2961 (State::Running, Event::Done) => State::Complete,
2962 (s, _) => s,
2963 }};
2964 }}
2965 Ok(state)
2966}}"#
2967 );
2968 (old, new)
2969 }),
2970
2971 Box::new(|idx: usize| {
2973 let old = format!(
2974 r#"fn read_config_{idx}(path: &str) -> Result<Config, Error> {{
2975 let file = File::open(path)?;
2976 let reader = BufReader::new(file);
2977 let mut lines = Vec::new();
2978 for line in reader.lines() {{
2979 let line = line?;
2980 if line.starts_with('#') {{
2981 continue;
2982 }}
2983 if line.is_empty() {{
2984 break;
2985 }}
2986 lines.push(line);
2987 }}
2988 let parsed = parse_config(&lines)?;
2989 validate_config(&parsed)?;
2990 Ok(parsed)
2991}}"#
2992 );
2993 let new = format!(
2994 r#"fn read_config_{idx}(path: &str, env: &Env) -> Result<Config, Error> {{
2995 let file = File::open(path)?;
2996 let reader = BufReader::new(file);
2997 let mut lines = Vec::new();
2998 let mut saw_section = false;
2999 for line in reader.lines() {{
3000 let line = line?;
3001 if line.starts_with('#') {{
3002 continue;
3003 }}
3004 if line.starts_with('[') {{
3005 if saw_section {{
3006 break;
3007 }}
3008 saw_section = true;
3009 continue;
3010 }}
3011 if line.is_empty() && !saw_section {{
3012 break;
3013 }}
3014 let resolved = if line.contains("${{") {{
3015 env.resolve_vars(&line)?
3016 }} else {{
3017 line
3018 }};
3019 lines.push(resolved);
3020 }}
3021 let parsed = parse_config(&lines)?;
3022 validate_config(&parsed)?;
3023 Ok(parsed)
3024}}"#
3025 );
3026 (old, new)
3027 }),
3028
3029 Box::new(|idx: usize| {
3031 let old = format!(
3032 r#"fn compute_metrics_{idx}(data: &[f64]) -> Metrics {{
3033 let sum: f64 = data.iter().sum();
3034 let count = data.len();
3035 let mean = sum / count as f64;
3036 let variance = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count as f64;
3037 let stddev = variance.sqrt();
3038 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
3039 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3040 let range = max - min;
3041 Metrics {{ mean, stddev, min, max, range }}
3042}}"#
3043 );
3044 let new = format!(
3045 r#"fn compute_metrics_{idx}(data: &[f64], opts: &MetricOpts) -> Result<Metrics, Error> {{
3046 if data.is_empty() {{
3047 return Err(Error::EmptyData);
3048 }}
3049 let sum: f64 = data.iter().sum();
3050 let count = data.len();
3051 let mean = sum / count as f64;
3052 let trimmed = if opts.trim_outliers {{
3053 let lo = mean - 2.0 * opts.threshold;
3054 let hi = mean + 2.0 * opts.threshold;
3055 data.iter().filter(|&&x| x >= lo && x <= hi).copied().collect::<Vec<_>>()
3056 }} else {{
3057 data.to_vec()
3058 }};
3059 let adj_count = trimmed.len();
3060 if adj_count == 0 {{
3061 return Err(Error::AllOutliers);
3062 }}
3063 let adj_mean = trimmed.iter().sum::<f64>() / adj_count as f64;
3064 let variance = trimmed.iter().map(|x| (x - adj_mean).powi(2)).sum::<f64>() / adj_count as f64;
3065 let stddev = variance.sqrt();
3066 let min = trimmed.iter().copied().fold(f64::INFINITY, f64::min);
3067 let max = trimmed.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3068 let range = max - min;
3069 let cv = if adj_mean.abs() > f64::EPSILON {{ stddev / adj_mean }} else {{ 0.0 }};
3070 Ok(Metrics {{ mean: adj_mean, stddev, min, max, range, cv }})
3071}}"#
3072 );
3073 (old, new)
3074 }),
3075
3076 Box::new(|idx: usize| {
3078 let old = format!(
3079 r#"fn handle_request_{idx}(req: &Request) -> Result<Response, Error> {{
3080 let auth = validate_auth(&req.headers)?;
3081 let body = parse_body(&req.body)?;
3082 let user = lookup_user(auth.user_id)?;
3083 let result = execute_query(&user, &body.query)?;
3084 let formatted = format_response(&result)?;
3085 Ok(Response::new(200, formatted))
3086}}"#
3087 );
3088 let new = format!(
3089 r#"fn handle_request_{idx}(req: &Request, ctx: &Context) -> Result<Response, Error> {{
3090 let auth = validate_auth(&req.headers)?;
3091 if auth.expired() {{
3092 return Ok(Response::new(401, "Token expired".into()));
3093 }}
3094 let body = parse_body(&req.body)?;
3095 let user = lookup_user(auth.user_id)?;
3096 if !user.has_permission(&body.query) {{
3097 return Ok(Response::new(403, "Forbidden".into()));
3098 }}
3099 let result = if ctx.read_only {{
3100 execute_read_query(&user, &body.query)?
3101 }} else {{
3102 execute_query(&user, &body.query)?
3103 }};
3104 let formatted = format_response(&result)?;
3105 ctx.metrics.record_latency(req.start.elapsed());
3106 Ok(Response::new(200, formatted))
3107}}"#
3108 );
3109 (old, new)
3110 }),
3111 ];
3112
3113 let num_functions: usize = 50;
3115 let num_templates = complex_templates.len();
3116 let mut changed_functions = Vec::with_capacity(num_functions);
3117 let mut inserted_functions = Vec::with_capacity(num_functions / 5);
3118 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
3119 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
3120 let mut ast_changes: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3121 let mut changed_files: Vec<PathBuf> = Vec::new();
3122
3123 let files_count = 10;
3124 for file_idx in 0..files_count {
3125 let file_path = PathBuf::from(format!("src/service_{}.rs", file_idx));
3126 changed_files.push(file_path.clone());
3127
3128 let mut baseline_src = String::new();
3129 let mut current_src = String::new();
3130 let mut file_ast_changes = Vec::new();
3131
3132 let funcs_per_file = num_functions / files_count;
3133 for func_idx in 0..funcs_per_file {
3134 let global_idx = file_idx * funcs_per_file + func_idx;
3135 let template_idx = global_idx % num_templates;
3136 let (old_source, new_source) = complex_templates[template_idx](global_idx);
3137
3138 let func_name = old_source
3140 .strip_prefix("fn ")
3141 .and_then(|s| s.split('(').next())
3142 .unwrap_or(&format!("func_{}", global_idx))
3143 .to_string();
3144
3145 let def_line = baseline_src.lines().count() + 1;
3146
3147 baseline_src.push_str(&old_source);
3148 baseline_src.push_str("\n\n");
3149 current_src.push_str(&new_source);
3150 current_src.push_str("\n\n");
3151
3152 let fid =
3153 FunctionId::new(file_path.clone(), func_name.clone(), def_line);
3154
3155 changed_functions.push(FunctionChange {
3156 id: fid,
3157 name: func_name.clone(),
3158 old_source: old_source.clone(),
3159 new_source: new_source.clone(),
3160 });
3161
3162 file_ast_changes.push(ASTChange {
3163 change_type: ChangeType::Update,
3164 node_kind: NodeKind::Function,
3165 name: Some(func_name.clone()),
3166 old_location: Some(Location::new(
3167 file_path.to_string_lossy().to_string(),
3168 def_line as u32,
3169 )),
3170 new_location: Some(Location::new(
3171 file_path.to_string_lossy().to_string(),
3172 def_line as u32,
3173 )),
3174 old_text: Some(old_source),
3175 new_text: Some(new_source),
3176 similarity: Some(0.75),
3177 children: None,
3178 base_changes: None,
3179 });
3180
3181 if global_idx.is_multiple_of(10) {
3182 let ins_name = format!("new_helper_{}", global_idx);
3183 let ins_source = format!(
3184 "fn {}(x: i32) -> i32 {{\n x * 2\n}}\n",
3185 ins_name
3186 );
3187 inserted_functions.push(InsertedFunction {
3188 id: FunctionId::new(
3189 file_path.clone(),
3190 ins_name.clone(),
3191 def_line + 200,
3192 ),
3193 name: ins_name,
3194 source: ins_source,
3195 });
3196 }
3197 }
3198
3199 baseline_contents.insert(file_path.clone(), baseline_src);
3200 current_contents.insert(file_path.clone(), current_src);
3201 ast_changes.insert(file_path, file_ast_changes);
3202 }
3203
3204 let ctx = L2Context::new(
3205 PathBuf::from("/tmp/bench-deferred-realistic"),
3206 Language::Rust,
3207 changed_files,
3208 FunctionDiff {
3209 changed: changed_functions,
3210 inserted: inserted_functions,
3211 deleted: vec![],
3212 },
3213 baseline_contents,
3214 current_contents,
3215 ast_changes,
3216 );
3217
3218 let all_engines = l2_engine_registry();
3220
3221 let start = Instant::now();
3222 let (findings, results) = run_l2_engines(&ctx, &all_engines);
3223 let elapsed = start.elapsed();
3224
3225 let engines_ran = results
3227 .iter()
3228 .filter(|r| r.functions_analyzed > 0)
3229 .count();
3230 assert!(
3231 engines_ran > 0,
3232 "All engines skipped on realistic workload"
3233 );
3234
3235 eprintln!(
3237 "\n[realistic-bench] Total={:?}\n Engines: {:?}\n Findings: {}",
3238 elapsed,
3239 results
3240 .iter()
3241 .map(|r| format!(
3242 "{}={}ms(a={},f={})",
3243 r.name, r.duration_ms, r.functions_analyzed, r.finding_count
3244 ))
3245 .collect::<Vec<_>>(),
3246 findings.len(),
3247 );
3248
3249 assert_eq!(
3251 results.len(),
3252 all_engines.len(),
3253 "Every engine must produce a result"
3254 );
3255
3256 let total_finding_count: usize =
3263 results.iter().map(|r| r.finding_count).sum();
3264 assert_eq!(
3265 findings.len(),
3266 total_finding_count,
3267 "Merged findings count must equal sum of per-engine finding counts"
3268 );
3269 }
3270
3271 #[test]
3277 fn test_all_engines_produce_results() {
3278 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3279 use crate::commands::bugbot::l2::context::FunctionDiff;
3280 use std::collections::HashMap;
3281
3282 let engines = l2_engine_registry();
3283 let ctx = L2Context::new(
3284 PathBuf::from("/tmp/test-engine-results"),
3285 Language::Rust,
3286 vec![],
3287 FunctionDiff {
3288 changed: vec![],
3289 inserted: vec![],
3290 deleted: vec![],
3291 },
3292 HashMap::new(),
3293 HashMap::new(),
3294 HashMap::new(),
3295 );
3296
3297 let (_findings, results) = run_l2_engines(&ctx, &engines);
3298
3299 assert_eq!(
3301 results.len(),
3302 engines.len(),
3303 "All engines should have results"
3304 );
3305
3306 let engine_names: Vec<&str> = engines.iter().map(|e| e.name()).collect();
3308 for result in &results {
3309 assert!(
3310 engine_names.contains(&result.name.as_str()),
3311 "Result for unknown engine '{}' -- not in registry",
3312 result.name
3313 );
3314 }
3315
3316 assert_eq!(
3318 engines.len(),
3319 1,
3320 "Should have exactly 1 engine (DeltaEngine), got {}",
3321 engines.len()
3322 );
3323 }
3324
3325 #[test]
3327 fn test_engines_run_synchronously_without_daemon() {
3328 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3329 use crate::commands::bugbot::l2::context::FunctionDiff;
3330 use std::collections::HashMap;
3331
3332 let engines = l2_engine_registry();
3333 let ctx = L2Context::new(
3334 PathBuf::from("/tmp/test-sync-no-daemon"),
3335 Language::Rust,
3336 vec![],
3337 FunctionDiff {
3338 changed: vec![],
3339 inserted: vec![],
3340 deleted: vec![],
3341 },
3342 HashMap::new(),
3343 HashMap::new(),
3344 HashMap::new(),
3345 );
3346
3347 assert!(!ctx.daemon_available());
3349
3350 let (_findings, results) = run_l2_engines(&ctx, &engines);
3351
3352 assert_eq!(
3354 results.len(),
3355 engines.len(),
3356 "All engines should have run even without daemon"
3357 );
3358
3359 for result in &results {
3360 assert!(
3361 !result.status.is_empty(),
3362 "Engine '{}' should have a status after running synchronously",
3363 result.name
3364 );
3365 }
3366 }
3367
3368 #[test]
3370 fn test_deferred_engines_use_daemon_cache() {
3371 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3372 use crate::commands::bugbot::l2::context::FunctionDiff;
3373 use crate::commands::bugbot::l2::daemon_client::DaemonClient;
3374 use std::collections::HashMap;
3375
3376 struct AvailableDaemon;
3378 impl DaemonClient for AvailableDaemon {
3379 fn is_available(&self) -> bool { true }
3380 fn query_call_graph(&self) -> Option<tldr_core::ProjectCallGraph> { None }
3381 fn query_cfg(&self, _fid: &super::super::l2::types::FunctionId) -> Option<tldr_core::CfgInfo> { None }
3382 fn query_dfg(&self, _fid: &super::super::l2::types::FunctionId) -> Option<tldr_core::DfgInfo> { None }
3383 fn query_ssa(&self, _fid: &super::super::l2::types::FunctionId) -> Option<tldr_core::ssa::SsaFunction> { None }
3384 fn notify_changed_files(&self, _files: &[PathBuf]) {}
3385 }
3386
3387 let engines = l2_engine_registry();
3388 let ctx = L2Context::new(
3389 PathBuf::from("/tmp/test-daemon-cache"),
3390 Language::Rust,
3391 vec![],
3392 FunctionDiff {
3393 changed: vec![],
3394 inserted: vec![],
3395 deleted: vec![],
3396 },
3397 HashMap::new(),
3398 HashMap::new(),
3399 HashMap::new(),
3400 )
3401 .with_daemon(Box::new(AvailableDaemon));
3402
3403 assert!(ctx.daemon_available());
3404
3405 let (_findings, results) = run_l2_engines(&ctx, &engines);
3406
3407 assert_eq!(
3409 results.len(),
3410 engines.len(),
3411 "All engines should run even with daemon available"
3412 );
3413 }
3414
3415 #[test]
3417 fn test_daemon_client_creation_factory() {
3418 use crate::commands::bugbot::l2::daemon_client::create_daemon_client;
3419
3420 let client = create_daemon_client(std::path::Path::new("/tmp/nonexistent-project-xyz"));
3422 assert!(!client.is_available());
3423 }
3424
3425 #[test]
3430 fn test_build_function_diff_from_ast_changes_insert() {
3431 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3432
3433 let project = PathBuf::from("/project");
3434 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3435 all_diffs.insert(
3436 PathBuf::from("/project/src/lib.rs"),
3437 vec![ASTChange {
3438 change_type: ChangeType::Insert,
3439 node_kind: NodeKind::Function,
3440 name: Some("new_func".to_string()),
3441 old_location: None,
3442 new_location: Some(Location::new("src/lib.rs", 10)),
3443 old_text: None,
3444 new_text: Some("fn new_func() { }".to_string()),
3445 similarity: None,
3446 children: None,
3447 base_changes: None,
3448 }],
3449 );
3450
3451 let diff = build_function_diff(&all_diffs, &project);
3452
3453 assert_eq!(diff.inserted.len(), 1, "Should have 1 inserted function");
3454 assert_eq!(diff.changed.len(), 0, "Should have 0 changed functions");
3455 assert_eq!(diff.deleted.len(), 0, "Should have 0 deleted functions");
3456 assert_eq!(diff.inserted[0].name, "new_func");
3457 assert_eq!(diff.inserted[0].source, "fn new_func() { }");
3458 assert_eq!(
3459 diff.inserted[0].id.file,
3460 PathBuf::from("src/lib.rs"),
3461 "FunctionId file should be relative"
3462 );
3463 assert_eq!(diff.inserted[0].id.def_line, 10);
3464 }
3465
3466 #[test]
3467 fn test_build_function_diff_from_ast_changes_update() {
3468 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3469
3470 let project = PathBuf::from("/project");
3471 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3472 all_diffs.insert(
3473 PathBuf::from("/project/src/main.rs"),
3474 vec![ASTChange {
3475 change_type: ChangeType::Update,
3476 node_kind: NodeKind::Function,
3477 name: Some("existing_fn".to_string()),
3478 old_location: Some(Location::new("src/main.rs", 5)),
3479 new_location: Some(Location::new("src/main.rs", 5)),
3480 old_text: Some("fn existing_fn() { old }".to_string()),
3481 new_text: Some("fn existing_fn() { new }".to_string()),
3482 similarity: None,
3483 children: None,
3484 base_changes: None,
3485 }],
3486 );
3487
3488 let diff = build_function_diff(&all_diffs, &project);
3489
3490 assert_eq!(diff.changed.len(), 1, "Should have 1 changed function");
3491 assert_eq!(diff.inserted.len(), 0);
3492 assert_eq!(diff.deleted.len(), 0);
3493 assert_eq!(diff.changed[0].name, "existing_fn");
3494 assert_eq!(diff.changed[0].old_source, "fn existing_fn() { old }");
3495 assert_eq!(diff.changed[0].new_source, "fn existing_fn() { new }");
3496 assert_eq!(
3497 diff.changed[0].id.file,
3498 PathBuf::from("src/main.rs"),
3499 "FunctionId file should be relative"
3500 );
3501 }
3502
3503 #[test]
3504 fn test_build_function_diff_from_ast_changes_delete() {
3505 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3506
3507 let project = PathBuf::from("/project");
3508 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3509 all_diffs.insert(
3510 PathBuf::from("/project/src/old.rs"),
3511 vec![ASTChange {
3512 change_type: ChangeType::Delete,
3513 node_kind: NodeKind::Function,
3514 name: Some("removed_fn".to_string()),
3515 old_location: Some(Location::new("src/old.rs", 20)),
3516 new_location: None,
3517 old_text: Some("fn removed_fn() { }".to_string()),
3518 new_text: None,
3519 similarity: None,
3520 children: None,
3521 base_changes: None,
3522 }],
3523 );
3524
3525 let diff = build_function_diff(&all_diffs, &project);
3526
3527 assert_eq!(diff.deleted.len(), 1, "Should have 1 deleted function");
3528 assert_eq!(diff.changed.len(), 0);
3529 assert_eq!(diff.inserted.len(), 0);
3530 assert_eq!(diff.deleted[0].name, "removed_fn");
3531 assert_eq!(
3532 diff.deleted[0].id.file,
3533 PathBuf::from("src/old.rs"),
3534 "FunctionId file should be relative"
3535 );
3536 }
3537
3538 #[test]
3539 fn test_build_function_diff_skips_non_function_nodes() {
3540 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3541
3542 let project = PathBuf::from("/project");
3543 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3544 all_diffs.insert(
3545 PathBuf::from("/project/src/lib.rs"),
3546 vec![
3547 ASTChange {
3549 change_type: ChangeType::Insert,
3550 node_kind: NodeKind::Class,
3551 name: Some("MyClass".to_string()),
3552 old_location: None,
3553 new_location: Some(Location::new("src/lib.rs", 1)),
3554 old_text: None,
3555 new_text: Some("class MyClass {}".to_string()),
3556 similarity: None,
3557 children: None,
3558 base_changes: None,
3559 },
3560 ASTChange {
3562 change_type: ChangeType::Update,
3563 node_kind: NodeKind::Statement,
3564 name: Some("let x".to_string()),
3565 old_location: Some(Location::new("src/lib.rs", 10)),
3566 new_location: Some(Location::new("src/lib.rs", 10)),
3567 old_text: Some("let x = 1;".to_string()),
3568 new_text: Some("let x = 2;".to_string()),
3569 similarity: None,
3570 children: None,
3571 base_changes: None,
3572 },
3573 ASTChange {
3575 change_type: ChangeType::Insert,
3576 node_kind: NodeKind::Function,
3577 name: Some("real_fn".to_string()),
3578 old_location: None,
3579 new_location: Some(Location::new("src/lib.rs", 20)),
3580 old_text: None,
3581 new_text: Some("fn real_fn() {}".to_string()),
3582 similarity: None,
3583 children: None,
3584 base_changes: None,
3585 },
3586 ],
3587 );
3588
3589 let diff = build_function_diff(&all_diffs, &project);
3590
3591 assert_eq!(
3592 diff.inserted.len(),
3593 1,
3594 "Only function/method nodes should be included"
3595 );
3596 assert_eq!(diff.inserted[0].name, "real_fn");
3597 }
3598
3599 #[test]
3600 fn test_build_function_diff_includes_method_nodes() {
3601 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3602
3603 let project = PathBuf::from("/project");
3604 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3605 all_diffs.insert(
3606 PathBuf::from("/project/src/impl.rs"),
3607 vec![ASTChange {
3608 change_type: ChangeType::Update,
3609 node_kind: NodeKind::Method,
3610 name: Some("MyStruct::do_thing".to_string()),
3611 old_location: Some(Location::new("src/impl.rs", 15)),
3612 new_location: Some(Location::new("src/impl.rs", 15)),
3613 old_text: Some("fn do_thing(&self) { old }".to_string()),
3614 new_text: Some("fn do_thing(&self) { new }".to_string()),
3615 similarity: None,
3616 children: None,
3617 base_changes: None,
3618 }],
3619 );
3620
3621 let diff = build_function_diff(&all_diffs, &project);
3622
3623 assert_eq!(diff.changed.len(), 1, "Method nodes should be included");
3624 assert_eq!(diff.changed[0].name, "MyStruct::do_thing");
3625 }
3626
3627 #[test]
3628 fn test_build_function_diff_skips_unnamed_changes() {
3629 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3630
3631 let project = PathBuf::from("/project");
3632 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3633 all_diffs.insert(
3634 PathBuf::from("/project/src/lib.rs"),
3635 vec![ASTChange {
3636 change_type: ChangeType::Insert,
3637 node_kind: NodeKind::Function,
3638 name: None, old_location: None,
3640 new_location: Some(Location::new("src/lib.rs", 1)),
3641 old_text: None,
3642 new_text: Some("fn() {}".to_string()),
3643 similarity: None,
3644 children: None,
3645 base_changes: None,
3646 }],
3647 );
3648
3649 let diff = build_function_diff(&all_diffs, &project);
3650
3651 assert_eq!(
3652 diff.inserted.len(),
3653 0,
3654 "Unnamed function changes should be skipped"
3655 );
3656 }
3657
3658 #[test]
3659 fn test_build_function_diff_move_with_both_texts_becomes_update() {
3660 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3661
3662 let project = PathBuf::from("/project");
3663 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3664 all_diffs.insert(
3665 PathBuf::from("/project/src/lib.rs"),
3666 vec![ASTChange {
3667 change_type: ChangeType::Move,
3668 node_kind: NodeKind::Function,
3669 name: Some("moved_fn".to_string()),
3670 old_location: Some(Location::new("src/lib.rs", 10)),
3671 new_location: Some(Location::new("src/lib.rs", 50)),
3672 old_text: Some("fn moved_fn() { a }".to_string()),
3673 new_text: Some("fn moved_fn() { b }".to_string()),
3674 similarity: Some(0.9),
3675 children: None,
3676 base_changes: None,
3677 }],
3678 );
3679
3680 let diff = build_function_diff(&all_diffs, &project);
3681
3682 assert_eq!(
3683 diff.changed.len(),
3684 1,
3685 "Move with both old/new text should become a changed function"
3686 );
3687 assert_eq!(diff.changed[0].name, "moved_fn");
3688 assert_eq!(diff.changed[0].old_source, "fn moved_fn() { a }");
3689 assert_eq!(diff.changed[0].new_source, "fn moved_fn() { b }");
3690 }
3691
3692 #[test]
3693 fn test_build_function_diff_multiple_files() {
3694 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3695
3696 let project = PathBuf::from("/project");
3697 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3698 all_diffs.insert(
3699 PathBuf::from("/project/src/a.rs"),
3700 vec![ASTChange {
3701 change_type: ChangeType::Insert,
3702 node_kind: NodeKind::Function,
3703 name: Some("fn_a".to_string()),
3704 old_location: None,
3705 new_location: Some(Location::new("src/a.rs", 1)),
3706 old_text: None,
3707 new_text: Some("fn fn_a() {}".to_string()),
3708 similarity: None,
3709 children: None,
3710 base_changes: None,
3711 }],
3712 );
3713 all_diffs.insert(
3714 PathBuf::from("/project/src/b.rs"),
3715 vec![ASTChange {
3716 change_type: ChangeType::Delete,
3717 node_kind: NodeKind::Method,
3718 name: Some("fn_b".to_string()),
3719 old_location: Some(Location::new("src/b.rs", 5)),
3720 new_location: None,
3721 old_text: Some("fn fn_b() {}".to_string()),
3722 new_text: None,
3723 similarity: None,
3724 children: None,
3725 base_changes: None,
3726 }],
3727 );
3728
3729 let diff = build_function_diff(&all_diffs, &project);
3730
3731 assert_eq!(diff.inserted.len(), 1, "Should have insert from a.rs");
3732 assert_eq!(diff.deleted.len(), 1, "Should have delete from b.rs");
3733 assert_eq!(diff.inserted[0].name, "fn_a");
3734 assert_eq!(diff.deleted[0].name, "fn_b");
3735 }
3736
3737 #[test]
3738 fn test_build_function_diff_empty_input() {
3739 let project = PathBuf::from("/project");
3740 let all_diffs: HashMap<PathBuf, Vec<crate::commands::remaining::types::ASTChange>> =
3741 HashMap::new();
3742
3743 let diff = build_function_diff(&all_diffs, &project);
3744
3745 assert_eq!(diff.changed.len(), 0);
3746 assert_eq!(diff.inserted.len(), 0);
3747 assert_eq!(diff.deleted.len(), 0);
3748 }
3749
3750 #[test]
3751 fn test_build_function_diff_path_already_relative() {
3752 use crate::commands::remaining::types::{ASTChange, ChangeType, NodeKind, Location};
3753
3754 let project = PathBuf::from("/project");
3757 let mut all_diffs: HashMap<PathBuf, Vec<ASTChange>> = HashMap::new();
3758 all_diffs.insert(
3759 PathBuf::from("src/lib.rs"), vec![ASTChange {
3761 change_type: ChangeType::Insert,
3762 node_kind: NodeKind::Function,
3763 name: Some("f".to_string()),
3764 old_location: None,
3765 new_location: Some(Location::new("src/lib.rs", 1)),
3766 old_text: None,
3767 new_text: Some("fn f() {}".to_string()),
3768 similarity: None,
3769 children: None,
3770 base_changes: None,
3771 }],
3772 );
3773
3774 let diff = build_function_diff(&all_diffs, &project);
3775
3776 assert_eq!(diff.inserted.len(), 1);
3777 assert_eq!(diff.inserted[0].id.file, PathBuf::from("src/lib.rs"));
3779 }
3780
3781 #[test]
3790 fn test_bugbot_finds_real_bugs() {
3791 use crate::commands::bugbot::l2::context::FunctionDiff;
3792 use crate::commands::bugbot::l2::{l2_engine_registry, L2Context};
3793 use std::collections::HashMap;
3794
3795 let simple_src = "def process(x):\n return x + 1\n";
3800 let complex_src = r#"def process(x):
3801 if x > 10:
3802 if x > 20:
3803 if x > 30:
3804 return x * 3
3805 elif x > 25:
3806 return x * 2
3807 else:
3808 return x
3809 elif x > 15:
3810 return x - 1
3811 else:
3812 return x + 1
3813 else:
3814 return 0
3815"#;
3816
3817 let file = PathBuf::from("src/process.py");
3818
3819 let mut baseline_contents: HashMap<PathBuf, String> = HashMap::new();
3820 let mut current_contents: HashMap<PathBuf, String> = HashMap::new();
3821 baseline_contents.insert(file.clone(), simple_src.to_string());
3822 current_contents.insert(file.clone(), complex_src.to_string());
3823
3824 let ctx = L2Context::new(
3825 PathBuf::from("/tmp/bugbot-simulation"),
3826 Language::Python,
3827 vec![file],
3828 FunctionDiff {
3829 changed: vec![],
3830 inserted: vec![],
3831 deleted: vec![],
3832 },
3833 baseline_contents,
3834 current_contents,
3835 HashMap::new(),
3836 );
3837
3838 let all_engines = l2_engine_registry();
3842 let (findings, results) = run_l2_engines(&ctx, &all_engines);
3843
3844 let has_finding = |finding_type: &str| -> bool {
3848 findings.iter().any(|f| f.finding_type == finding_type)
3849 };
3850
3851 assert!(
3852 has_finding("complexity-increase"),
3853 "Expected complexity-increase finding. Got: {:?}",
3854 findings
3855 .iter()
3856 .map(|f| format!("{}:{}", f.finding_type, f.function))
3857 .collect::<Vec<_>>()
3858 );
3859
3860 eprintln!("\n=== Bugbot Simulation Results ===");
3864 eprintln!("Total findings: {}", findings.len());
3865 for engine_result in &results {
3866 eprintln!(
3867 " {}: {} findings ({}ms, analyzed={}, skipped={})",
3868 engine_result.name,
3869 engine_result.finding_count,
3870 engine_result.duration_ms,
3871 engine_result.functions_analyzed,
3872 engine_result.functions_skipped,
3873 );
3874 }
3875
3876 assert_eq!(
3882 results.len(),
3883 all_engines.len(),
3884 "Every engine must produce a result"
3885 );
3886
3887 assert!(
3889 !findings.is_empty(),
3890 "Expected at least 1 finding from buggy code, got 0"
3891 );
3892
3893 let total_from_results: usize = results.iter().map(|r| r.finding_count).sum();
3895 assert_eq!(
3896 findings.len(),
3897 total_from_results,
3898 "Merged findings count must equal sum of per-engine finding counts"
3899 );
3900 }
3901}