1use std::path::Path;
16use std::process::{Command, Stdio};
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21use super::parsers;
22use super::tools::{L1Finding, ToolConfig, ToolResult};
23
24fn kill_process_by_id(pid: u32) {
31 #[cfg(unix)]
32 {
33 unsafe {
37 libc::kill(pid as libc::pid_t, libc::SIGKILL);
38 }
39 }
40 #[cfg(windows)]
41 {
42 unsafe {
45 let handle = windows_sys::Win32::System::Threading::OpenProcess(
46 windows_sys::Win32::System::Threading::PROCESS_TERMINATE,
47 0, pid,
49 );
50 if handle != 0 {
51 windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
52 windows_sys::Win32::Foundation::CloseHandle(handle);
53 }
54 }
55 }
56 #[cfg(not(any(unix, windows)))]
57 {
58 eprintln!("bugbot: cannot kill process {} on this platform", pid);
62 }
63}
64
65pub const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024; pub struct ToolRunner {
77 timeout_secs: u64,
79}
80
81impl ToolRunner {
82 pub fn new(timeout_secs: u64) -> Self {
84 Self { timeout_secs }
85 }
86
87 pub fn run_tool(&self, tool: &ToolConfig, project_path: &Path) -> (ToolResult, Vec<L1Finding>) {
98 let start = Instant::now();
99
100 let child = Command::new(tool.binary)
102 .args(tool.args)
103 .current_dir(project_path)
104 .stdout(Stdio::piped())
105 .stderr(Stdio::piped())
106 .spawn();
107
108 let child = match child {
109 Ok(c) => c,
110 Err(e) => {
111 return (
112 ToolResult {
113 name: tool.name.to_string(),
114 category: tool.category,
115 success: false,
116 duration_ms: start.elapsed().as_millis() as u64,
117 finding_count: 0,
118 error: Some(format!(
119 "Failed to spawn '{}': {}",
120 tool.binary, e
121 )),
122 exit_code: None,
123 },
124 vec![],
125 );
126 }
127 };
128
129 let timeout = Duration::from_secs(self.timeout_secs);
134 let child_id = child.id();
135 let timed_out = Arc::new(AtomicBool::new(false));
136 let timed_out_clone = timed_out.clone();
137
138 let _watchdog = std::thread::spawn(move || {
139 std::thread::sleep(timeout);
140 timed_out_clone.store(true, Ordering::SeqCst);
141 kill_process_by_id(child_id);
144 });
145
146 let output = child.wait_with_output();
148 let duration_ms = start.elapsed().as_millis() as u64;
149
150 if timed_out.load(Ordering::SeqCst) {
152 return (
153 ToolResult {
154 name: tool.name.to_string(),
155 category: tool.category,
156 success: false,
157 duration_ms,
158 finding_count: 0,
159 error: Some(format!("Timeout after {}s", self.timeout_secs)),
160 exit_code: None,
161 },
162 vec![],
163 );
164 }
165
166 let (stdout, stderr, exit_code) = match output {
169 Ok(o) => {
170 let raw_stdout = String::from_utf8_lossy(&o.stdout).to_string();
171 let raw_stderr = String::from_utf8_lossy(&o.stderr).to_string();
172 let stdout = if raw_stdout.len() > MAX_OUTPUT_BYTES {
173 let mut truncated = raw_stdout;
174 truncated.truncate(MAX_OUTPUT_BYTES);
175 if let Some(last_newline) = truncated.rfind('\n') {
177 truncated.truncate(last_newline + 1);
178 }
179 truncated
180 } else {
181 raw_stdout
182 };
183 let stderr = if raw_stderr.len() > MAX_OUTPUT_BYTES {
184 let mut truncated = raw_stderr;
185 truncated.truncate(MAX_OUTPUT_BYTES);
186 truncated
187 } else {
188 raw_stderr
189 };
190 (stdout, stderr, o.status.code())
191 }
192 Err(e) => {
193 return (
194 ToolResult {
195 name: tool.name.to_string(),
196 category: tool.category,
197 success: false,
198 duration_ms,
199 finding_count: 0,
200 error: Some(format!("Failed to read output: {}", e)),
201 exit_code: None,
202 },
203 vec![],
204 );
205 }
206 };
207
208 match parsers::parse_tool_output(tool.parser, &stdout) {
210 Ok(mut findings) => {
211 for f in &mut findings {
213 f.tool = tool.name.to_string();
214 }
215 let count = findings.len();
216 (
217 ToolResult {
218 name: tool.name.to_string(),
219 category: tool.category,
220 success: true,
221 duration_ms,
222 finding_count: count,
223 error: None,
224 exit_code,
225 },
226 findings,
227 )
228 }
229 Err(e) => {
230 let error_msg = if stderr.is_empty() {
232 format!("Parse error: {}", e)
233 } else {
234 let truncated = if stderr.len() > 200 {
235 &stderr[..200]
236 } else {
237 &stderr
238 };
239 format!("Parse error: {}. stderr: {}", e, truncated.trim())
240 };
241 (
242 ToolResult {
243 name: tool.name.to_string(),
244 category: tool.category,
245 success: false,
246 duration_ms,
247 finding_count: 0,
248 error: Some(error_msg),
249 exit_code,
250 },
251 vec![],
252 )
253 }
254 }
255 }
256
257 pub fn run_tools_parallel(
265 &self,
266 tools: &[&ToolConfig],
267 project_path: &Path,
268 ) -> (Vec<ToolResult>, Vec<L1Finding>) {
269 if tools.len() <= 1 {
270 return self.run_tools_sequential(tools, project_path);
271 }
272
273 let results: Vec<(usize, ToolResult, Vec<L1Finding>)> = std::thread::scope(|s| {
276 let handles: Vec<_> = tools
277 .iter()
278 .enumerate()
279 .map(|(i, tool)| {
280 let tool_name = tool.name;
281 let tool_category = tool.category;
282 let path = project_path;
283 let handle = s.spawn(move || {
284 let (result, findings) = self.run_tool(tool, path);
285 (i, result, findings)
286 });
287 (handle, i, tool_name, tool_category)
288 })
289 .collect();
290
291 handles
294 .into_iter()
295 .map(|(h, idx, name, category)| {
296 match h.join() {
297 Ok(result) => result,
298 Err(_) => {
299 eprintln!("bugbot: tool thread for '{}' panicked", name);
300 (
301 idx,
302 ToolResult {
303 name: name.to_string(),
304 category,
305 success: false,
306 duration_ms: 0,
307 finding_count: 0,
308 error: Some("Tool thread panicked".to_string()),
309 exit_code: None,
310 },
311 vec![],
312 )
313 }
314 }
315 })
316 .collect()
317 });
318
319 let mut sorted = results;
321 sorted.sort_by_key(|(i, _, _)| *i);
322
323 let mut all_results = Vec::new();
324 let mut all_findings = Vec::new();
325 for (_idx, result, findings) in sorted {
326 all_results.push(result);
327 all_findings.extend(findings);
328 }
329
330 (all_results, all_findings)
331 }
332
333 fn run_tools_sequential(
335 &self,
336 tools: &[&ToolConfig],
337 project_path: &Path,
338 ) -> (Vec<ToolResult>, Vec<L1Finding>) {
339 let mut all_results = Vec::new();
340 let mut all_findings = Vec::new();
341 for tool in tools {
342 let (result, findings) = self.run_tool(tool, project_path);
343 all_results.push(result);
344 all_findings.extend(findings);
345 }
346 (all_results, all_findings)
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::commands::bugbot::tools::ToolCategory;
354
355 fn make_tool(
358 name: &'static str,
359 binary: &'static str,
360 args: &'static [&'static str],
361 parser: &'static str,
362 category: ToolCategory,
363 ) -> ToolConfig {
364 ToolConfig {
365 name,
366 binary,
367 detection_binary: binary,
368 args,
369 category,
370 parser,
371 }
372 }
373
374 #[test]
379 fn test_run_tool_binary_not_found() {
380 let runner = ToolRunner::new(10);
381 let tool = make_tool(
382 "missing-tool",
383 "nonexistent-binary-xyz-12345",
384 &[],
385 "cargo",
386 ToolCategory::Linter,
387 );
388
389 let (result, findings) = runner.run_tool(&tool, Path::new("."));
390
391 assert!(!result.success, "should fail for missing binary");
392 assert!(
393 result.error.is_some(),
394 "should have error message"
395 );
396 let err = result.error.as_ref().unwrap();
397 assert!(
398 err.contains("spawn") || err.contains("not found") || err.contains("No such file"),
399 "error should mention spawn failure, got: {}",
400 err
401 );
402 assert!(findings.is_empty(), "no findings for missing binary");
403 assert_eq!(result.name, "missing-tool");
404 assert_eq!(result.finding_count, 0);
405 assert!(result.exit_code.is_none());
406 }
407
408 #[test]
413 fn test_run_tool_timeout() {
414 let runner = ToolRunner::new(1); let tool = make_tool(
416 "sleeper",
417 "sleep",
418 &["10"], "cargo",
420 ToolCategory::Linter,
421 );
422
423 let start = Instant::now();
424 let (result, findings) = runner.run_tool(&tool, Path::new("."));
425 let elapsed = start.elapsed();
426
427 assert!(!result.success, "should fail on timeout");
428 assert!(
429 result.error.as_ref().unwrap().contains("imeout"),
430 "error should mention timeout, got: {:?}",
431 result.error
432 );
433 assert!(findings.is_empty(), "no findings on timeout");
434 assert!(
436 elapsed.as_secs() < 5,
437 "should have been killed within ~1s, took {:?}",
438 elapsed
439 );
440 assert!(
441 result.duration_ms >= 900,
442 "should have waited at least ~1s, got {}ms",
443 result.duration_ms
444 );
445 }
446
447 const SH_CMD_ONE_WARNING: &str = concat!(
457 "printf '%s\\n' '",
458 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/test/Cargo.toml","#,
459 r#""target":{"kind":["lib"],"crate_types":["lib"],"name":"test","src_path":"/test/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"#,
460 r#""message":{"rendered":"warning: unused","children":[],"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable","#,
461 r#""spans":[{"byte_end":100,"byte_start":99,"column_end":10,"column_start":9,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":null,"line_end":10,"line_start":10,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
462 "'",
463 );
464
465 #[test]
466 fn test_run_tool_injects_tool_name() {
467 let runner = ToolRunner::new(10);
468 let tool = make_tool(
469 "test-clippy",
470 "sh",
471 &["-c", SH_CMD_ONE_WARNING],
472 "cargo",
473 ToolCategory::Linter,
474 );
475
476 let (result, findings) = runner.run_tool(&tool, Path::new("."));
477
478 assert!(result.success, "tool should succeed, error: {:?}", result.error);
479 assert_eq!(findings.len(), 1, "should have 1 finding");
480 assert_eq!(
481 findings[0].tool, "test-clippy",
482 "PM-6: tool name should be injected by runner, got: '{}'",
483 findings[0].tool
484 );
485 }
486
487 #[test]
492 fn test_run_tool_parse_error_captured() {
493 let runner = ToolRunner::new(10);
496 let tool = make_tool(
497 "bad-output",
498 "echo",
499 &["this is not valid json at all"],
500 "cargo-audit", ToolCategory::SecurityScanner,
502 );
503
504 let (result, findings) = runner.run_tool(&tool, Path::new("."));
505
506 assert!(!result.success, "should fail on parse error");
507 assert!(
508 result.error.as_ref().unwrap().contains("Parse error"),
509 "error should mention parse error, got: {:?}",
510 result.error
511 );
512 assert!(findings.is_empty(), "no findings on parse error");
513 }
514
515 #[test]
520 fn test_run_tools_parallel_failure_isolation() {
521 let runner = ToolRunner::new(10);
522
523 let tool_a = make_tool(
525 "tool-a",
526 "echo",
527 &[""],
528 "cargo",
529 ToolCategory::Linter,
530 );
531
532 let tool_b = make_tool(
534 "tool-b",
535 "nonexistent-binary-xyz-12345",
536 &[],
537 "cargo",
538 ToolCategory::Linter,
539 );
540
541 let tools: Vec<&ToolConfig> = vec![&tool_a, &tool_b];
542 let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
543
544 assert_eq!(results.len(), 2, "should have 2 results");
545 assert!(
546 results[0].success,
547 "tool-a should succeed, error: {:?}",
548 results[0].error
549 );
550 assert!(!results[1].success, "tool-b should fail");
551 assert_eq!(results[0].name, "tool-a");
552 assert_eq!(results[1].name, "tool-b");
553 }
554
555 #[test]
560 fn test_run_tools_parallel_deterministic_order() {
561 let runner = ToolRunner::new(10);
562
563 let tool_alpha = make_tool(
564 "alpha",
565 "echo",
566 &[""],
567 "cargo",
568 ToolCategory::Linter,
569 );
570 let tool_beta = make_tool(
571 "beta",
572 "echo",
573 &[""],
574 "cargo",
575 ToolCategory::Linter,
576 );
577 let tool_gamma = make_tool(
578 "gamma",
579 "echo",
580 &[""],
581 "cargo",
582 ToolCategory::Linter,
583 );
584
585 let tools: Vec<&ToolConfig> = vec![&tool_alpha, &tool_beta, &tool_gamma];
586 let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
587
588 assert_eq!(results.len(), 3);
589 assert_eq!(
590 results[0].name, "alpha",
591 "first result should be alpha, got {}",
592 results[0].name
593 );
594 assert_eq!(
595 results[1].name, "beta",
596 "second result should be beta, got {}",
597 results[1].name
598 );
599 assert_eq!(
600 results[2].name, "gamma",
601 "third result should be gamma, got {}",
602 results[2].name
603 );
604 }
605
606 #[test]
611 fn test_run_tools_sequential_for_single_tool() {
612 let runner = ToolRunner::new(10);
613 let tool = make_tool(
614 "solo",
615 "echo",
616 &[""],
617 "cargo",
618 ToolCategory::Linter,
619 );
620
621 let tools: Vec<&ToolConfig> = vec![&tool];
622 let (results, findings) = runner.run_tools_parallel(&tools, Path::new("."));
623
624 assert_eq!(results.len(), 1);
625 assert!(results[0].success);
626 assert_eq!(results[0].name, "solo");
627 assert!(findings.is_empty(), "empty echo -> no cargo findings");
628 }
629
630 const SH_CMD_WARNING_EXIT1: &str = concat!(
636 "printf '%s\\n' '",
637 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/test/Cargo.toml","#,
638 r#""target":{"kind":["lib"],"crate_types":["lib"],"name":"test","src_path":"/test/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"#,
639 r#""message":{"rendered":"warning: unused","children":[],"code":{"code":"unused_variables","explanation":null},"level":"warning","message":"unused variable","#,
640 r#""spans":[{"byte_end":100,"byte_start":99,"column_end":10,"column_start":9,"expansion":null,"file_name":"src/main.rs","is_primary":true,"label":null,"line_end":10,"line_start":10,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
641 "'; exit 1",
642 );
643
644 #[test]
645 fn test_run_tool_nonzero_exit_with_parseable_output() {
646 let runner = ToolRunner::new(10);
648 let tool = make_tool(
649 "linter-with-findings",
650 "sh",
651 &["-c", SH_CMD_WARNING_EXIT1],
652 "cargo",
653 ToolCategory::Linter,
654 );
655
656 let (result, findings) = runner.run_tool(&tool, Path::new("."));
657
658 assert!(
659 result.success,
660 "non-zero exit with parseable output should be success, error: {:?}",
661 result.error
662 );
663 assert_eq!(
664 result.exit_code,
665 Some(1),
666 "exit code should be captured as 1"
667 );
668 assert_eq!(findings.len(), 1, "should have parsed 1 finding");
669 assert_eq!(
670 findings[0].tool, "linter-with-findings",
671 "tool name should be injected"
672 );
673 }
674
675 #[test]
680 fn test_run_tools_parallel_empty_list() {
681 let runner = ToolRunner::new(10);
682 let tools: Vec<&ToolConfig> = vec![];
683 let (results, findings) = runner.run_tools_parallel(&tools, Path::new("."));
684
685 assert!(results.is_empty(), "no tools = no results");
686 assert!(findings.is_empty(), "no tools = no findings");
687 }
688
689 #[test]
694 fn test_run_tool_success_echo_empty_output() {
695 let runner = ToolRunner::new(10);
697 let tool = make_tool(
698 "echo-tool",
699 "echo",
700 &[""],
701 "cargo",
702 ToolCategory::Linter,
703 );
704
705 let (result, findings) = runner.run_tool(&tool, Path::new("."));
706
707 assert!(result.success, "echo should succeed, error: {:?}", result.error);
708 assert_eq!(result.finding_count, 0);
709 assert!(findings.is_empty());
710 assert!(result.error.is_none());
711 assert_eq!(result.name, "echo-tool");
712 }
713
714 #[test]
719 fn test_run_tool_tracks_duration() {
720 let runner = ToolRunner::new(10);
721 let tool = make_tool(
722 "timer-test",
723 "echo",
724 &["hello"],
725 "cargo",
726 ToolCategory::Linter,
727 );
728
729 let (result, _findings) = runner.run_tool(&tool, Path::new("."));
730
731 assert!(
734 result.duration_ms < 5000,
735 "echo should complete in well under 5s, got {}ms",
736 result.duration_ms
737 );
738 }
739
740 #[test]
745 fn test_run_tool_preserves_category() {
746 let runner = ToolRunner::new(10);
747 let tool = make_tool(
748 "security-tool",
749 "echo",
750 &[""],
751 "cargo",
752 ToolCategory::SecurityScanner,
753 );
754
755 let (result, _findings) = runner.run_tool(&tool, Path::new("."));
756
757 assert_eq!(
758 result.category,
759 ToolCategory::SecurityScanner,
760 "category should be preserved from ToolConfig"
761 );
762 }
763
764 #[test]
769 fn test_run_tool_build_finished_only() {
770 let runner = ToolRunner::new(10);
772 let tool = make_tool(
773 "cargo-noop",
774 "echo",
775 &[r#"{"reason":"build-finished","success":true}"#],
776 "cargo",
777 ToolCategory::Linter,
778 );
779
780 let (result, findings) = runner.run_tool(&tool, Path::new("."));
781
782 assert!(result.success, "valid output should succeed");
783 assert_eq!(result.finding_count, 0, "build-finished is not a finding");
784 assert!(findings.is_empty());
785 }
786
787 #[test]
792 fn test_run_tool_unknown_parser() {
793 let runner = ToolRunner::new(10);
794 let tool = make_tool(
795 "bad-parser",
796 "echo",
797 &["some output"],
798 "nonexistent-parser",
799 ToolCategory::Linter,
800 );
801
802 let (result, findings) = runner.run_tool(&tool, Path::new("."));
803
804 assert!(!result.success, "unknown parser should fail");
805 assert!(
806 result.error.as_ref().unwrap().contains("Parse error"),
807 "should mention parse error, got: {:?}",
808 result.error
809 );
810 assert!(findings.is_empty());
811 }
812
813 const SH_CMD_TWO_WARNINGS: &str = concat!(
819 "printf '%s\\n' '",
820 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/t/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"t","src_path":"/t/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"message":{"rendered":"w","children":[],"code":{"code":"W1","explanation":null},"level":"warning","message":"warning one","spans":[{"byte_end":10,"byte_start":1,"column_end":5,"column_start":1,"expansion":null,"file_name":"src/a.rs","is_primary":true,"label":null,"line_end":1,"line_start":1,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
821 "'; printf '%s\\n' '",
822 r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/t/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"t","src_path":"/t/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"message":{"rendered":"e","children":[],"code":{"code":"E1","explanation":null},"level":"error","message":"error one","spans":[{"byte_end":20,"byte_start":11,"column_end":8,"column_start":3,"expansion":null,"file_name":"src/b.rs","is_primary":true,"label":null,"line_end":5,"line_start":5,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#,
823 "'; printf '%s\\n' '",
824 r#"{"reason":"build-finished","success":true}"#,
825 "'",
826 );
827
828 #[test]
829 fn test_run_tool_multiple_findings_all_have_tool_name() {
830 let runner = ToolRunner::new(10);
831 let tool = make_tool(
832 "multi-finder",
833 "sh",
834 &["-c", SH_CMD_TWO_WARNINGS],
835 "cargo",
836 ToolCategory::Linter,
837 );
838
839 let (result, findings) = runner.run_tool(&tool, Path::new("."));
840
841 assert!(result.success, "should succeed, error: {:?}", result.error);
842 assert_eq!(findings.len(), 2, "should have 2 findings");
843 for (i, f) in findings.iter().enumerate() {
844 assert_eq!(
845 f.tool, "multi-finder",
846 "PM-6: finding[{}].tool should be 'multi-finder', got '{}'",
847 i, f.tool
848 );
849 }
850 }
851
852 #[test]
857 fn test_max_output_bytes_constant_exists() {
858 let max_output_bytes = std::hint::black_box(super::MAX_OUTPUT_BYTES);
860 assert!(
861 max_output_bytes > 0,
862 "MAX_OUTPUT_BYTES should be a positive constant"
863 );
864 assert!(
865 max_output_bytes >= 1_000_000,
866 "MAX_OUTPUT_BYTES should be at least 1MB, got {}",
867 max_output_bytes
868 );
869 }
870
871 #[test]
872 fn test_large_stdout_is_truncated() {
873 let runner = ToolRunner::new(10);
876
877 let single_line = r#"{"reason":"compiler-message","package_id":"test 0.1.0","manifest_path":"/t/Cargo.toml","target":{"kind":["lib"],"crate_types":["lib"],"name":"t","src_path":"/t/src/lib.rs","edition":"2021","doc":false,"doctest":false,"test":false},"message":{"rendered":"w","children":[],"code":{"code":"W1","explanation":null},"level":"warning","message":"warning","spans":[{"byte_end":10,"byte_start":1,"column_end":5,"column_start":1,"expansion":null,"file_name":"src/a.rs","is_primary":true,"label":null,"line_end":1,"line_start":1,"suggested_replacement":null,"suggestion_applicability":null,"text":[]}]}}"#;
881
882 let line_count = (super::MAX_OUTPUT_BYTES / single_line.len()) + 100;
883
884 let sh_cmd = format!(
886 "for i in $(seq 1 {}); do printf '%s\\n' '{}'; done",
887 line_count, single_line
888 );
889
890 let tool = make_tool(
891 "large-output",
892 "sh",
893 Box::leak(Box::new(["-c", Box::leak(sh_cmd.into_boxed_str()) as &str])) as &[&str],
895 "cargo",
896 ToolCategory::Linter,
897 );
898
899 let (result, _findings) = runner.run_tool(&tool, Path::new("."));
900
901 assert!(result.success, "should succeed even with truncated output, error: {:?}", result.error);
903 }
904
905 #[test]
910 fn test_thread_panic_does_not_propagate() {
911 let runner = ToolRunner::new(10);
917
918 let tool_a = make_tool(
919 "good-tool",
920 "echo",
921 &[""],
922 "cargo",
923 ToolCategory::Linter,
924 );
925
926 let tool_b = make_tool(
928 "bad-tool",
929 "nonexistent-binary-xyz-12345",
930 &[],
931 "cargo",
932 ToolCategory::Linter,
933 );
934
935 let tools: Vec<&ToolConfig> = vec![&tool_a, &tool_b];
936 let (results, _findings) = runner.run_tools_parallel(&tools, Path::new("."));
937
938 assert_eq!(results.len(), 2, "should have results for both tools");
940 }
941}